多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

法研杯相似案例匹配实战:长文本判决书处理与BERT模型优化

法研杯相似案例匹配实战:长文本判决书处理与BERT模型优化 简介面向法律人工智能与自然语言处理竞赛选手这是一份法研杯2019相似案例匹配第二名解决方案压缩包整合了CAIL2019竞赛的代码、数据集与说明文档可用于复现相似案例匹配模型并衔接司法考试赛道相关任务。包体共22个文件以Python源码脚本、Shell脚本、Dockerfile部署配置及Markdown与文本说明为主整体仅192KB轻量但结构清晰代码包含训练、预测、模型定义、数据读取等模块并提供Docker部署配置方便快速搭建环境。项目覆盖文本预处理、深度学习语义表示、特征工程与模型调优等完整链路同时附有数据集和文档便于对照实验。通过阅读代码与文档可以掌握数据划分、模型构建、结果评测的竞赛思路理解法律文本匹配的难点与解决方案。目前已有266人学习适合希望入门法律NLP或准备CAIL系列比赛的研究者与工程师。1. 法研杯2019相似案例匹配一场从“翻车”到“第二名”的持久战法研杯2019相似案例匹配第二名解决方案这个标题我第一次看到时以为又是一份把BERT调到最优的竞赛代码包真正按这个方向跑完一轮才发现胜负手根本不在模型结构而在数据管线和证据段落的定位。任务本身不复杂给定一个查询案件A和两个候选案件B、C判断哪个和A更相似。但判决书动辄几千字而当时的预训练模型只能吃512个token这中间的落差才是所有名次差的来源。这篇笔记我会按竞赛到落地的顺序把数据处理、模型选型、参数调节和几类典型踩坑拆开讲适合正在做法律NLP、类案检索的工程师也适合准备打CAIL类比赛想少走弯路的人。2. 相似案例匹配的数据底座清洗、分段与三元组构造2.1 CAIL2019的SCM任务定义与数据格式法研杯CAIL2019里的相似案例匹配任务是公开数据集里最典型的长文本语义匹配场景之一。公开训练集在九千组左右每组包含三个字段A、B、C分别是三个案件文本外加一个label。label为1表示A与B更相似为0表示A与C更相似。评测时逐组判断最后算整体准确率。数据本身是判决书文本不是经过预处理的短句含有大量当事人信息、诉讼请求、证据罗列和落款。拿到这种原始JSON文本第一个直觉是把全文塞进模型但现实是2019年的中文BERT基本都卡在512个token上限。一份判决书少说三千字多的上万字直接截前512个字符会让模型只看到“原告××公司”“被告××局”这种没有任何区分度的头部信息。当时绝大多数队伍在这里翻车不是模型能力不够是根本没意识到判决书的有效信息分布极度不均匀。方案包里如果带了整理好的数据集和文档第一件事应该就是看它给出的清理和分段策略这往往比模型代码更值钱。2.2 从判决书到训练样本清洗、分段与三元组构造以我的处理经验判决书里有价值的部分集中在两个区域一是“本院认为”后面的法理分析二是“判决如下”的裁判主文。相似案例的相似性主要来自这两个区域里的事实认定、案由、争议焦点和裁判结果。所以数据清洗的首要原则是不要试图保留全文而是把文本按判决书的法定结构切成四个证据段落当事人信息、查明事实、法院认为、裁判结果。切分用正则匹配锚点即可判决书的格式在司法实践中相对统一出错率不高。import json import re def parse_judgment(text): 把判决书切成四段保留各段文本用于后续拼接 patterns { head: r^(.{0,200}?[号字案]), facts: r(经审理查明|认定如下|查明以下事实), reason: r(本院认为|本院经审查认为|裁判理由), verdict: r(判决如下|裁定如下|裁判结果) } segments {} pos 0 for name, pattern in patterns.items(): match re.search(pattern, text) if match: segments[name] text[pos:match.start()].strip() pos match.start() else: segments[name] segments[tail] text[pos:].strip() if pos len(text) else return segments这段代码的逻辑是按判决书的法定结构定位四个锚点把锚点之前的文本归到对应段落锚点之后的残留文本统一放进tail。切完以后reason和verdict两段是相似判断的核心facts段是辅助head和tail基本可以丢弃。2019年很多方案不做切分直接截前512字符结果模型学到的是“原告公司名称是否相同”这种表层特征榜首和后面的差距就在这里拉开。切分之后要做字符级清洗。判决书里常见“经过合议庭评议”“审判员XXX”“二〇一九年六月一日”这类落款信息还有OCR转写留下的标点错乱。清洗时用白名单策略只保留中文、数字、常见标点其余全部去掉连续空白字符压成一个空格。这一步看着土但对BERT的词表影响极大脏字符会稀释有限的位置编码容量。def build_training_sample(row, tokenizer, max_len512): seg_a parse_judgment(row[A]) seg_b parse_judgment(row[B]) seg_c parse_judgment(row[C]) # 关键段在前次要段在后 text_pairs [] for seg in (seg_a, seg_b, seg_c): text .join([ seg.get(reason, )[:256], seg.get(verdict, )[:128], seg.get(facts, )[:128] ]) text_pairs.append(text) # A与B拼接 tokens_ab tokenizer(text_pairs[0], text_pairs[1], truncationlongest_first, max_lengthmax_len) # A与C拼接 tokens_ac tokenizer(text_pairs[0], text_pairs[2], truncationlongest_first, max_lengthmax_len) label 1 if row[label] 1 else 0 return {ab: tokens_ab, ac: tokens_ac, label: label}截断策略上常见做法是“分段截断”reason段取前256字符verdict段取128字符facts段取128字符拼成512。这样既覆盖关键信息又避免单段过长把其他内容挤出去。构造训练样本时把A分别和B、C拼接成两对输入label直接来自原始标注。这里要特别注意label的语义官方给的是“哪个更相似”不是“相似/不相似”所以哪怕B和C都不太像A模型也要从两者中选出相对更接近的。这也是后来调阈值反复踩坑的根源模型输出0.52和0.49之间的微小差距就是全部意义。做完清洗和构造后顺手统计一下每个案由下的样本量。CAIL2019的数据里借款合同纠纷、婚姻家庭纠纷占了大头知识产权和行政案件稀少。如果不做任何处理模型会倾向于把案件猜成高频案由的相似这是后面要解决的坑之一。3. 模型方案选型从TF-IDF基线到BERT单塔再到大模型融合3.1 基线怎么搭为什么先跑TF-IDF不少拿到数据的人直接上BERT结果跑了半天发现准确率不到70%还以为代码有问题。我习惯先花一小时搭一个TF-IDF基线。把清洗后的文本分词用TF-IDF向量化算A和B、A和C两对余弦相似度谁高就选谁。这个基线在法研杯的长文本数据上我自己的经验跑下来大概七成上下受文本清洗质量影响波动明显。跑基线有三个目的。第一验证数据管线有没有泄漏如果TF-IDF的准确率反而比BERT单塔还高那多半是标签泄漏或者数据集里混入了重复案件先不要急着上模型。第二给出一个性能下限后面模型调不动的时候你知道该不该换方向。第三TF-IDF的bad case可以摊在桌面上逐条看帮助理解这个任务的真实难点绝大多数判错案例要么是事实相似但适用法条不同要么是法条相同但裁判金额计算方式不同这提示我们需要的是语义匹配而不是字面匹配。TF-IDF对这类难点无能为力但它能把问题暴露出来。3.2 BERT类模型的核心改造拼接编码与两分类头2019年那会儿中文预训练模型的选项比今天少得多大家基本在bert-base-chinese和RoBERTa-wwm-ext之间选。这两种模型对法研杯数据都够用难点在于怎么把长文本压进512上限。直接把A和B拼接起来送进BERT是当时的主流做法也就是单塔结构。单塔的好处是A和B在自注意力层能充分交互相似信号不会先被编码成两个独立向量、再拿向量算相似度那样丢失交互信息。import torch import torch.nn as nn from transformers import BertModel class ScmBert(nn.Module): def __init__(self, model_namebert-base-chinese, num_labels2, dropout0.1): super().__init__() self.bert BertModel.from_pretrained(model_name) self.dropout nn.Dropout(dropout) self.classifier nn.Linear(self.bert.config.hidden_size, num_labels) def forward(self, input_ids, attention_mask): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) cls_token outputs.last_hidden_state[:, 0, :] logits self.classifier(self.dropout(cls_token)) return logits这段代码的核心逻辑是输入已经是(A,B)或(A,C)拼接后的token序列BERT编码后取每个序列的第一个token也就是[CLS]的768维向量再用线性层映射成2维logitssoftmax后得到“哪一对更相似”的概率。参数上需要注意两点dropout建议设在0.1到0.3之间太小容易过拟合那九千组训练样本太大又会让后续集成时的单模型性能明显下降num_labels这里是2输出的是AB和AC谁更相似而不是“相似程度”的回归值。这里取[CLS]是当年的标准操作今天再看用均值池化或者对“本院认为”段的token做加权池化通常效果更好因为[CLS]在长文本上会丢失太多位置信息。3.3 多模型融合与置信度校准把第二名推到极限训练单模型到八成附近后进一步提分的手段基本就三样对抗训练、交叉验证融合、伪标签半监督。FGM对抗训练几乎成了这类竞赛的标配它给embedding加上一个和梯度方向一致的扰动让模型对输入的小变化更鲁棒。扰动参数epsilon常见取0.5或1.0我实测0.5在法研杯数据上更稳因为拼接后的文本里噪声多扰动太大会把语义冲散。多模型融合上常用的组合有三种方案。其一按案由拆分成不同fold训五个模型预测时对五个logits取平均其二同一个模型用不同随机种子训两三遍对结果投票其三把bert-base-chinese和RoBERTa-wwm-ext的预测结果做加权平均权重在验证集上网格搜索。融合之后准确率通常比最优单模型再高一到两个百分点已经能挤进榜前几。需要警惕的是融合不一定总变好如果每个单模型的错误高度相关融合几乎无收益判断标准是bad case的重叠度而不是验证集分数。置信度校准是容易被忽视的一环。竞赛只看相对排序模型输出0.51还是0.95不重要只要AB和AC之间相对大小对就行。但落地时法官不可能接受一个51%置信度的推荐。常见做法是保留验证集上的预测logits做Temperature Scaling把概率分布压到一个更极端的范围同时尽量不改变排序。我的体感是在这个任务上数据侧的优化永远比置信度校准优先级高因为绝大部分错误案例是证据段落切分错误导致的模型本身没做错什么。4. 训练细节与参数配置复现这套方案必调的10个参数4.1 数据划分与验证策略按案由划分防止泄漏训练集只有九千组划分验证集时最容易犯的错误是随机划分。CAIL的同一个案件可能以不同角色出现在多组三元组里比如某案作为A出现在一组、又作为B出现在另一组。随机划分会让模型在验证时“见到”训练里出现过的案件文本验证分数虚高线下九成线上直接跌穿。我一般会按案由先分桶再对每个桶内部做分层抽样。更稳妥的做法是构造三元组之前先给每个案件文本计算一个哈希把相同或高度重叠的案件归到同一簇保证一个簇的文本不会同时落在训练集和验证集里。这会让验证集准确率比随机划分低两三个百分点但几乎总是和线上结果更接近。方案包里如果提供了按案由分好组的文档可以直接拿来用没有的话就自己按上面步骤拆。每次改模型前先确认验证集干净否则后面所有调参工作都在一个虚高的地基上盖楼。4.2 关键参数表学习率、warmup、截断长度、FGM参数对结果的影响排序大概是这样的max_len、学习率、batch_size、FGM扰动系数、warmup比例每一个都直接决定训练能否收敛。我在相似案例匹配数据上调参的经验值整理如下注意这是通用经验值不针对某个特定代码包。参数推荐值调节区间说明max_len512384~512短了丢关键信息不要低于384学习率2e-51e-5~4e-5超过4e-5验证集损失容易震荡batch_size168~32显存不够时优先降batch而不是降max_lenwarmup0.10.05~0.2前几百步让学习率线性爬升FGM epsilon0.50.25~1.0只在embedding层加扰动训练轮数32~4九千组数据3轮够用多了过拟合dropout0.10.1~0.3接在[CLS]后面的全连接层权重衰减0.010.01~0.05对BERT原参数生效梯度裁剪1.00.5~2.0防止个别长样本爆梯度混合精度开开/关开fp16省一半显存注意loss scale训练脚本里FGM的写法要特别小心扰动必须在embedding层加而且要在backward之后恢复原参数否则下一个batch的embedding会被污染。下面是一个可以直接用的训练循环片段PyTorch版本。import torch from torch.cuda.amp import GradScaler, autocast def add_fgm_perturbation(model, epsilon0.5, emb_nameword_embeddings): 对embedding参数施加与梯度同向的扰动并保存备份 backups {} for name, param in model.named_parameters(): if param.requires_grad and emb_name in name: backups[name] param.data.clone() grad param.grad if grad is None: continue norm torch.norm(grad, p2) if norm.item() 0: param.data.add_(epsilon * grad / norm) return backups def restore_embedding(model, backups): 恢复被扰动过的embedding参数 for name, param in model.named_parameters(): if name in backups: param.data backups[name] scaler GradScaler() optimizer torch.optim.AdamW(model.parameters(), lr2e-5, weight_decay0.01) for batch in dataloader: input_ids batch[input_ids].cuda() attention_mask batch[attention_mask].cuda() label batch[label].cuda() with autocast(): logits model(input_ids, attention_mask) loss criterion(logits, label) scaler.scale(loss).backward() backups add_fgm_perturbation(model, epsilon0.5) with autocast(): logits_adv model(input_ids, attention_mask) loss_adv criterion(logits_adv, label) scaler.scale(loss_adv).backward() restore_embedding(model, backups) scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) scaler.step(optimizer) scaler.update() optimizer.zero_grad()这段代码的核心是backward被调用了两次。第一次反传得到原始梯度FGM根据这个梯度给embedding加扰动第二次反传制造一个“对抗损失”的梯度然后恢复原始embedding让优化器沿“原始梯度对抗梯度”的方向更新。epsilon0.5意味着扰动范数是当前梯度范数的0.5倍超过1.0时验证集损失会明显回升。注意FGM只改embedding层不碰其他任何参数这是它稳定性的关键。调参顺序不要乱。先固定其他参数只扫学习率确定最佳学习率后再开FGMFGM开了之后验证集如果反而下降优先尝试把epsilon降到0.3而不是直接关掉。max_len的调节永远放在最后因为它直接影响训练速度而且每次改动都要重新清洗数据。4.3 训练过程中的监控指标不要只盯着loss训练时大部分人只盯loss曲线但在这个任务里loss下降不代表准确率上升。原因是label是“二选一”模型只要产生一点点偏向就能把loss压得很低。我建议每个epoch都跑一遍完整验证集记录三个东西整体准确率、按案由分组的准确率标准差、以及预测置信度直方图。按案由分组的准确率标准差是个很灵敏的信号。如果借款合同组准确率九成而婚姻家庭组只有七成说明模型学到的是案由特征而不是相似性特征。置信度直方图则看模型输出概率是集中在0.5附近还是两端前者说明模型在犹豫后者说明它已经“有信心”但信心有可能是错的。如果置信度0.8以上的样本里错误率仍然偏高那要回到数据侧找原因通常是某类案由的样本太少模型把高频词当成了强信号。5. 相似案例匹配的5个常见坑从现象到根因的排查记录5.1 坑一一二审案件混杂导致标签泄漏现象TF-IDF基线跑出来的准确率高得差点追上BERT单塔模型怎么看都不合理。仔细看预测结果凡是判断对的案例三份判决书里都出现了大段重复文本。原因数据集里同一案件的一审判决书和二审判决书被当成两个独立案件使用。二审判决书会大段引用一审判决的“本院认为”字面重叠度极高。模型不需要理解语义只要算文本重合率就能蒙对。解决把判决书的案号字段提取出来做唯一ID同一案号的判决书归并到同一聚类。如果数据里没有案号就用最长公共子串的覆盖率去重覆盖率超过一定阈值的文本对直接合并。这一步做完TF-IDF基线就会掉到正常区间。5.2 坑二长文本截断丢掉了“本院认为”现象模型在验证集上对民间借贷纠纷的案例分析全错预测相似度几乎是随机的。查看bad case发现模型根本没有“看过”判决理由。原因BERT只有512上限最初直接取前512字符。但部分判决书的格式是“当事人信息诉讼请求事实与理由”占满前面大部分篇幅“本院认为”被挤到了512之后模型完全没见过关键段落。解决改成按证据段落截断的策略把reason段和verdict段优先保留再填充facts段。如果reason段本身就超过256字符只取前256。改完这一条民间借贷组的准确率直接从“不及格”恢复到接近整体均值水平。注意优先保留的不是“开头”而是“法理分析”。所有截断函数都应该围绕“本院认为”和裁判主文来设计。5.3 坑三类案分布不均衡模型只会“抄近道”现象整体准确率看着不错但把错误案例按案由分组看知识产权类样本量本来就少预测结果几乎错了一半。原因训练数据里借款合同纠纷近四千组知识产权不足两百组。模型学到的是“原文里出现‘专利权’就偏向选包含更多法律术语的候选”而不是真正的相似性判断因为只要判断对高频案由整体分数就掩盖了这种偏科。解决对低频案由做上采样重复拼接是最简单的办法但不能只重复文本本身还要配合在训练时给这些样本更高的损失权重。另一个有效做法是用TF-IDF召回每个A的最相似案例对低频案由的B和C替换成更难区分的干扰项逼模型学细节区别而不是案由标签。5.4 坑四离线评测分数高线上全面翻车现象本地五折交叉验证平均准确率看着有八五成线上提交却只有七成八落差达到六七个点。反复调试模型结构落差不缩小。原因线下划分时按三元组随机切分同一案件文本在训练集和验证集里都是“见过的”。线上的样本是无泄漏的独立案件模型没见过。另一个隐性原因是线上线下对长文本的清洗管线不一致比如线上数据里有更多OCR噪声和排版异常。解决恢复按案件唯一ID划分之后线下分数立刻降到八成左右再提交线上则基本对齐。这个坑告诉我们在做任何模型改进前先确认验证集划分是干净的。否则后面所有调参都是在虚高分数上自嗨模型一上线就被打回原形。5.5 坑五预训练模型的法律领域迁移不足现象bert-base-chinese在验证集上的准确率来到八成上下之后很难再上涨换各种融合结构都只有微小波动。错误案例集中在法律术语密集的段落。原因通用的中文BERT是在百科、新闻、问答语料上预训练的法律文本里的“本院认为”“驳回上诉”“维持原判”等词频很低它的语言先验不完全适配法律领域。尤其“驳回上诉维持原判”这种固定搭配在通用语料里几乎不会连续出现。解决在CAIL数据上做领域自适应的继续预训练也就是不用下游标签只对全部判决书做MLM任务再加载回下游微调。哪怕只用五千条判决书继续预训练两轮下游准确率也能稳定提升一到两个百分点。这是当年不少队伍在用的招往往比换更大的模型划算因为模型的容量不是瓶颈领域词汇分布才是。6. 从竞赛到落地验证你的模型在真实场景是否可靠6.1 构建自己的“法官一致性”抽样测试集真实落地场景里没有现成的ABC三元组标签给你验证。我的做法是找合作单位的法官或法务从线上案由池里随机抽50组案件请两位专家独立判断“哪两个更相似”然后计算模型结果和两位专家的一致性。如果专家之间的一致性本来就只有85%那模型到80%已经接近天花板别追数字。弱标签也是一种办法把“同案由同一法条裁判结果方向一致”当成相似的正样本虽然噪声大但足以筛掉明显的翻车案例。用这个弱标签集做回归测试每次改完模型跑一遍比盯着验证集分数实在得多。6.2 把相似案例匹配扩展成司法考试式问答CAIL 2020/2021的司法考试赛道把任务从“匹配”升级成了“推理”。选择题给出案情和四个选项正确选项往往需要把案例事实、法条条文和过往判决综合起来才能推出。比赛里那些夺冠团队的底层能力不少正是从相似案例匹配这里迁移来的先做候选法条召回再做类案相似度排序最后让模型在“最相似法条最相似类案”的上下文里完成选择。这套组合对应到真实业务里的类案推荐就是检索层用BM25召回候选精排层用前文讲的BERT单塔做rerank再把每条候选的法条依据抽出来拼到证据上下文里输出的是一个带理由链的推荐结果而不是一个孤零零的相似度分数。6.3 可解释性与上线监控竞赛不会告诉你的是模型输出的相似度分数是相对值不是绝对值。同一个模型对两对不同案件输出的0.55和0.6完全不代表后者更可信。上线时一定要为每个预测保留“A与B的前十大重叠关键词”和“A与C的前三大关键词”这两份材料是算法和业务之间唯一的沟通语言。我习惯把这类系统的日监控分成两层一是分数分布漂移二是每日抽出1%预测结果人工复核并回填错误样本形成数据闭环。这个方向我从数据清洗到调参踩了整整三周最大的教训是法律AI的瓶颈从来不在模型在你愿不愿意把判决书拆到“本院认为”这一层。希望这些细节能帮你少走一点同样的弯路希望帮到你。本文还有配套的精品资源点击获取
返回列表