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

资讯详情

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

相似案例匹配冠军方案:法律长文本BERT微调与避坑指南

相似案例匹配冠军方案:法律长文本BERT微调与避坑指南 简介这是中国法研杯司法人工智能挑战赛相似案例匹配赛项第一名解决方案的完整资料包面向司法AI研究者、竞赛选手及算法开发者。压缩包共28个文件整体约116KB核心为18个Python源码文件覆盖数据预处理、模型构建、损失函数设计、训练评估、结果可视化全流程另含4个JSON配置文件、2个BIN权重、3个TXT说明与1个MD文档分别承担参数配置、模型权重、运行清单和项目说明的角色。资源内含完整的赛题方案与配套资料从源码中可深入理解优胜队伍在相似案例匹配上的算法逻辑、数据结构及工程实现细节配套文档则补充了项目背景、设计思路、依赖环境与运行说明有助于读者从零掌握整条技术链路。已有38人学习下载尤其适合准备司法AI竞赛、研究法律文本相似度计算或希望借鉴冠军代码风格的开发者。1. 相似案例匹配冠军方案包最值钱的不是模型权重司法人工智能赛道里中国法研杯的相似案例匹配任务看起来门槛极低给你一个查询案例和两个候选案例判断哪个候选和查询更像二选一随机猜也有 50% 准确率。可一旦真拿一份“第一名解决方案源码资料”的 zip 包来研究你会发现真正值钱的往往不是某个模型权重而是数据处理方式、验证集划分和特征边界。这篇笔记就沿着这类方案包的常规结构把从原始裁判文书到线上提交的完整链路拆开讲也把本地高分线上翻车的几个典型原因摆出来。适合准备打中文法律 NLP 赛事、或者正在做长文本匹配的工程师照着复现。2. 先看懂任务格式判定“哪个更相似”而不是“有多相似”2.1 二选一任务的数据形态与评估口径法研杯相似案例匹配赛道的数据形态很统一每一条样本是一个三元组包含查询案例文本、候选案例一、候选案例二标签是 0 或 1表示哪个候选与查询更相似。评测口径普遍是准确率因为二选一任务天然不需要纠结阈值你选对了就算对。这个设计比常规的相似度计算任务省事不需要人工打“相似度分数”只要标注人员做相对比较标注成本低、一致性好。但这个任务和常规文本匹配有个本质区别训练信号是“偏好关系”不是“相似度分数”。常规匹配模型学的是“A 和 B 有多像”这里要学的是“A 和 B 比 A 和 C 更像”。所以模型输出层不适合直接回归 cosine 值更适合给两个候选分别打分再比较分数大小。这决定了后面选模型和设计损失函数的方向。拿到数据后第一步不是急着训练而是先看懂数据里有什么。我带过的人里十个有八个栽在第一步不看长度分布直接定 max_len不看标签分布直接上模型。下面这段代码花两分钟跑完能帮你省下后面几小时的返工。import json from collections import Counter with open(train.json, r, encodingutf-8) as f: raw json.load(f) # 假设每条样本是 dict # q_text 查询案例文本, a_text 候选1, b_text 候选2, label 0/1 print(total samples:, len(raw)) lengths [] label_counter Counter() for item in raw: for key in (q_text, a_text, b_text): lengths.append(len(item[key])) label_counter[item[label]] 1 print(mean len: %.0f, max len: %d % (sum(lengths) / len(lengths), max(lengths))) print(label dist:, dict(label_counter))这段代码的核心价值是两个数字平均长度和最大长度。平均长度决定你的 max_len 应该设在 256 还是 512 还是更高最大长度决定要不要做截断和分段。标签分布则告诉你是不是均衡问题绝大多数情况下 0/1 接近各半但保险起见一定要确认一旦 7:3 偏斜后面就要考虑加权损失或者重采样。2.2 验证集划分按查询案例分组别让文本记忆作弊相似案例匹配数据有一个天然陷阱同一个查询案例可能会对应多组候选对。如果随机划分训练集和验证集同一个查询的文本会同时出现在两边模型根本不用学语义直接记住查询文本就能在验证集上拿高分。这种分数虚高在竞赛里极其常见也是线下 98%、线上 90% 的最大来源之一。正确的做法是按查询案例的 id 分组保证同一个查询的所有候选对只落在一个折里。sklearn 的 GroupKFold 正好干这个from sklearn.model_selection import GroupKFold # 假设每样本有 q_id指向它所属的查询案例 group_ids [item[q_id] for item in raw] gkf GroupKFold(n_splits5) for fold, (tr_idx, va_idx) in enumerate(gkf.split(raw, groupsgroup_ids)): tr_items [raw[i] for i in tr_idx] va_items [raw[i] for i in va_idx] print(fold %d: train %d, valid %d % (fold, len(tr_items), len(va_items)))这里有个细节值得注意GroupKFold 的 groups 参数只负责划分约束不参与训练所以传 q_id 列表即可。跑完你会看到每个折的训练/验证比例稳定在 8:2 左右但分布方式比随机切更接近线上评测环境。如果你拿到的原始数据没有显式给出 q_id就自己按查询文本的哈希值构造一个但要保证同一文本哈希一致。2.3 立一个不花钱的基线字符级 TF-IDF 能到多少在动 BERT 之前先跑一个 TF-IDF 字面匹配基线。它有两个作用一是验证数据读取和评估流程没有 bug二是给你一个“不学习语义也能达到”的下限。裁判文书的特点是法律用词高度重复“盗窃”“故意伤害”“有期徒刑”这些词频繁出现所以字面重合本身就能提供不弱的信号。import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer q_texts [item[q_text] for item in raw] a_texts [item[a_text] for item in raw] b_texts [item[b_text] for item in raw] vec TfidfVectorizer(analyzerchar, ngram_range(1, 2), max_features200000) vec.fit(q_texts a_texts b_texts) q_vec vec.transform(q_texts) a_vec vec.transform(a_texts) b_vec vec.transform(b_texts) # TfidfVectorizer 默认做 L2 归一化, 点积就是余弦相似度 sim_a (q_vec.multiply(a_vec)).sum(axis1).A1 sim_b (q_vec.multiply(b_vec)).sum(axis1).A1 pred (sim_a sim_b).astype(int) acc (pred np.array([item[label] for item in raw])).mean() print(char tfidf acc: %.4f % acc)参数说明analyzer 选 char 是因为中文分词依赖词典字符级 n-gram 避免引入分词错误ngram_range 用 (1,2) 能捕获相邻字组合对法律术语很有效。max_features 设 200000 是防止 char n-gram 维度爆炸跑不动就降到 50000准确率会略降但不会太离谱。这个基线通常能到 60% 到 70% 之间具体看数据脱敏程度。如果连 55% 都不到先检查是不是标签读反了。如果直接到 85% 以上说明数据里存在明显的字面线索这时要警惕验证集泄露。3. 把裁判文书喂给 BERT长文本截断与要素抽取3.1 法律文本的关键在“事实段”先用规则定位裁判文书不是普通新闻文本它有相对固定的骨架当事人信息、案由、事实认定、证据列举、法律依据、判决结果。相似案例匹配真正依赖的其实是“事实认定”部分——被告做了什么、后果是什么、主观状态如何。如果直接把整篇文书拼起来喂给 BERT当事人信息、证据罗列这些噪声会稀释语义信号还可能让模型学到“长度相似”这种伪相关。常见的做法是先用规则把事实段抽出来再送进模型。不需要多高级一个正则就能做import re def extract_fact(text, max_chars1500): # 常见的两种表述经审理查明…上述事实审理查明…以上事实 patterns [ r经审理查明(.{0,%d}?)(上述事实|本院认为|以上事实) % max_chars, r审理查明(.{0,%d}?)(以上事实|本院认为) % max_chars, ] for p in patterns: m re.search(p, text, re.S) if m: return m.group(1).strip() return text[:max_chars] # 规则失败时兜底取前段这个函数里最关键的是非贪婪匹配.{0,max_chars}?它保证在第一个“本院认为”出现时就停下不会把判决理由也吞进来。结束词列表要按你的语料微调有的文书写“上述事实有证据证实”有的写“以上事实清楚”把这些变体都加进去。兜底分支很重要规则抽不出来的文本至少给一个确定输出避免后面 tokenizer 收到 None。抽取完建议抽样打印 50 条人工确认正则的精度。法律文书的地域差异很大同一个法院的不同法官写法也可能不同这一步是整个预处理里最值得花时间的部分。3.2 超长文本怎么截前 512、后 512 还是头尾拼接BERT 的上下文窗口是 512 token而事实段经常超过 800 字。直接截前 512 是最省事的方案但会把“判决结果”这个最关键的罪名认定截掉。在相似案例匹配里两个候选的案情可能高度重合真正区分它们的是罪名、刑期、情节而这些恰好常出现在事实段的后半部分或独立字段里。我一般用一个“头尾截断”策略保留开头和结尾牺牲中间。因为法律事实描述的常规结构是“时间地点人物事件”在前、“后果定性”在后开头和结尾的信息密度远高于中间的过程描写。def truncate_head_tail(text, max_len512, head_ratio0.75): if len(text) max_len: return text head_len int(max_len * head_ratio) tail_len max_len - head_len return text[:head_len] text[-tail_len:]head_ratio 是一个值得调的参数。0.75 意味着 512 的窗口里给开头 384 字、结尾 128 字。如果你发现数据里罪名认定出现在“经审理查明”之后不远可以提高到 0.85如果发现事实描述太长、定性都在最后就降到 0.5 甚至 0.3。这个参数对最终结果的影响经常超过换一个预训练模型。注意这里截断的是“文本长度”不是“token 长度”。中文一个 token 大约对应 1 到 1.5 个字所以 512 的 max_len 实际需要输入文本控制在 850 字以内比较安全否则后半段会被 tokenizer 截掉。头尾截断后如果仍然超长再用分段池化的方案但这会让实现复杂不少建议先跑通头尾截断再考虑。3.3 把案由/罪名做成先验信息拼进输入法研杯这类数据有时会保留案由字段即使脱敏也会在文本里隐含罪名。如果显式的罪名、案由信息可用千万别浪费把它作为先验拼到文本前面能让模型少学很多东西。拼接位置有讲究拼在候选文本前而不是拼在查询文本前因为模型需要把先验和候选的事实做关联。from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(hfl/chinese-roberta-wwm-ext) def encode_pair(q_text, c_text, causeNone, max_len512): # cause 是盗窃罪这类短文本, 拼到候选文本前面 if cause: c_text cause 。 c_text enc tokenizer( q_text, c_text, truncationonly_second, # 只截断候选, 保住查询正文 max_lengthmax_len, paddingmax_length, return_tensorspt, ) return enc这里有两个容易踩的细节。一是truncationonly_second它保证查询文本完整保留截断只发生在候选端因为查询是判别的基础候选被截了还有另一个候选兜底。二是如果候选本身很短把案由直接拼在前面会让模型误以为“案由”是事实的开头所以我在中间加了句号分隔让序列边界更平滑。如果数据里没有显式案由字段也可以用关键词表去文本里抽但规则命中率通常不高。更推荐的做法是让模型自己学不强行加字段避免引入错误标签。3.4 中文预训练模型选型通用模型还是法律领域模型中文预训练模型的选择直接影响复现难度和效果上限。通用场景下我的默认选择是 hfl/chinese-roberta-wwm-ext它的全词掩码机制对法律术语这种词内信息密集的文本很友好而且生态成熟、各种 transformers 版本兼容性好。如果显存紧张可以先换 hfl/rbt3 这个 3 层小模型跑通流程再换回大模型出最终结果。法律领域模型比如基于裁判文书继续预训练的版本理论上更贴合领域但实际使用要谨慎。很多领域模型是基于旧版本 transformers/megatron 发布的加载时会遇到 key 名不匹配、词表缺失等问题修复成本不低。我的建议是通用模型作为基准线领域模型作为锦上添花的尝试不要在流程没跑通时就陷入领域模型的环境泥潭。4. 从双塔到交互式相似案例匹配的模型实现与训练参数4.1 双塔编码还是交叉编码二选一任务为什么常用交互式相似案例匹配的模型架构主流就两派。双塔结构把查询和候选分别过编码器得到两个向量后算相似度交互式交叉编码把查询和候选拼成一段话过编码器用整段文本的交互信息做判断。双塔的优点是候选向量可以离线算好适合海量候选的召回场景缺点是两个塔在编码阶段完全不接触查询和候选之间的细粒度交互只能靠最后一步向量比较信息损失明显。二选一任务的候选数量只有两个不需要召回所以交互式是更自然的选择。把 AB 和 AC 分别拼成两段各自过编码器取 [CLS] 向量接一个打分头最后比较两个分数的大小。付出的代价是每个样本要跑两次 forward训练时间是双塔的两倍但在线推理也只有两次前向计算完全能接受。两者的取舍可以用一个简单标准判断如果你的业务里候选集合有上万个只能双塔如果候选只有几个甚至两个无脑交互式。竞赛里的相似案例匹配就是两个候选交互式是性价比最高的方案。维度双塔编码交互式编码精度中等交互不足高全序列交互推理速度快可预计算慢每对都要算显存占用低高适用场景大规模召回精排、二选一实现难度低低4.2 交互式 BERT 匹配模型可抄的 PyTorch 骨架模型定义本身不复杂关键是要把“两个候选共享编码器”这件事做对。共享编码器意味着两个 pair 用的是同一套参数这样打分才有可比性。我常用的实现是把一组 pair batch 拼起来过编码器或者像下面这样对两个 pair 分别 forward注意编码器必须复用同一个实例。import torch import torch.nn as nn from transformers import AutoModel class PairwiseMatchModel(nn.Module): 两个候选共享编码器, 分别打分后比较 def __init__(self, model_namehfl/chinese-roberta-wwm-ext, dropout0.1): super().__init__() self.encoder AutoModel.from_pretrained(model_name) self.dropout nn.Dropout(dropout) self.head nn.Linear(self.encoder.config.hidden_size, 1) def forward(self, e1, e2): # e1/e2 是 tokenizer 返回的 dict, 包含 input_ids 和 attention_mask h1 self.encoder(**e1).last_hidden_state[:, 0] # [CLS] 向量 h2 self.encoder(**e2).last_hidden_state[:, 0] s1 self.head(self.dropout(h1)).squeeze(-1) # 候选1得分 s2 self.head(self.dropout(h2)).squeeze(-1) # 候选2得分 return s1, s2 def pairwise_loss(s1, s2, labels): # labels: 0 表示候选1更相似, 1 表示候选2更相似 diff s1 - s2 logits torch.stack([-diff, diff], dim-1) return nn.CrossEntropyLoss()(logits, labels)损失函数的设计是这套架构的核心。这里没有让模型回归“相似度分数”而是把分数差转化成二分类 logits[-diff, diff]用交叉熵训练。这样模型只需要学会“哪个更大”不需要学“有多大”更贴合二选一任务的本质。实验里这个损失比 MSE 回归分数差稳定得多收敛也快。取last_hidden_state[:, 0]是 BERT 的 [CLS] 惯例。如果发现模型在长文本上效果差可以改成对序列做 mean pooling但 [CLS] 在分类任务上通常已经够用。dropout 建议 0.1 起步过拟合迹象明显时调到 0.2。4.3 训练循环与关键超参数训练这套模型不需要特别花哨的代码关键是超参数别乱来。法律文本匹配任务的数据量通常不大几千到几万条样本BERT 微调在这种规模下很容易过拟合所以学习率要比常规分类任务更保守。from torch.utils.data import Dataset, DataLoader from transformers import get_linear_schedule_with_warmup class MatchDataset(Dataset): def __init__(self, items, tokenizer, max_len256): self.items items self.tok tokenizer self.max_len max_len def __getitem__(self, i): item self.items[i] e1 self.tok(item[q_text], item[a_text], truncationonly_second, max_lengthself.max_len, paddingmax_length) e2 self.tok(item[q_text], item[b_text], truncationonly_second, max_lengthself.max_len, paddingmax_length) return {e1: e1, e2: e2, label: item[label]} def __len__(self): return len(self.items) def collate(batch): e1 {k: torch.tensor([b[e1][k] for b in batch]) for k in (input_ids, attention_mask)} e2 {k: torch.tensor([b[e2][k] for b in batch]) for k in (input_ids, attention_mask)} labels torch.tensor([b[label] for b in batch]) return {e1: e1, e2: e2, label: labels} model PairwiseMatchModel().cuda() opt torch.optim.AdamW(model.parameters(), lr2e-5, weight_decay0.01) steps_per_epoch len(dataloader) scheduler get_linear_schedule_with_warmup( opt, num_warmup_stepsint(0.1 * epochs * steps_per_epoch), num_training_stepsepochs * steps_per_epoch, ) for epoch in range(epochs): model.train() for batch in dataloader: e1 {k: v.cuda() for k, v in batch[e1].items()} e2 {k: v.cuda() for k, v in batch[e2].items()} labels batch[label].cuda() s1, s2 model(e1, e2) loss pairwise_loss(s1, s2, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) opt.step() opt.zero_grad() scheduler.step()训练参数参考值学习率 2e-5 到 5e-5batch size 8 到 32max_len 256 到 384epoch 3 到 5warmup 10%weight_decay 0.01。显存不够时优先减 batch size 而不是减 max_len因为你已经做了头尾截断再减窗口会直接把关键信息切掉。梯度裁剪clip_grad_norm_(1.0)这行建议保留。BERT 微调在 loss spike 时梯度范数会突然暴涨裁剪一下能避免一步把预训练权重冲坏。4.4 提分三板斧对抗训练、R-Drop 与多折投票交互式骨架跑通之后提分最快的是三个技巧。对抗训练 FGM 最容易实现在 embedding 上加一个小扰动让模型见过“被攻击”的输入泛化能力会增强。扰动系数 epsilon 在 0.5 到 1.0 之间加在 embedding 梯度方向上forward 一次算原 lossbackward 后恢复 embedding 再加扰动前向算对抗 loss。代价是训练时间翻倍但对几千条的小数据集很值。R-Drop 的思路更简单同一个 batch 过两次模型用 KL 散度约束两次输出的分布一致相当于给模型加正则。缺点是显存翻倍可以用梯度累积模拟。多折投票则是把 5 折模型的结果做平均每折验证集上选最优 epoch最终预测用 5 个模型的 logits 求均值。这三个技巧不是互斥的可以叠加但每加一个都会让训练时间变长。我的习惯是先把 FGM 加上R-Drop 视显存情况而定多折投票放到最后一两天再跑。5. 相似案例匹配避坑清单5 个让我对线上分数死心的翻车点5.1 验证集分数虚高划分方式没跟官方对齐现象本地 5 折交叉验证平均 96%提交线上只有 88%差距稳定在 8 个点上下。 原因随机划分把同一个查询案例的多个候选对同时分到了训练集和验证集模型记住了查询文本本身验证时直接“背答案”。 解决所有实验统一使用按 q_id 分组的 GroupKFold。如果你拿到的源码包自带验证集划分脚本先看它切分时有没有 group 约束没有的话自己重写一份。这个改动不需要动模型只改数据加载但通常能消除一半以上的虚高。5.2 截断把“罪名认定”截没了长文本的定位问题现象训练 loss 正常下降acc 也不错但错误样本几乎都是“案情接近、罪名不同”的 pair。 原因直接截前 512 token把文末的罪名认定和判决结果截掉了。模型只能靠开头的事件描述来猜遇到描述雷同但定性不同的案件就必错。 解决先用规则定位“经审理查明”到“本院认为”之间的事实段再对事实段做头尾截断。如果字段里有显式的罪名或案由信息拼进输入序列。改完之后重点看原本会错的罪名类样本是否改善。5.3 难负样本不足模型学到的是“长度偏好”而不是语义现象验证集准确率卡在 63% 上不去无论怎么调学习率都无效。 原因数据里大量候选对是一个“明显相似”一个“明显不相似”模型学会了用长度、用词频这些表层特征应付没真正建模语义。当验证集里出现两个候选都比较像的样本时就原形毕露。 解决做难负样本挖掘。用当前模型对训练集所有 pair 打分把模型预测置信度高的错误样本挑出来加入训练。代码不复杂在训练后期冻结模型预测一遍训练集按|score1 - score2|从大到小挑选错误样本。这等于把模型自己的“知识盲区”喂回给它。5.4 环境版本漂移同样的代码复现不出同样的分数现象别人的源码包在作者机器上 92%你换自己的环境跑只有 85%。 原因transformers 版本差异最常见。老版本模型加载用的是BertModel.from_pretrained新版本换成了AutoModel输出结构里last_hidden_state的行为可能变过。PyTorch 版本升级后某些算子尤其 attention mask 相关的计算结果也会有细微差异。 解决解压源码包后第一件事不是看模型而是看 requirements.txt。用 conda 单独建一个环境严格安装锁定版本的依赖。跑通后再升级任何包都会引入不可控变量这是复现类项目的铁律。5.5 zip 伪加密与文件完整性动手前先确认包是真的现象拿到 zip 包解压时报需要密码或者解压到一半提示 CRC 校验失败文件无法完整用。 原因网上流传的 zip 包里伪加密很常见——通过修改本地文件头的加密标志位让解压器误以为文件加密。实际数据并没有加密只是 flag 位被改。另外也有原始压缩包里文件被覆盖解压时静默失败的情况。 解决先用下面这段代码扫一遍 zip 的本地文件头看加密标志位是否真的被置位。import struct path solution.zip with open(path, rb) as f: data f.read() idx 0 while True: idx data.find(bPK\x03\x04, idx) if idx -1: break # local file header 的第 6-8 字节是通用标志位 flag struct.unpack(H, data[idx 6: idx 8])[0] print(offset%d, encrypted_flag%d % (idx, flag 0x1)) idx 4如果输出全部是encrypted_flag0但解压时仍提示要密码那基本就是伪加密用 7-Zip 打开后直接拖出文件即可。另外检查压缩包内是否有重复文件路径有的话说明资源被覆盖过优先从原始发布的网盘或镜像重新下载。代码能不能复现是后面的事前提是文件得是完整的。6. 最后一公里用错误分析决定下一版改哪里模型跑通、分数达到预期之后最忌讳的就是直接调参继续刷数字。分数是宏观结果错误样本才是微观原因。每版实验跑完我都强迫自己做一次错误分析把预测错误、且置信度最高的前 20 条样本打印出来逐条看模型为什么错。def inspect_errors(model, dataloader, topk10): model.eval() errors [] with torch.no_grad(): for batch in dataloader: e1 {k: v.cuda() for k, v in batch[e1].items()} e2 {k: v.cuda() for k, v in batch[e2].items()} labels batch[label].cuda() s1, s2 model(e1, e2) pred (s1 s2).long() for i in range(len(labels)): if pred[i] ! labels[i]: conf torch.abs(s1[i] - s2[i]).item() errors.append((conf, i, labels[i].item())) errors.sort(reverseTrue) for conf, i, label in errors[:topk]: print(conf%.2f label%d % (conf, label)) print(Q:, dataloader.dataset[i][q_text][:80]) print(A:, dataloader.dataset[i][a_text][:80]) print(B:, dataloader.dataset[i][b_text][:80]) print(---)看错误样本时重点找规律是同一类罪名错还是截断位置错还是候选文本长度差异导致的偏好。我遇到最典型的情况是模型把“刑期长短”当成了相似度信号因为训练数据里相同刑期的 pair 确实更常被标注为相似这是数据本身的偏置需要靠难负样本挖掘来矫正。验证技巧上还有一个便宜但有效的做法把查询文本和候选文本互换位置再测一遍。模型如果对顺序敏感说明它依赖了位置先验而不是语义这种情况下把两个 pair 的方向都跑一遍取平均能稳定提升一点在线分数。我每次跑这类长文本匹配任务先看的永远是错误样本而不是分数。这个习惯帮我躲过了无数个“看着分数挺好、上线就崩”的尴尬也希望帮到你。本文还有配套的精品资源点击获取
返回列表