ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

法研杯2019相似案例匹配实战:法律文本相似度建模与避坑指南

法研杯2019相似案例匹配实战:法律文本相似度建模与避坑指南 简介法研杯2019相似案例匹配第二名解决方案附带CAIL2020/2021司法考试赛道冠军团队材料是一份面向法律人工智能与自然语言处理竞赛选手及研究者的完整工程代码包。方案覆盖法律文本相似度匹配与司法考试自动答题两条任务线围绕案件事实、争议焦点、判决结果等关键信息的抽取与表示展开涉及文本预处理、词向量与预训练语言模型应用、特征工程、相似度计算、模型融合与超参数调优等环节有助于理解司法场景下NLP模型从数据到部署的全流程实现。压缩包共二十二个文件核心为六个Python脚本对应训练、预测、评测与命令行推理等模块另有Shell脚本、Dockerfile、Markdown文档和依赖清单可快速搭建容器化运行环境整体仅一百九十二KB轻量易用。已有二百六十六人学习资料中还包含说明文档、Docker配置思路、模型结构示意图以及代码注释和调参记录可按文档复现比赛方案也可迁移到相似案例检索、裁判文书分类、法律问答等任务。1. 法研杯2019相似案例匹配为什么所有基线模型都栽在“案由”上拿到法研杯2019相似案例匹配这个任务时大多数人第一反应是“这不就是文本相似度吗BERT拉出来跑一跑就行”。结果真正跑起来才发现法律文本和通用文本压根不是一回事。被告、案由、争议焦点、裁判结果每一段信息对“相似”的贡献权重完全不同通用语义匹配模型拿到的分数往往卡在一个尴尬的区间离榜单前排差一大截。这个比赛的实质是给定三篇文书——一个查询案例和两个候选案例——判断哪个候选与查询更相似。看着是传统排序任务但法律文书的特殊性在于法条引用和事实描述的重复率极高字面相似不一定案情相似。第二名方案之所以能打关键是抓住了两个点把数据清洗和法律文本的结构化特征做透模型层面用预训练语言模型搭配针对法律文本设计的交互方式而不是直接套用通用匹配网络。CAIL2020/2021司法考试赛道冠军团队的做法对数据集和文档做了系统梳理也验证了同一件事法律AI的落地瓶颈不在模型结构而在任务理解和数据加工的细度。这篇文章按我实际做过的顺序来从数据构建和预处理开始到模型选型、训练细节、评估指标再到踩过的坑和推理阶段的调优技巧。内容围绕法研杯2019相似案例匹配展开方法和代码同样适用于CAIL的司法考试赛道以及其他法律句子对/文书对匹配任务。2. 数据集与文书预处理相似案例匹配的分数一半在这里2.1 CAIL2019-SCM数据集的结构和标注暴露的问题法研杯2019相似案例匹配使用的是CAIL2019-SCM数据集我从这个数据集入手对齐任务口径。比赛数据每个样本包含三份刑事法律文书A、B、C其中A是查询文书B和C是候选文书任务是从B/C中选出与A更相似的那个。标签是0或10表示B与A更相似1表示C与A更相似。这个数据集的标注口径是“案情相似度”依据是罪名的最终认定。也就是说如果A和B都被认定为盗窃罪而C被认定为诈骗罪那么B就是正确答案——即便C在文字表面上有更多与A重合的词语也是错误答案。这里就埋了第一个也是最大的坑不能用普通文本相似度的思路去处理表面字词重合度高不能说明案情相似。拿到原始数据后我先做了几个基础检查bash# 查看原始数据目录结构 find ./data -type f | head -50 # 统计样本数和字段情况以一份JSON为例 python -c import json with open(./data/exercise_contest/raw/train.json) as f: data json.load(f) print(样本总数:, len(data)) print(字段:, list(data[0].keys())) print(data[0].keys()) 代码逻辑说明第一段用find命令快速摸清文件分布第二段加载一份样本确认字段名和样本量。这个数据集的原始文件在官方渠道可以下载但注意不同版本的字段可能略有差别有的版本叫a、b、c有的版本叫text_a、text_b、text_c需要先弄清楚再写后续代码。提示比赛数据的格式在不同阶段初赛、复赛会有差异复赛数据量更大且部分为无标签样本预处理脚本的参数要预留扩展空间。2.2 从JSON到训练集切分、标签映射、长文本截断原始数据是JSON列表每条包含A/B/C的文本和标签。实际使用时我一般把数据拆成(query, candidate, label)的格式这样可以复用通用的句子对数据集处理流程。pythonimport json import random # 读取原始数据 with open(./data/exercise_contest/raw/train.json, r) as f: raw json.load(f) # 转换为句子对格式 samples [] for item in raw: q item[a] pos item[b] neg item[c] label item[label] # label0 - b与a更相似label1 - c与a更相似 # 统一转成 (query, b, c, label)后续建模时再按label选择正负样本对 samples.append({query: q, cand_b: pos, cand_c: neg, label: label}) # 切分训练/验证集比例 9:1 random.seed(42) random.shuffle(samples) split_idx int(len(samples) * 0.9) train_samples samples[:split_idx] valid_samples samples[split_idx:] print(f训练集大小: {len(train_samples)}, 验证集大小: {len(valid_samples)}) # 保存为jsonl方便后续读取 with open(./data/train.jsonl, w) as f: for s in train_samples: f.write(json.dumps(s, ensure_asciiFalse) \n)代码逻辑说明把三元组转换成句子对任务的标准格式。这里没有直接把B/C展开成正负样本对而是保留了三元组结构因为后续模型需要同时看到B和C才能学到“相对相似度”——即A与B的相似度、A与C的相似度之间的差值。如果过早展开成独立的句子对会丢掉这个相对信息。参数说明train/valid切分比例9:1是常用做法为了避免验证集分布偏差需要设置固定随机种子这里用42。更稳妥的做法是按案由分层抽样但CAIL2019-SCM的数据量才一万条左右简单随机切分基本够用。2.3 法律文书分词与去噪别急着上大模型法律文书的文本质量和通用语料差别很大直接扔给BERT之前有几个去噪步骤是必须做的。我在这个项目里踩过坑一开始跳过了预处理直接跑BERT结果验证集分数比baseline还低——原因是文书末尾的“本判决自送达之日起生效”这种格式化表述干扰了匹配判断而它们在全语料里出现频率极高模型很容易学会“看到这些字就认为相似”的捷径。pythonimport re def clean_legal_text(text): # 去掉文书编号、抬头和落款 text re.sub(r(刑\s*事\s*判\s*决\s*书), , text) text re.sub(r[(]\s*[0-9]{4}\s*[)]\s*[^\s。]{1,20}刑初[^\s。]{0,10}号, , text) text re.sub(r(公诉机关|被告人|辩护人)[^。]*。, , text) # 去掉多余空白 text re.sub(r\s, , text) return text.strip() # 应用清洗并输出统计前后文本长度变化 sample_text train_samples[0][query] clean_text clean_legal_text(sample_text) print(f清洗前长度: {len(sample_text)}, 清洗后长度: {len(clean_text)})代码逻辑说明第一步去掉“刑事判决书”这类与案情无关的固定抬头第二步用正则匹配文书号如“2019粤0305刑初1234号”第三步去掉“公诉机关……”、“被告人……”、“辩护人……”这些程序性段落——注意这一步要小心辩护人的辩护意见里可能包含关键案情是否去掉取决于后续模型对长文本的容忍度。我这里选择去掉是因为数据集里的“被告人xxx男xxxx年xx月xx日出生”这类内容纯粹是个人信息与案情无关。参数说明这个正则里的[^\s。]{1,20}限定了法院名称长度不会超过20个字符。如果你的数据里法院名很长可以适当放宽到30。去噪后要重新统计文本长度分布如果出现某个样本清洗后为空说明原始文书本身就是程序性文本直接丢弃即可不要硬留。提示这一步看似简单但在CAIL2020司法考试赛道尤其重要——那里很多题目就是纯案情描述清洗策略要和法研杯2019区分开。别拿一套正则打天下。3. 模型方案选型从双塔到交互为什么排名靠前的方案都选交互式3.1 BERT-CLS、双塔、跨编码器的取舍逻辑相似案例匹配的建模方式有三条路线表示型双塔、交互型跨编码器、混合型。三条路线在法研杯2019的战场上都有代表队但成绩分布极不均匀。双塔模型DSSM/双塔BERT的优势是快——两边各自编码然后拿两个向量做余弦相似度。但法律文书的特点是冗长动辄上千字双塔模型把文本压成一个768维向量时信息损失非常大。而且双塔天然不擅长捕捉A和B之间“细颗粒度的局部对应”关系——比如两篇文书都在描述“入室盗窃”一个写“撬锁入户”另一个写“翻窗入院”双塔模型很难在向量空间里精确对上这种细节。交互型模型把A和B同时拼在一起过编码器每个token都能直接看到另一边的token细粒度交互是它的天然优势。缺点是计算量大预测时要对每个candidate单独跑一次前向。在法研杯2019的场景下候选只有两个计算量完全可接受——所以最终榜单上前排方案基本都选择了交互型。我当时参考第二名方案的技术路线主模型用的是BERT-base中文版配合特定的类别差值学习策略大概结构是这样pythonimport torch import torch.nn as nn from transformers import BertModel, BertTokenizer class SimilarCaseMatcher(nn.Module): def __init__(self, model_namehfl/chinese-roberta-wwm-ext, 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 * 2, 1) # 分别过编码器再拼接等价于交互型的简化版 def forward(self, a_input_ids, a_attention_mask, b_input_ids, b_attention_mask, c_input_ids, c_attention_mask): a_repr self.bert(a_input_ids, attention_maska_attention_mask)[0][:, 0, :] b_repr self.bert(b_input_ids, attention_maskb_attention_mask)[0][:, 0, :] c_repr self.bert(c_input_ids, attention_maskc_attention_mask)[0][:, 0, :] # 分别计算AB、AC的表示差交给分类器 ab_diff torch.abs(a_repr - b_repr) ac_diff torch.abs(a_repr - c_repr) score_ab self.classifier(self.dropout(torch.cat([a_repr, ab_diff], dim-1))) score_ac self.classifier(self.dropout(torch.cat([a_repr, ac_diff], dim-1))) # 返回相对分数训练时用 AB分数 - AC分数 和 label 构造排序损失 return score_ab, score_ac代码逻辑说明这个结构是“共享编码器的三方输入”方案——同一套BERT参数分别编码A、B、C然后通过abs差别向量把AB和AC的差异喂给同一个分类头。注意这里没有用标准三元组loss而是让分类头分别输出score_ab和score_ac再用两者的差值去拟合label。参数说明hfl/chinese-roberta-wwm-ext当时是效果最好的中文预训练模型比BERT-base原始权重在多项中文任务上稳定提升1-2个点。如果你跑这个方案换成bert-base-chinese也能跑但最后的匹配准确率会明显有差距。3.2 排序损失是怎么设计的对比学习思路在相似案例匹配的落地训练这个任务可以有两条路线一是把问题转成二分类——“B和A更相似还是C和A更相似”二是显式构造排序损失。二分类的做法简单粗暴但模型学到的是“B的绝对相似度分数”而不是“B相对于C的排名”——后者才是任务真正关心的。我在实际训练中用的是margin ranking的思路但这个“margin”要动点手脚。标准的margin ranking loss是让正样本对的分数比负样本对高出至少一个固定的margin值。但法律文书对难度差异极大有的样本中B和C案情几乎一样仅凭一个量刑细节区分有的样本中B和C完全两个方向模型很容易就能分开。固定margin在这两种样本上的梯度方向是冲突的。pythonimport torch.nn.functional as F def ranking_loss(score_ab, score_ac, label, margin0.3): score_ab: 模型认为B与A更相似的程度 score_ac: 模型认为C与A更相似的程度 label: 0 - 真实是B与A更相似; 1 - 真实是C与A更相似 # 统一方向始终让 positive_score 大于 negative_score positive_score torch.where(label 0, score_ab, score_ac).squeeze(-1) negative_score torch.where(label 0, score_ac, score_ab).squeeze(-1) losses F.relu(negative_score - positive_score margin) return losses.mean()代码逻辑说明torch.where(label 0, score_ab, score_ac)也就是选出正确配对对应的分数作为positive_score错误配对对应的分数作为negative_score。F.relu(negative_score - positive_score margin)的意思是只有当negative_score比positive_score低超过margin时loss才为0否则就产生惩罚。参数说明margin取0.3是一个经验值。这个值太小会让模型对难易样本一视同仁太大则会让模型在难样本上过拟合、丧失泛化能力。我在验证集上调过0.1到0.8的范围0.3到0.5之间效果最好。一个更精细的做法是margin随着训练轮数从0.5退火到0.2——实际验证集分数能再涨一点点但收益有限基础版本不用加。提示score_ab和score_ac来自同一个分类头和同一套BERT参数这等于隐式做了一个权重共享的siamese结构能有效防止两个分数分布漂移。4. 训练细节与关键参数让模型真正收敛到法律语义空间4.1 学习率、batch size、warmup和最长长度的搭配预训练模型微调时最核心的参数搭配是学习率、batch size和最大序列长度。我在法研杯2019这个任务上跑了很多组实验最终稳定的配置如下最大序列长度384第一版用512显存和训练时间翻倍但验证集分数几乎没有提升——因为法律文书的有效信息集中在事实描述部分截断到384并不丢关键案情batch size最大序列长度384时单卡16再大容易OOM学习率2e-5配合warmup ratio 0.1训练轮数4个epoch超过后验证集分数明显下降过拟合信号非常清晰优化器AdamWweight_decay取0.01pythonfrom transformers import AdamW, get_linear_schedule_with_warmup def build_optimizer(model, train_steps): no_decay [bias, LayerNorm.weight] optimizer_grouped_parameters [ {params: [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)], weight_decay: 0.01}, {params: [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)], weight_decay: 0.0} ] optimizer AdamW(optimizer_grouped_parameters, lr2e-5, eps1e-8) scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(train_steps * 0.1), num_training_stepstrain_steps ) return optimizer, scheduler代码逻辑说明no_decay列表里的权重不做L2正则——这是BERT微调的通行做法因为LayerNorm的gamma/beta和bias项对weight_decay敏感加了会导致训练不稳定。optimizer_grouped_parameters把参数分成两组一组带weight_decay一组不带这是HuggingFace官方推荐的写法。参数说明lr2e-5是BERT微调的标准起点。如果显存不够导致batch size降到8可以适当把学习率降到1.5e-5否则收敛不稳。warmup_steps设为总步数的10%这样前10%的步数内学习率从0线性升到2e-5能有效避免预训练参数在开局被大梯度冲坏。4.2 如何正确评估相似案例匹配模型用Accuracy还是更细的指标法研杯2019的官方评估指标是Accuracy对B/C二选一选对了就是1错了就是0。但只盯着Accuracy会掩盖模型的很多问题——特别是类别不均衡和难易样本混杂。我建议在验证集上同时记录Accuracy和每一类别的准确率这样能精确看出模型在label0和label1上的表现差异。pythondef evaluate(model, valid_loader, device): model.eval() total 0 correct 0 # 分别统计label0和label1的准确率 label_correct {0: 0, 1: 0} label_total {0: 0, 1: 0} with torch.no_grad(): for batch in valid_loader: # 把batch里的a/b/c三路输入搬到device input_ids_a batch[a_input_ids].to(device) attention_mask_a batch[a_attention_mask].to(device) # ... 同理b和c labels batch[label].to(device) score_ab, score_ac model( input_ids_a, attention_mask_a, # ... 其他输入 ) # 判决哪个分数高就选哪个 preds (score_ac score_ab).long().squeeze(-1) correct (preds labels).sum().item() total labels.size(0) for lbl in [0, 1]: mask (labels lbl) label_total[lbl] mask.sum().item() label_correct[lbl] (preds[mask] labels[mask]).sum().item() acc correct / total acc0 label_correct[0] / max(label_total[0], 1) acc1 label_correct[1] / max(label_total[1], 1) print(fAccuracy: {acc:.4f}, label0 acc: {acc0:.4f}, label1 acc: {acc1:.4f}) return acc代码逻辑说明预测结果对应原始标签的转换逻辑是——原始label0表示B与A更相似即正确答案为B原始label1表示C与A更相似即正确答案为C。所以当score_ac大于score_ab时预测为1否则预测为0。参数说明这个评估函数里max(label_total[lbl], 1)是防止某个batch里只有一个类别导致除零。实际场景中label0和label1的数量基本均衡但如果你的数据在跑数据增强后发生偏移这个保护就很重要了。建议每完成一个epoch就评估一次保存验证集Accuracy最高的模型作为最终模型。5. 法研杯2019相似案例匹配的避坑清单五个血泪经验5.1 把“字面相似”当“案情相似”——最典型的翻车现场现象模型在验证集上准确率能到0.75左右但死活上不去0.80细看预测结果发现大量错误案例里两篇文书包含大量相同词语——比如都出现了“钱包”“手机”“当铺”。原因法律文书的官方模板和固定表述太多模型很容易学到“词面重合度越高、两个案子就越像”的捷径。但CAIL2019-SCM的标注是对最终罪名认定的考量字面重复不等于案情重复。解决把预处理做扎实去掉文书抬头、落款、被告人基本信息这些模板内容。然后用TF-IDF做一个基线如果基线准确率超过0.60说明数据里的“骗分特征”已经被清得差不多了可以放心训练模型。如果TF-IDF基线还不到0.55回去检查预处理——不要急着调模型。5.2 整段过长的文本直接撑爆显存现象GPU显存16GBbatch size只能设4训练速度奇慢且验证集效果还不如batch size16跑出来的。原因序列长度设512导致padding率极高每一条样本都由大量无效的[PAD]token填充。在batch size4的情况下这4条样本里可能只有一半是有效长度另一半是padding等于浪费了大部分算力还在学“PAD位置没有任何语义”。解决先统计数据集的文本长度分布把95分位的长度作为max_length的基准。法研杯2019的数据集经过清洗后95分位长度通常在350-400之间所以max_length384是最优解。同时写一个collate函数把batch内的文本按长度排序再分桶减少padding占比。5.3 预训练模型选错中文BERT差点让我怀疑人生现象用bert-base-chinese跑了3个epoch验证集Accuracy只有0.68比第二名方案报告的结果低了接近10个点。原因bert-base-chinese是字级别预训练对法律领域词汇的表示能力不足。“盗窃”“抢夺”“侵占”这三个词在通用语料中语义距离较远但在法律语义空间里是邻近概念。字级别预训练模型捕捉不到这种领域内的近义关系。而hfl/chinese-roberta-wwm-ext用了全词掩码Whole Word Masking把“盗窃”作为一个整体掩码学到的表示明显更适合法律文本。解决换用hfl/chinese-roberta-wwm-ext后同样参数、同样数据验证集Accuracy直接跳到0.76。如果条件允许用法律领域的预训练模型效果会更好——但注意检查模型的词表是否覆盖了法言法语中的生僻字以及模型的最大序列长度是否满足你的需求。5.4 直接预测概率而不是差值——输出层设计错了现象验证集Accuracy在0.75上下波动loss已经降到很小了但预测结果的置信度分布很诡异——大量样本的score_ab和score_ac都接近0.9。原因分类头只学了每个绝对分数的幅度没有学到两者之间的对比关系。最后用Argmax决定胜负时两个接近0.9的分数只需要一个微小扰动就会翻盘。解决最终判决用score_ab - score_ac的差值而不是单独看某个score。同时可以在分类头前加一个LayerNorm让两个分数分布稳定在同一尺度。之后在验证集上统计差值分布——差值集中在0附近的样本就是那些真正困难的案例可以用tips/tricks处理见第6章。5.5 复赛和初赛的数据分布不一致——换数据集直接掉点现象初赛训练的模型直接上复赛的无标签测试集分数比baseline还差。用复赛半量标签重训后分数也没恢复到初赛水平。原因初赛和复赛的案由分布不同、文书长度分布不同、甚至文书的模板年份也可能不同司法文书格式在不同年份有细微修订。初赛数据里高频出现的“盗窃罪”在复赛里可能变成了“故意伤害罪”模型在初赛学到的“偷窃行为描述词”特征在复赛中成了噪声。解决从初赛数据里挑出与复赛案由分布最接近的子集按案由分层抽样把模型在子集上继续做一轮domain adaptation微调。如果没有案由标注可以先对所有数据做一次无监督聚类按聚类的类别重新采样。6. 推理阶段的最后冲刺阈值调节、证据级输出与工程落地的驯服技巧模型训练完成后推理阶段还能捞回不少分数。法研杯2019这类二选一任务最简单的预测方式是比较score_ab和score_ac谁大。但实际部署时这个阈值不一定非要是0.5。先算差值diff score_ab - score_ac。在验证集上统计diff的分布你会看到一个近似高斯分布。如果数据质量高diff的正负区间和label的对应关系会很清晰中间模糊地带很窄。如果模糊地带很宽说明模型对大量样本缺乏区分信心此时可以引入一个拒绝预测机制当|diff|小于某个阈值时让投票规则或规则引擎介入而不是强行预测——这在司法辅助场景尤其重要宁可让系统说“不确定”也不要给一个错误结论。另一个实用的技巧是证据级输出——别只输出一个分数把模型attention map里权重最高的top-10词找出来用正则提取出包含这些词的句子在UI上展示“为什么模型认为A和B更相似”。这对从业者的价值是用户可以快速核验模型的判断依据是否合理而不必盲信黑匣子。CAIL2020司法考试赛道上冠军团队也用了类似思路把题目和法条依据同时展示出来。代码做过一个很小的模块帮用户直接看到“案件重合点”pythonimport jieba import numpy as np def show_key_overlap(query_text, cand_text, top_k10): # 用简单词频实现关键重合词生产环境可以换attention权重 q_words jieba.lcut(query_text) c_words jieba.lcut(cand_text) q_counter {} for w in q_words: if len(w.strip()) 2: q_counter[w] q_counter.get(w, 0) 1 c_counter {} for w in c_words: if len(w.strip()) 2: c_counter[w] c_counter.get(w, 0) 1 overlap_words set(q_counter.keys()) set(c_counter.keys()) overlap_words sorted(overlap_words, keylambda x: (q_counter[x] c_counter[x]), reverseTrue)[:top_k] return overlap_words代码逻辑说明这个函数用jieba分词从两篇文书中提取各自的关键词取交集后按出现频次排序。生产环境用attention权重替换词频统计展示效果会更精确。参数说明len(w.strip()) 2过滤掉了单字虚词这在法律文本里很重要——“的”“了”“在”几乎出现在每个句子中不加过滤全是噪音。top_k10是展示层的经验值超过10个关键词用户就看不过来了。再聊一下推理性能。如果有大量候选文书需要匹配比如从1000篇文书中找最相似的交互型模型就要跑1000次完整前向速度会成为一个瓶颈。常见做法是两阶段检索第一阶段用双塔模型快速召回top-50候选第二阶段用本文的交互型模型精排。这个组合在法研杯2019里不需要候选只有2个但在CAIL2020司法考试的开放场景里非常必要。最后我习惯做一次全量的错误分析——把验证集预测错误的样本打印出来逐条看。绝大多数错误集中在这几类罪名一样但犯罪情节差异巨大的罪名不同但事实描述极其相似的复赛新增案由在训练集中完全没出现的。看多了就会明白这个任务的难点从来不在模型结构是否花哨而在于你是否理解法律文本的“相似”到底意味着什么。第二名方案能拿到那个分数靠的也不是什么秘传模型而是把数据、任务、模型三者对齐的工程耐心。法研杯2019相似案例匹配做完之后这个方案稍作修改就能迁移到CAIL司法考试赛道把多选分类的输出头换成阅读理解式的span抽取把三元组输入换成“题目备选答案对”其他训练技巧和避坑经验几乎可以直接复用。如果你正准备走类似的方向希望这篇文章能给到具体的帮助。本文还有配套的精品资源点击获取
返回列表