尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

CAIL 2019相似案例匹配实践:长文本BERT微调与避坑指南

CAIL 2019相似案例匹配实践:长文本BERT微调与避坑指南 简介这份压缩包整合了法研杯2019相似案例匹配第二名及CAIL20202021司法考试赛道冠军团队的技术方案附有数据集与文档面向法律NLP、机器学习和人工智能从业者用于解决裁判文书等法律文本的相似性匹配问题。包内共22个文件以Python脚本为主包含模型定义、训练、预测、数据处理等核心代码另有Shell脚本与Dockerfile便于环境部署Markdown和TXT文档则记录技术细节、依赖清单与实验结果整体仅192KB小而完整。当前已有266人学习浏览。通过源码可以掌握文本预处理、分词去停用词、命名实体识别、预训练模型应用、特征工程、模型调参与评价指标等关键流程也可结合自带数据集和文档复现赛题方案。这套方案展示了AI在司法领域的实际落地路径对智能法律检索、案例分析甚至辅助司法决策都具有直接的参考价值。1. 相似案例匹配在比什么CAIL 2019的三元组任务与赛题真相法研杯2019相似案例匹配赛题本身不复杂给定一个盗窃案的查询文书A和两份候选文书B1、B2判断A与哪一份在案情和量刑情节上更相似提交结果按三元组准确率排名。很多人以为这是普通文本匹配拉个BERT上去微调就行实际差距往往出在数据组织、长文书截断和训练策略上。裁判文书动辄两三千字直接截前512个token“本院认为”里的关键信息会被整段丢掉准确率卡在0.6附近上不去。这篇笔记按“数据处理→模型选型→训练细节→踩坑→进阶”的顺序把一个可复现的解决方案讲清楚适合正在做法律文本处理、长文本分类或者想复现CAIL系列比赛数据集的从业者。2. 把数据集转成模型能吃下的格式三元组构造、划分与评测口径2.1 数据集长什么样A、B1、B2三篇文书的内部结构与误导项CAIL 2019的相似案例匹配数据集核心单位是一个三元组。每个样本包含三份裁判文书查询文书A以及候选文书B1、B2任务就是判断A与B1、B2哪一个在案件事实上更接近。常见的数据集格式是JSON行一行一个三元组字段名在历年版本里略有差异但大致会是a、b1、b2外加一个answer表示正确答案是1还是2。解压出来的压缩包里一般就是train.json、test.json、评测脚本和说明文档这几类文件直接按行读就行。这里有一个容易忽略的设计赛题把罪名字段限定在盗窃罪范围内。这么做的好处是屏蔽了“罪名先验”——模型不需要先判断两篇文书是不是同一类犯罪只需要比较盗窃案内部的细粒度情节比如盗窃金额、作案次数、是否入户、是否累犯。如果数据集混入多个罪名模型很容易靠“都是抢劫罪所以相似”这种偷懒信号拿分反而掩盖了真实匹配能力。文书内部结构也有规律。一份刑事判决书大致由被告人基本情况、公诉机关指控、经审理查明、本院认为、判决主文组成。“经审理查明”和“本院认为”这两段是信息密度最高的地方前者讲犯罪事实细节后者讲量刑理由和法律适用。而开头一大段被告人身份信息、案号、公诉机关名称对“相似度判断”几乎是噪声。第一次接触这个数据集时我直接把全文档塞给BERT512上限根本装不下于是被迫认真考虑“到底哪一段才是模型真正该看的”。2.2 评测指标取舍为什么只有三元组准确率以及线下该怎么算赛题官方指标是Accuracy也就是预测正确的三元组数除以总三元组数。这个口径对训练目标影响很大模型要做的是“两两比较正确”而不是“相似度分数绝对精确”。换句话说哪怕模型给B1打了0.8分、给B2打了0.79分只要B1是正确答案这个三元组就算对反过来如果模型给两个候选打了0.95和0.90但选错了损失一样惨重。所以训练时用二分类交叉熵来拉大“当前候选是否与查询更相似”的概率差比用MSE去拟合一个相似度分数更直接。还有一个隐藏点线下验证时不能把两个pair拆开各自算准确率再求平均。一个三元组里的两个pair高度相关如果按pair划分数据集同一篇文书可能同时出现在训练集和验证集造成数据泄漏。划分和评估都必须以三元组为最小单位。另外不要对每个三元组单独做概率归一化之后再比较。模型对(A,B1)输出0.9对(A,B2)输出0.8直接比较这两个概率就好因为它们是在同一个模型、同一套输入分布下产生的天然可比。如果先把0.9和0.8做softmax得到0.53和0.47再比较反而放大了噪声。2.3 样本构造与数据划分把“二选一”拆成两个二分类样本一个三元组要转成模型能吃的监督信号常见做法是拆成两个“查询候选”的pair每个pair一个二分类标签。比如A与B1更相似那就构造(A,B1,label1)同时构造(A,B2,label0)。模型学习的是“给定查询和候选预测候选是否与查询更相似”推理时对两个候选分别打分取分高的那个作为三元组预测结果。import json def load_and_build_pairs(json_path): pairs [] # 每个元素是 (query, candidate, label) with open(json_path, encodingutf-8) as f: for line in f: obj json.loads(line.strip()) a obj[a] b1 obj[b1] b2 obj[b2] # answer 字段表示哪一个更相似通常为 1 或 2 answer obj.get(answer, 1) pairs.append((a, b1, 1 if answer 1 else 0)) pairs.append((a, b2, 0 if answer 1 else 1)) return pairs这段逻辑看起来简单但有一个细节值得展开不能把两个pair的label都设为1。如果把“A与B1相似”和“A与B2相似”同时作为正样本模型学的就是“查询与任意候选都倾向于相似”退化成回归任务推理时两个候选得分都会很高只能靠微小的概率差去碰运气准确率很难超过0.6。数据划分要用分组划分保证同一个三元组的所有pair只落在训练集或验证集不能一边一半。用GroupShuffleSplit可以做到。from sklearn.model_selection import GroupShuffleSplit # triplet_ids 与 pairs 等长每个 pair 都带上所属三元组的编号 triplet_ids [pid for pid in range(len(pairs)) for _ in range(2)] gss GroupShuffleSplit(n_splits1, test_size0.1, random_state42) train_idx, val_idx next(gss.split(pairs, groupstriplet_ids))这里groups参数填的是每个pair所属三元组的编号sklearn会保证同一个组的所有样本被分到同一侧。如果忘了这一步直接用random_split训练集和验证集里会出现同一篇候选文书互相“抄答案”的情况线下验证分数虚高提交后却掉点这是比赛里最常见的翻车点之一。3. 模型选型与长文书截断BERT类模型为什么是首选以及怎么喂3.1 模型选型对比ESIM、TextRCNN与BERT类模型在篇章匹配上的差距相似案例匹配本质上是文本对匹配很多人第一反应是上ESIM或者TextRCNN。ESIM在句子对任务上是经典方案通过双向注意力做词级交互再聚合到全局表示在SNLI这类句子级推理上有不错表现。但法研杯的输入是整篇裁判文书动辄两千字ESIM的词级交互矩阵是O(n×m)复杂度两篇长文书直接跑出天文数字的attention矩阵显存先扛不住。TextRCNN靠CNN/RNN提取局部n-gram特征对“入户盗窃”和“扒窃”这种局部关键词敏感但法律相似性判断往往依赖跨段落的情节呼应比如第一段讲作案次数最后一段讲累犯加重CNN的局部窗口根本连不起来。模型优势劣势匹配度ESIM句子级词交互强长文本文本O(n²)复杂度爆炸不适合TextRCNN局部n-gram特征强难以捕捉跨段长距离依赖勉强可用BERT类预训练长距离依赖、上下文建模能力强512 token长度上限需截断首选长文本BERT类直接处理超长文本训练和推理成本高复现困难有余力再上BERT类预训练模型在这个任务上的优势很明显注意力机制天然支持跨位置的信息交互“本院认为”里的量刑理由可以和“经审理查明”里的事实细节直接建立关联不需要像CNN那样靠堆层数去扩大感受野。中文场景下我一般直接用RoBERTa-wwm-ext这类全词掩码权重它对中文分词边界的处理比原生BERT更细对法律术语的表示也更充分。选型这件事不需要在多个模型之间反复横跳先跑通一个BERT类baseline后续再靠数据优化拿分比换模型架构划算得多。3.2 长文书截断的三种策略前512、首尾拼接与规则定位裁判文书超过512个token是常态截断策略直接决定模型能看到什么。最省事的是保留前512个token但开头往往是大段被告人和案号信息真正的犯罪事实还没展开就被截断了。保留后512个token也不理想因为“经审理查明”在文书中间偏后的位置只保尾部又会丢掉开头的基础身份信息模型连“是否累犯”都看不全。我实际跑下来的体验是前512截断能把准确率做到0.60到0.63之间但很难再往上走。换成“首部尾部”拼接把开头256个token和末尾256个token拼成一段512准确率会有明显提升原因在于文书开头有案由和基本罪名末尾有判决主文这两段组合起来已经能给模型提供“罪名量刑结果”的闭环信号。更精细的做法是用规则定位“经审理查明”和“本院认为”两个关键段各自截取一段再拼接信息密度最高但实现成本也最高不同法院的文书格式有细微差异规则容易漏。截断策略保留信息主要缺点复现成本前512案号、被告人信息丢掉“本院认为”极低后512判决主文、量刑理由丢掉案件事实开头极低首部尾部各256罪名判决结果中间证据链可能断裂低规则定位关键段事实量刑双高密度格式变化导致规则失效中3.3 首尾拼接tokenize代码平衡信息密度与复现成本首尾拼接是性价比最高的策略代码实现也不复杂。核心思路是把原始文本按下标比例切成两段分别编码再拼起来最后统一裁剪到max_len。def encode_head_tail(text, tokenizer, max_len512, head_ratio0.5): head_len int(max_len * head_ratio) tail_len max_len - head_len head tokenizer.encode(text[:head_len], add_special_tokensFalse) tail tokenizer.encode(text[-tail_len:], add_special_tokensFalse) # 多出来的部分直接裁掉避免超长 tokens (head tail)[:max_len] return tokens这个写法是在原始字符层面先切再用tokenizer编码最后裁剪。严格来说字符截断可能把一个汉字词切成两半分词结果里会出现不完整词但实践中对整体效果影响很小。如果你想要更严谨的版本可以先对整个文本做tokenize再按下标取头和尾这样不会切词但速度会慢一些。考虑到训练数据量不大我一般优先保证迭代速度。这里有个参数值得单独说head_ratio。我一开始默认0.5后来对比过0.4、0.5、0.6三档验证集上的波动并不大差异在1个百分点以内。原因是判决书的头部信息相对冗余尾部信息更关键head_ratio往小调一点往往更好但调太小又会丢掉案由。建议以0.5为基准验证集上调一次即可不用在这上面花太多时间。真正影响更大的是后续训练阶段的超参数。4. 微调训练与超参数设置最小可跑脚本和五个关键参数4.1 最小训练脚本用transformers微调二分类模型数据准备好了模型选型也定了接下来就是微调。下面是一个最小可跑的训练循环用的是transformers库和PyTorch模型输出2分类logits对应“候选与查询更相似”和“候选与查询不更相似”。from transformers import AutoTokenizer, AutoModelForSequenceClassification from torch.utils.data import Dataset, DataLoader from transformers import AdamW, get_linear_schedule_with_warmup import torch tokenizer AutoTokenizer.from_pretrained(hfl/roberta-wwm-ext) model AutoModelForSequenceClassification.from_pretrained( hfl/roberta-wwm-ext, num_labels2 ) class PairDataset(Dataset): def __init__(self, pairs, tokenizer, max_len512): self.pairs pairs self.tokenizer tokenizer self.max_len max_len def __getitem__(self, i): q, c, label self.pairs[i] enc self.tokenizer( q, c, truncationTrue, max_lengthself.max_len, paddingmax_length, return_tensorspt ) return {**{k: v.squeeze(0) for k, v in enc.items()}, labels: torch.tensor(label, dtypetorch.long)} loader DataLoader(PairDataset(train_pairs, tokenizer), batch_size16, shuffleTrue) optimizer AdamW(model.parameters(), lr5e-5) total_steps len(loader) * 5 scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps ) for epoch in range(5): for step, batch in enumerate(loader): out model(**{k: v.cuda() for k, v in batch.items()}) loss out.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad()这里有个关键点tokenizer的输入是query和candidate两个文本transformers会把它们拼成“[CLS] query [SEP] candidate [SEP]”的格式这个结构对交互式匹配任务很合适。不要犯的一个错误是把两篇文书分别编码成两个向量再拼起来那样模型丢掉了词间的交叉注意力“相似”就变成了“向量距离”效果会差一截。4.2 五个必调参数max_len、batch size、学习率、warmup与梯度裁剪这一节列一下我每次跑这个任务都会固定的超参数以及每个参数为什么值得调。参数建议值说明max_len512首尾拼接后刚好用满BERT上限batch_size16显存不够时用梯度累积凑等效batch学习率5e-5中文预训练模型微调常用起点epochs5小数据集5轮左右足够再长容易过拟合warmup_ratio0.1前10%的step线性升温防止开局震荡max_grad_norm1.0梯度裁剪长文匹配的loss曲线容易突跳batch_size这个参数在长文本任务里很敏感。每个样本都是512 token16的batch在单卡上已经是比较重的负载如果只放得下8也不要急着换小学习率加梯度累积把等效batch凑到32或16更好。实际操作是每8个step做一次优化器更新模型参数更新的频率和batch 16保持一致收敛也更容易复现。学习率5e-5是BERT微调的标准起点但在这个任务里我会额外关注验证集loss曲线。如果训练loss下降但验证acc纹丝不动大概率是学习率偏高模型在训练集上过拟合了降到3e-5再跑一轮对比。warmup比例也不能省尤其数据量小的时候模型参数刚从预训练权重出发一上来就用大步长容易被带偏。梯度裁剪则是对长文本里偶发极端loss的一种兜底clip到1.0基本不会伤害效果但能避免单个样本炸掉整轮更新。4.3 验证逻辑按三元组算准确率而不是按pair算训练过程中要频繁看验证集很多人直接对pair算准确率这是错误的。两个pair来自同一个三元组如果模型对(A,B1)和(A,B2)分别预测可能两个pair都预测对了也可能两个都错单看pair准确率会掩盖三元组级别的真实表现。正确的是把pair的预测分数按三元组重新归组再比较。def evaluate_triplet_acc(model, val_triplets, tokenizer): model.eval() correct 0 total 0 for tid, q, c1, c2, ans in val_triplets: with torch.no_grad(): s1 model(**tokenize(q, c1)).logits[:, 1].item() s2 model(**tokenize(q, c2)).logits[:, 1].item() if (s1 s2 and ans 1) or (s2 s1 and ans 2): correct 1 total 1 return correct / total注意这里对两个候选比较的是模型输出的正类概率logit也就是候选“更相似”的得分。如果模型对两个候选输出都很低比如0.51和0.49只要有相对高低就行。这个评估逻辑和赛题评测保持一致训练过程中显示的验证分数才具有参考意义。不要把pair准确率和三元组准确率混为一谈我见过有人线下pair准确率到了0.8换算成三元组准确率只有0.68提交后排名被拉下一大截。5. 避坑清单相似案例匹配最容易翻车的五个细节5.1 用bert-base-chinese做baseline法律术语被切得稀碎现象第一次跑通训练验证集准确率只有0.58比随机猜测0.5好不了多少。单独看预测结果模型把“扒窃”和“盗窃”几乎当成同一件事。原因bert-base-chinese的词表对法律领域术语覆盖不足分词时“盗窃罪”可能被切成“盗”和“窃罪”关键信号被拆散另外它没有针对法律语料做继续预训练对“累犯”“从轻处罚”这类量刑关键词的语义表示很弱。解决换用中文全词掩码的RoBERTa-wwm-ext作为基座效果立竿见影。更进一步的选手会用法律语料继续预训练但比赛场景下直接换权重性价比最高。如果换完权重还有其他术语识别问题可以看看是不是输入里法条编号被tokenizer切碎了考虑在原始文本里给“刑法第二百六十四条”这类字符串两边加分隔符。5.2 只看前512个token把“本院认为”整段丢掉现象验证集准确率稳定在0.62训练到第3轮就不再上涨。检查模型输入发现预测错误的三元组大多涉及量刑比较而量刑理由出现在文书后半段根本没进模型视野。原因前512个token覆盖了案号和被告人信息占了大量空间“本院认为”通常在token 700到900之间出现被截断得干干净净。模型只能靠犯罪事实部分做猜测自然学不到量刑维度的相似性。解决改成首尾拼接截断策略保留末尾256个token。这样“本院认为”和判决主文一定能保留进来准确率能跳到0.7附近。如果还想再进一步可以写规则在文本里定位“本院认为”的位置从那里开始向后截取效果会更好但要注意不同法院的文书措辞并不完全统一。5.3 只跑一个随机种子调参调成了玄学现象把学习率从5e-5改成3e-5验证准确率从0.71掉到0.69改回5e-5再跑一次又变成0.72。同样的代码同一个参数两次结果差1个百分点。原因数据集不大训练集只有几千个三元组模型初始权重、数据打乱顺序、dropout随机性都会带来方差。单次实验的波动幅度完全可能掩盖真实参数差异。解决固定随机种子是第一步但不够。每个关键参数组合至少跑3个不同种子取验证准确率的平均值来比较。种子固定了数据加载顺序和初始权重也不会乱跳。这一步看起来费时间但能省掉大量“调参调了个寂寞”的时间。调参这件事上多seed平均不代表玄学反而是在把黑匣子变小。5.4 只存模型权重不存tokenizer换机器推理直接变脸现象训练完只保存了model_state_dict提交时在另一台机器上加载验证准确率掉了3个百分点。原因tokenizer的版本不一致或者max_length设置得不同。transformers里模型和tokenizer的配置必须严格配套中文tokenizer尤其明显旧版本把“盗窃”切成一个词新版本切成两个词输入变化后模型表现完全不一样。解决保存整个模型目录用model.save_pretrained和tokenizer.save_pretrained加载时直接用from_pretrained读目录。保存路径里最好把transformers版本也记下来格式类似hfl_roberta_wwm_ext_v1避免一段时间后环境升级导致被迫重新对齐。这里顺便养成的习惯是每次实验跑完把代码、数据hash、tokenizer版本一起写进实验记录回头排查问题不用靠回忆。5.5 两个候选样本固定顺序输入模型偷偷学了位置现象验证集上准确率有0.73但把三元组里B1和B2的顺序互换之后准确率掉到0.67。原因训练时A和B1的组合永远作为第一个样本A和B2永远作为第二个样本。这个固定顺序相当于把“候选编号”这个信号泄漏给了模型模型学会了“排前面的候选倾向于是正确答案”而不是真正理解文本相似性。解决训练时对每个三元组随机互换B1、B2的位置同时把label跟着换。推理时不需要换因为推理比较的是两个候选的打分高低与输入顺序无关。实现方式很简单构造pair时把b1和b2的位置按random.random()的概率交换。这个坑不踩到永远不知道踩过一次之后我所有文本对任务都会顺手做一次顺序扰动。6. 复现之外再推一步伪标签、阈值校准与跨赛道复用6.1 伪标签把无标注测试集变成第二份训练数据训练好的模型在测试集上做预测时会输出每个pair的正类概率。把置信度高的测试样本筛选出来当作伪标签加入训练集再微调一到两个epoch往往能在验证集上再涨一个多点。关键参数是置信度阈值我一般从0.9开始扫低于0.9的样本不要防止错误标签污染模型。with torch.no_grad(): logits model(**batch).logits probs logits.softmax(-1)[:, 1] # 只保留高置信度样本作为伪标签 mask (probs 0.9) | (probs 0.1) pseudo_pairs [ (q, c, 1 if p 0.5 else 0) for q, c, p in zip(queries, candidates, probs) if mask ]伪标签的代码逻辑不复杂但有一个前提测试集必须和训练集同分布。相似案例匹配数据集本身来自同一批裁判文书库满足这个条件。混入伪标签后再训练时要注意把学习率降到2e-5左右并且只训练1到2个epoch时间太长模型会过度拟合伪标签里的噪声。6.2 决策阈值扫描0.5不一定是最优判断线模型的输出概率0.5是默认判断线但在相似案例匹配里两个候选都多少与查询相似模型输出概率分布经常整体偏向0.5以上。如果你把“是否更相似”的阈值固定在0.5相当于在猜模型的内部校准。更实用的做法是在验证集上扫一遍阈值找到让三元组准确率最高的那条线。best_thresh 0.5 best_acc 0.0 for th in [i / 100 for i in range(30, 70)]: acc evaluate_with_threshold(model, val_triplets, th) if acc best_acc: best_acc, best_thresh acc, th阈值扫描看起来是小事但在两个候选得分接近时会明显影响结果。比如模型对A与B1输出0.53对A与B2输出0.47判断线正好压在中间稍微偏一点就会选错。实际操作时我会把验证集按概率差的绝对值分成几档低置信度区间单独看一眼错误率这样对模型哪里弱心里有数。6.3 迁移到CAIL 2020/2021司法考试赛道匹配骨架的复用方式这套“查询候选”的二分类匹配骨架不只是为2019相似案例匹配服务的。CAIL 2020/2021司法考试赛道本质上也是给定一个法律问题在多个候选答案里选出正确项只是输出从二选一变成多选或问答。复用时把输出层从2类改成N类或者对每个候选答案独立打分再排序数据处理、长文书截断、阈值校准的经验都能直接搬过去。长文书截断和伪标签这两项在司法考试赛道甚至比比赛当年更重要因为题目加选项的总长度远超512更需要设计合理的截断策略。整个方案跑下来我最大的习惯变化是每次开实验前先固定seed记录tokenizer版本和数据hash训练结束时连同模型一起保存整个目录。调参这件事没有后悔药但配置管理就是最便宜的后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表