简介:这份压缩包整合了法研杯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、评测脚本和说明文档这几类文件,直接按行读就行。
这里有一个容易忽略的设计:赛题把罪名字段限定在盗窃罪范围内。这么做的好处是屏蔽了“罪名先验”——模型不需要先判断两篇文书是不是同一类犯罪,只需要比较盗窃案内部的细粒度情节,比如盗窃金额、作案次数、是否入户、是否累犯。如果数据集混入多个罪名,模型很容易靠“都是抢劫罪所以相似”这种偷懒信号拿分,反而掩盖了真实匹配能力。
文书内部结构也有规律。一份刑事判决书大致由被告人基本情况、公诉机关指控、经审理查明、本院认为、判决主文组成。“经审理查明”和“本院认为”这两段是信息密度最高的地方:前者讲犯罪事实细节,后者讲量刑理由和法律适用。而开头一大段被告人身份信息、案号、公诉机关名称,对“相似度判断”几乎是噪声。第一次接触这个数据集时,我直接把全文档塞给BERT,512上限根本装不下,于是被迫认真考虑“到底哪一段才是模型真正该看的”。
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,label=1),同时构造(A,B2,label=0)。模型学习的是“给定查询和候选,预测候选是否与查询更相似”,推理时对两个候选分别打分,取分高的那个作为三元组预测结果。
import json def load_and_build_pairs(json_path): pairs = [] # 每个元素是 (query, candidate, label) with open(json_path, encoding="utf-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_splits=1, test_size=0.1, random_state=42) train_idx, val_idx = next(gss.split(pairs, groups=triplet_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_len=512, head_ratio=0.5): head_len = int(max_len * head_ratio) tail_len = max_len - head_len head = tokenizer.encode(text[:head_len], add_special_tokens=False) tail = tokenizer.encode(text[-tail_len:], add_special_tokens=False) # 多出来的部分直接裁掉,避免超长 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_labels=2 ) class PairDataset(Dataset): def __init__(self, pairs, tokenizer, max_len=512): 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, truncation=True, max_length=self.max_len, padding="max_length", return_tensors="pt" ) return {**{k: v.squeeze(0) for k, v in enc.items()}, "labels": torch.tensor(label, dtype=torch.long)} loader = DataLoader(PairDataset(train_pairs, tokenizer), batch_size=16, shuffle=True) optimizer = AdamW(model.parameters(), lr=5e-5) total_steps = len(loader) * 5 scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(total_steps * 0.1), num_training_steps=total_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_len | 512 | 首尾拼接后刚好用满BERT上限 |
| batch_size | 16 | 显存不够时用梯度累积凑等效batch |
| 学习率 | 5e-5 | 中文预训练模型微调常用起点 |
| epochs | 5 | 小数据集5轮左右足够,再长容易过拟合 |
| warmup_ratio | 0.1 | 前10%的step线性升温,防止开局震荡 |
| max_grad_norm | 1.0 | 梯度裁剪,长文匹配的loss曲线容易突跳 |
batch_size这个参数在长文本任务里很敏感。每个样本都是512 token,16的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,训练结束时连同模型一起保存整个目录。调参这件事没有后悔药,但配置管理就是最便宜的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取