简介:这份资源是法研杯2019相似案例匹配赛道的第二名完整解决方案,面向具备一定NLP与机器学习基础、希望深入法律文本相似度匹配的开发者与竞赛选手。包内以cail2019-master项目为核心,涵盖模型训练、推理预测、评测打分与容器化部署等环节,可帮助读者理解从文本预处理、特征工程到深度学习建模的完整技术链路。资源共22个文件,包含6个Python脚本、3个Shell脚本、3个Dockerfile、3个Markdown文档及若干配置与说明文件,压缩包约192KB,体积轻量但结构完整,便于快速复现与二次开发。目前已有266人学习下载。借助其中的数据集、代码与文档,读者可掌握相似案例匹配的建模思路、评价指标设计与调参策略,并了解司法AI在辅助决策与提升法律服务效率方面的实际应用潜力。
1. 相似案例匹配这道题,为什么值得用检索+排序的完整链路来啃
做法研杯相似案例匹配,最容易翻车的地方不是模型不够深,而是把「相似」当成一个二分类问题硬做。2019 年这道赛题给的是成对的法律文书,要求判断两段案情描述是否相似,表面看是文本匹配,实际是一个带强领域先验的检索排序问题。很多队伍一上来就 BERT 双塔加交叉熵,线下 AUC 冲到 0.9,换到真实案由分布上一跑就崩,因为负样本采样方式和法律文本的长尾分布完全对不上。CAIL 系列赛道从 2018 年做到 2021 年,司法考试赛道冠军团队沉淀下来的那套方案,核心思路就是把「召回」和「精排」拆开:先用字面+语义做粗召回,再用交互式模型做精排,最后用规则兜底。这套链路对做相似案例匹配、类案检索、裁判文书推荐的从业者都能直接复用,数据集和文档齐全的话,两三天就能跑通 baseline。下面按我实际复现的顺序,把选型理由、可抄的代码和踩过的坑一条条讲清楚。
2. 数据到手先别急着训模型:CAIL 相似案例匹配的数据结构拆解
2.1 三件套字段与相似标签的真实分布
法研杯 2019 相似案例匹配的数据一般以 json 行或 csv 形式给出,每条样本包含 query 案情、candidate 案情和一个 0/1 相似标签。字段命名各版本略有差异,常见的是query、candidate、label,也有用sentence1、sentence2的。拿到手第一件事不是写 Dataset,而是统计三件事:正负样本比例、案情长度分布、案由分布。我见过太多人直接train_test_split就开跑,结果测试集里某类案由一条没有,模型学了个寂寞。
import json from collections import Counter def load_cail(path): data = [] with open(path, 'r', encoding='utf-8') as f: for line in f: item = json.loads(line) # 兼容不同版本的字段命名 q = item.get('query') or item.get('sentence1') c = item.get('candidate') or item.get('sentence2') y = int(item.get('label', 0)) data.append((q, c, y)) return data data = load_cail('cail2019_similar.jsonl') labels = Counter(y for _, _, y in data) print('正负比:', labels) lens = [len(q) for q, _, _ in data] print('query 长度分位:', sorted(lens)[len(lens)//2], max(lens))这段代码做的是最朴素的加载和统计。label转 int 是为了后面直接喂给 BCE 损失;长度分位用来决定max_length设 256 还是 512。法律文书动辄上千字,直接截断会丢关键事实,我一般会先看 95 分位,超过 512 的做分段或摘要,而不是无脑截。
2.2 负样本构造:随机采样为什么会让模型学废
原始数据里负样本往往是随机配的,随机负样本和 query 的案由、当事人、金额可能八竿子打不着,模型只要抓住「有没有相同关键词」就能分对,泛化极差。正确做法是做难负样本挖掘:用 BM25 或句向量先召回一批候选,把和 query 字面重叠高但标签为 0 的挑出来当负样本。这一步是 CAIL 方案里拉开差距的关键,冠军团队在文档里也强调了课程学习式的负样本难度递进。
from rank_bm25 import BM25Okapi import jieba def build_hard_negatives(queries, candidates, labels, topk=5): corpus = [list(jieba.cut(c)) for c in candidates] bm25 = BM25Okapi(corpus) hard_negs = [] for q, y in zip(queries, labels): if y != 1: continue scores = bm25.get_scores(list(jieba.cut(q))) # 取分数最高但标签为 0 的候选作为难负样本 ranked = sorted(range(len(scores)), key=lambda i: -scores[i]) picked = [i for i in ranked if labels[i] == 0][:topk] hard_negs.extend([(q, candidates[i], 0) for i in picked]) return hard_negstopk控制每个正样本配几个难负样本,一般 3 到 5 就够,太多会让训练集极度不均衡。jieba.cut是中文分词,法律文本里专有名词多,可以加载自定义词典把「买卖合同」「连带责任」这类词切准,否则 BM25 的召回质量会明显下降。
2.3 案由分层切分:别让测试集泄漏训练分布
切分数据集时按案由分层,保证训练集和验证集里各类案由比例接近。如果某类案由样本极少,宁可合并到相近大类,也不要在验证集里留一两条,否则指标抖动大到没法判断模型好坏。我一般用sklearn的StratifiedKFold思路,先按案由分组再切。
from sklearn.model_selection import train_test_split def stratified_split(data, case_types, test_size=0.2, seed=42): # case_types 是每条样本对应的案由标签 train, dev = train_test_split( data, test_size=test_size, random_state=seed, stratify=case_types) return train, devstratify参数是这里的关键,少了它切分就是随机的,小类案由可能全跑进验证集。seed固定住,方便复现和对比不同模型。
3. 召回层怎么搭:BM25 与句向量双路并行的最小实现
3.1 BM25 召回:法律术语的字面匹配仍然能打
很多人觉得有了 BERT 就不需要 BM25 了,这是典型的踩坑。法律文本里「合同解除」和「合同终止」语义接近但字面不同,BM25 抓不住;可「原告」「被告」「违约金」这类高频实体词,BM25 的精确匹配反而比稠密向量稳。我的做法是双路召回再合并去重,BM25 负责字面,句向量负责语义。
from rank_bm25 import BM25Okapi import jieba jieba.load_userdict('law_dict.txt') # 法律领域自定义词典 def bm25_recall(query, corpus, topk=50): tokenized = [list(jieba.cut(doc)) for doc in corpus] bm25 = BM25Okapi(tokenized) scores = bm25.get_scores(list(jieba.cut(query))) ranked = sorted(range(len(scores)), key=lambda i: -scores[i])[:topk] return ranked, scoreslaw_dict.txt里放的是从训练集里统计出的高频法律术语,加载后分词粒度更合理。topk设 50 是召回层的常见量级,太小会漏掉精排能救回来的样本,太大精排压力大。scores返回出来是为了后面和向量分数做加权融合。
3.2 句向量召回:用对比学习把案情压到同一空间
句向量这路我一般用text2vec或者自己拿 BERT 做 Sentence-BERT 式的对比学习微调。核心是构造正负对,用 InfoNCE 损失把相似案情拉近。CAIL 方案里冠军团队用了多任务学习,把相似度回归和案由分类一起训,效果比单任务稳。
import torch import torch.nn.functional as F from transformers import AutoModel, AutoTokenizer class CaseEncoder(torch.nn.Module): def __init__(self, model_name='hfl/chinese-roberta-wwm-ext'): super().__init__() self.bert = AutoModel.from_pretrained(model_name) self.proj = torch.nn.Linear(768, 256) def forward(self, input_ids, attention_mask): out = self.bert(input_ids, attention_mask=attention_mask) cls = out.last_hidden_state[:, 0] # 取 [CLS] return F.normalize(self.proj(cls), dim=-1) def info_nce(q_emb, c_emb, temperature=0.05): logits = q_emb @ c_emb.t() / temperature labels = torch.arange(q_emb.size(0), device=q_emb.device) return F.cross_entropy(logits, labels)temperature设 0.05 是句向量对比学习的常用值,太大梯度平,太小容易震荡。proj把 768 维降到 256 维,一是省显存,二是低维空间里余弦相似度更稳定。F.normalize保证向量在单位球面上,点积直接等于余弦相似度。
3.3 双路分数融合:加权还是 RRF,看你的召回规模
两路召回结果合并时,如果两路分数尺度差很多,直接加权会翻车。我一般用 RRF(Reciprocal Rank Fusion),只依赖排名不依赖分数,鲁棒性好。
def rrf_fusion(rank_lists, k=60): scores = {} for ranks in rank_lists: for pos, doc_id in enumerate(ranks): scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + pos + 1) return sorted(scores.items(), key=lambda x: -x[1])k是平滑常数,经验值 60,调大调小影响不大。rank_lists是 BM25 和向量两路各自返回的 doc_id 列表。融合后取前 100 进精排,这个量级在单卡上跑得动。
4. 精排模型怎么选:交互式 BERT 与特征工程的取舍
4.1 交叉编码器:把 query 和 candidate 拼一起喂 BERT
精排阶段最稳的是交叉编码器,把两段文本用[SEP]拼起来,让 BERT 的注意力在两边自由交互。缺点是推理慢,只能对召回后的少量候选做。CAIL 方案里精排用了 BERT + 多特征拼接,纯文本不够,还要加人工特征。
from transformers import BertTokenizer, BertForSequenceClassification tokenizer = BertTokenizer.from_pretrained('hfl/chinese-roberta-wwm-ext') model = BertForSequenceClassification.from_pretrained( 'hfl/chinese-roberta-wwm-ext', num_labels=2) def encode_pair(query, candidate, max_len=512): enc = tokenizer( query, candidate, truncation=True, max_length=max_len, padding='max_length', return_tensors='pt') return encmax_len设 512 是 BERT 的上限,法律文本超长时优先截 candidate 保留 query,因为 query 是检索意图所在。num_labels=2做二分类,也可以改成回归输出相似度分数,看评测指标是 AUC 还是 F1。
4.2 人工特征:案由、金额、当事人重叠度别丢
纯语义模型对数字和实体不敏感,「借款 10 万」和「借款 100 万」在向量空间里可能很近。我一般会补几维人工特征:案由是否一致、金额差值、当事人名称 Jaccard 重叠、法条引用重叠。
def overlap_features(q, c): q_set, c_set = set(jieba.cut(q)), set(jieba.cut(c)) jaccard = len(q_set & c_set) / max(len(q_set | c_set), 1) # 金额抽取用正则,法律文本里常见「人民币XX元」 import re q_amt = re.findall(r'(\d+(?:\.\d+)?)元', q) c_amt = re.findall(r'(\d+(?:\.\d+)?)元', c) amt_diff = abs(float(q_amt[0]) - float(c_amt[0])) if q_amt and c_amt else -1 return [jaccard, amt_diff]jaccard衡量字面重叠,amt_diff为 -1 表示至少一方没抽到金额,训练时当缺失值处理。这些特征拼到 BERT 的[CLS]向量后面,过一层 MLP 再输出,AUC 通常能涨 1 到 2 个点。
4.3 损失函数:Focal Loss 处理难样本
法律数据里难负样本占比高,普通 BCE 会被易分样本主导。换成 Focal Loss 让模型聚焦难样本,是 CAIL 方案里常见的操作。
import torch.nn.functional as F def focal_loss(logits, targets, alpha=0.25, gamma=2.0): bce = F.binary_cross_entropy_with_logits(logits, targets, reduction='none') p = torch.sigmoid(logits) p_t = p * targets + (1 - p) * (1 - targets) loss = bce * ((1 - p_t) ** gamma) return loss.mean()gamma设 2.0 是原论文默认值,越大越聚焦难样本。alpha平衡正负样本,正样本少时调大。这两个参数别一起大改,先固定 gamma 调 alpha。
5. 避坑与排查:相似案例匹配里最容易翻车的 5 个点
5.1 现象:线下 AUC 0.95,线上评测掉到 0.7
原因:训练集负样本是随机采的,和线上真实候选分布不一致,模型学到了「随机负样本」的捷径特征。解决:用 BM25 或向量召回构造难负样本,训练集负样本分布向线上对齐,必要时做课程学习,先易后难。
5.2 现象:模型对长文本预测全是同一类
原因:max_length截断把关键事实切掉了,或者 padding 太多导致注意力被稀释。解决:先统计长度分位,超长文本做分段编码再池化,或者用 Longformer 类模型。padding 用attention_mask屏蔽掉,别让模型看到填充位。
5.3 现象:验证集指标抖动大,换个 seed 差 5 个点
原因:验证集太小或案由分布不均,小类样本主导了指标波动。解决:按案由分层切分,验证集至少每类 50 条,不够就合并案由。多 seed 跑几次取平均,别信单次结果。
5.4 现象:精排模型推理慢,召回 100 条要跑 10 秒
原因:交叉编码器逐对推理,没有 batch,也没用 FP16。解决:候选按 batch 拼一起推理,开torch.cuda.amp混合精度,或者蒸馏一个小模型做精排。实在慢就把召回 topk 降到 30。
5.5 现象:人工特征加进去反而掉点
原因:特征尺度没归一化,或者特征和文本语义冲突,模型学混了。解决:数值特征做标准化,类别特征做 embedding,加特征前先单独训一版看增益,别一股脑全塞。
6. 从第二名方案里能偷的进阶技巧:多任务与模型融合
CAIL 相似案例匹配的第二名方案,以及后来司法考试赛道冠军团队公开的思路,有一个共同点:不把鸡蛋放一个篮子里。多任务学习是第一个能偷的技巧,把相似度判断和案由分类、法条预测一起训,共享底层 BERT,任务间的梯度互相正则,小样本案由上提升明显。实现上就是给 BERT 加几个 head,损失加权求和,权重用不确定性加权自动学。
class MultiTaskModel(torch.nn.Module): def __init__(self, encoder): super().__init__() self.encoder = encoder self.sim_head = torch.nn.Linear(768, 1) self.case_head = torch.nn.Linear(768, 20) # 假设 20 个案由 def forward(self, input_ids, attention_mask): out = self.encoder(input_ids, attention_mask=attention_mask) cls = out.last_hidden_state[:, 0] return self.sim_head(cls), self.case_head(cls)sim_head输出相似度 logit,case_head输出案由分类 logit,两个损失按 1:0.3 加权,案由任务当辅助。案由类别数按实际数据改,别写死。
第二个技巧是模型融合,但别用简单的投票。我一般把 BERT 精排、LightGBM 特征模型、句向量相似度三路分数做 stacking,用逻辑回归学融合权重。验证集上做 5 折,每折的 out-of-fold 预测拼起来训融合器,避免过拟合。
import numpy as np from sklearn.linear_model import LogisticRegression def stack_fusion(oof_preds, labels): # oof_preds: [n_samples, n_models] clf = LogisticRegression() clf.fit(oof_preds, labels) return clfoof_preds必须是 out-of-fold 的,不能拿训练集预测直接训融合器,否则融合权重会偏向过拟合的模型。LogisticRegression简单可解释,权重能看出哪路模型贡献大。
最后一个技巧是后处理规则兜底。相似案例匹配里,如果两段案情的案由不同、金额差一个数量级、当事人完全不重叠,基本可以判不相似,不管模型输出多少分。我一般设几条硬规则,命中就直接覆盖模型结果,线上能挡掉一批明显错误。
def rule_override(query, candidate, model_score): # 案由不同且金额差 10 倍以上,直接判不相似 if case_type(query) != case_type(candidate): if amt_ratio(query, candidate) > 10: return 0.0 return model_scorecase_type和amt_ratio按你的数据实现,规则别设太多,三五条够用,多了会和模型打架。这套链路我从数据清洗跑到融合,单卡 2080Ti 大概两天出结果,关键是把召回和精排的边界划清楚,别指望一个模型端到端解决所有问题。做相似案例匹配最深的教训是:先把数据分布摸透,再谈模型,不然调参调到天亮也是玄学。希望帮到你。
本文还有配套的精品资源,点击获取