资讯动态

法研杯相似案例匹配亚军方案:BERT与法律文本挖掘实战

发布时间:2026/10/5 13:28:13 来源:尧图企业网站定制
简介面向法律人工智能与NLP竞赛的资源包收录了法研杯2019相似案例匹配第二名方案并整合2020至2021年司法考试赛道冠军团队思路与工程实现。适用于案例相似度计算、法律文书信息抽取、法律问答与推理等场景。压缩包共22个文件大小192KB以Python脚本、Shell脚本、Dockerfile、Markdown文档和文本说明为主便于本地复现与二次开发。目前已有266人学习。资源内含模型定义、训练与预测脚本、评测工具及配套数据集和文档覆盖文本预处理分词、停用词过滤、命名实体识别、特征工程词向量、预训练语义编码、深度学习调优与模型融合完整链路可帮助快速搭建法律案例匹配基线作为参赛复盘与司法AI应用研究参考。1. 法研杯2019相似案例匹配这套亚军方案解决的是什么问题法研杯CAIL2019 的相似案例匹配赛道核心任务一句话讲清给定一份真实案例文本从若干个候选案例中找出最相似的那个。这个任务看着像检索实际做起来是「排序的粒度、文本的长度、司法领域的措辞」三方博弈所以第二名方案的工程价值远大于模型本身。cail2019-master 这个压缩包里有一套完整的处理链路JSON Lines 样本解析、BERT 句子对输入、特征融合、Docker 提交闭环连官方评判脚本 judger.py 都带上了。适合已经在做法律文本挖掘、智能检索、裁判文书自动归类的从业者也适合想复现一套顶级方案再改造到自己数据上的同学。它能明确回答你「相似案例匹配从数据集到提交该走哪几步、代码在哪里、坑在哪里」这几个直击需求的问题。2. 数据侧解析 CAIL2019 样本结构与文本预处理管线2.1 原生数据长什么样JSON Lines 三元组结构CAIL2019 相似案例匹配的原始数据集按 JSON Lines 格式存放每个文件一行一条样本。本项目 train/ 与 dev/ 目录下的样本最经典型的结构是三个核心字段加一个标签字段我在复现时习惯先用 jq 或 Python 随便拉一条出来看# 查看第一条样本的完整结构 head -n 1 train/*.json | python -m json.tool{ crime: 盗窃罪, A: [2018年11月15日被告人张某某在xx市xx区xx路趁李某不备扒窃其手机一部经鉴定价值人民币2000元后逃离现场。], B: [2017年6月被告人王某某在xx市xx区菜市场趁被害人陈某选购商品之际窃取其挎包内钱包一个内有现金人民币1500元。], C: [2019年1月被告人赵某某酒后驾驶机动车与路边护栏发生碰撞造成护栏损坏经检测其血液中乙醇含量为180mg/100ml。], label: 1 }四个字段的含义分别是crime是案件罪名A是查询案件的事实描述B和C是两个候选案件描述label取 1 表示B与A更相似取 0 表示C与A更相似。注意这里的label是二分类标签而不是相似度分数设计成标签的原因是比赛最终评估的是「选得对不对」不是「分数排得准不准」。读这条 JSON 时有一个细节容易被忽略A、B、C都是数组而不是纯字符串。这意味着官方把一个案件的事实描述拆分成了多段。我第一次处理时直接.join()拼成大字符串后来发现段落之间其实暗含时间顺序拼接不是不行但如果你要做段与段之间的细粒度对比这个数组结构本身就是有用信号。比如A有两段、B有三段你可以做「段落级对齐」这对后面提高 top-1 命中率会有帮助。2.2 文本清洗、分词与案件要素抽取裁判文书原文并不干净里面混着全角空格、不间断空格、各种编号符号直接喂给 BERT 分词器会浪费宝贵的 token 额度。我一般会先做一层轻量清洗再去考虑要不要分词# 裁判文书文本轻量清洗保住中文字符和常用标点 import re def clean_text(text: str) - str: text re.sub(r\s, , text) # 合并所有空白字符 text re.sub(r[\u3000\u00a0], , text) # 全角空格与不间断空格 text re.sub(r[^\u4e00-\u9fa50-9a-zA-Z。%], , text) return text.strip() a_text clean_text(.join(sample[A]))代码里第一行正则把换行、制表符全部压掉第二行单独处理全角空格第三行把除中文、数字、字母、常见标点之外的符号全部剔除。这里的取舍是不要过度清洗因为“某某区”“xx路”这类占位符本身是案件事实的一部分删掉反而会让模型丢失位置信息。对于命名实体识别我并没有单独接一个 NER 模型而是靠 BERT 自带的子词切分能力去感知人名、地名、时间只有当你想把「被告人年龄」「案发地点」做成离散特征时才值得引入额外的 NER 抽取。2.3 data.py 的加载逻辑与句子对构造这个项目的 data.py 干的事不只是读文件它把一条三元组样本拆成了两条句子对样本。这一步是相似案例匹配任务的关键我第一次看源码时差点忽略# data.py 的核心逻辑把 (A, B, C) 拆成 (A,B) 和 (A,C) 两组二分类句子对 import json from torch.utils.data import Dataset class CAIL2019Dataset(Dataset): def __init__(self, path: str, tokenizer, max_len: int 480): self.features [] with open(path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue item json.loads(line) a_text clean_text(.join(item[A])) b_text clean_text(.join(item[B])) c_text clean_text(.join(item[C])) label item.get(label, -1) # -1 表示测试集无标签 # 关键A vs B 和 A vs C 各构成一条样本 # label1 表示 B 更相似那么正样本是 (A,B)负样本是 (A,C) self.features.append(self._make_pair(a_text, b_text, 1 if label 1 else 0)) self.features.append(self._make_pair(a_text, c_text, 0 if label 1 else 1))这样构造的原因是模型最终要回答「B 和 C 哪个更像 A」而不是给一个绝对的相似度分数。拆成两个句子对每个句子对过一遍编码器得到 logits再用两个 logits 的大小关系决定预测结果。label-1的测试集分支不要删推理时要走同一个 Dataset 类读无标签数据否则会出现「训练能跑、预测报错」的尴尬局面。3. 模型选型与训练从 BERT 句子对到相似度分数的完整链路3.1 为什么选 BERT 而不是 TF-IDF 或 Word2Vec做相似案例匹配最先想到的基线一定是 TF-IDF 余弦相似度但它的短板非常明显两个盗窃案如果一个写「扒窃其手机一部经鉴定价值人民币 2000 元」另一个写「窃取其挎包内钱包内有现金 1500 元」字面重叠度极低TF-IDF 算出来的相似度几乎为零而人类一眼就能看出这两个案件在行为模式上是同类的。Word2Vec 能解决一部分词汇替换问题但它对一个句子整体的语义组合能力有限尤其是法律文本这种「事实 法条 判决」三层结构。BERT 这类预训练模型的优势在于它把「A 和 B 是否语义相近」直接当成一个句子对分类任务来学模型会自己去捕捉「扒窃」「窃取」「趁其不备」这些词之间的语义关联。实际训练中用 BERT 微调和用 TF-IDF 做基线相比top-1 准确率有 10 个点以上的差距这不是玄学而是语义匹配和字面匹配的天然鸿沟。如果你的显存连 BERT-base 都跑不动至少也要用 DistilBERT 起步再往下换传统方法就得接受准确率打骨折。3.2 train.py 里值得抠的训练参数这套方案的重要参数集中在 train.py 顶部我复现时把它们整理成了一张表直接对着改就行参数常用值说明max_len480BERT 的位置编码上限是 512留 32 给特殊 token 和尾段batch_size8单张 12G 显存下的稳定值约等于 12G 显存的极限learning_rate2e-5微调 BERT 的经典起点过大会导致灾难性遗忘epochs3再往上加容易过拟合dev 分数开始震荡warmup_ratio0.1前 10% 的训练步数做学习率预热gradient_accumulation4等效 batch_size 为 32稳定 BN 统计量训练命令一般长这样如果你把数据放到了 train/ 目录下python train.py \ --data_dir train/ \ --output_dir output/bert_cail2019 \ --model_name_or_path bert-base-chinese \ --max_len 480 \ --batch_size 8 \ --gradient_accumulation 4 \ --learning_rate 2e-5 \ --epochs 3max_len480这个值不是拍脑袋定的。裁判文书的平均长度远超过 512 token但把 512 占满会让每个 batch 都变得很重训练速度下降 40%。取 480 的折中是让多数样本的主体事实落在截断窗口内同时尾部判决不会完全丢失。如果你发现你的数据集里案件描述特别长可以考虑「头尾截断」策略保留前 256 个 token 和后 224 个 token中间丢弃这比从头硬截效果好得多。3.3 特征融合把传统特征拼进深度模型只靠 BERT 输出一个[CLS]向量就做分类浪费了案件文本里大量可显式计算的信号。我在这个项目上的经验是把三类特征拼在一起喂给最后的分类层通常能再涨 1 到 2 个点# 特征融合把深度特征、TF-IDF 余弦、重叠率拼接成一条向量 import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity def build_extra_features(a_text: str, b_text: str, tfidf_vec) - np.ndarray: # BERT 的 CLS 向量走模型 forward 拿到这里是传统特征部分 tfidf_sim cosine_similarity(tfidf_vec.transform([a_text]), tfidf_vec.transform([b_text]))[0][0] overlap_ratio len(set(a_text) set(b_text)) / max(len(set(a_text)), 1) return np.array([tfidf_sim, overlap_ratio]) # 拼接时 # final_feature concat([cls_logits, extra_features])tfidf_sim抓的是字面重叠overlap_ratio抓的是字符级重复程度这两个特征对 BERT 而言是「看不到的显式信号」直接拼进分类头等价于告诉模型「这两个案件在字面上本来就有很强的相似性」。注意拼接之后分类层的输入维度要对应改掉否则会报维度不匹配。这个融合做法的工作量不大收益却很稳定强烈建议保留。4. 推理、评估与容器化从 main.py 到 docker 提交的完整闭环4.1 推理脚本从模型权重到 top-1 命中训练完的产物是output/bert_cail2019/下的模型权重和配置文件接下来要走的是预测流程。这个项目里 cli_pred.py 就是干这个事的它读入无标签测试集逐条输出预测结果。核心思路是把 B 和 C 分别与 A 组成句子对取两个 logits 比较大小# cli_pred.py 的核心推理逻辑简化版 import torch from transformers import BertTokenizer, BertForSequenceClassification def predict(model, tokenizer, a_text, b_text, c_text, max_len480): # 构造两条句子对输入 pair_ab tokenizer(a_text, b_text, truncationTrue, max_lengthmax_len, return_tensorspt) pair_ac tokenizer(a_text, c_text, truncationTrue, max_lengthmax_len, return_tensorspt) model.eval() with torch.no_grad(): logit_ab model(**pair_ab).logits[0][1] # 取正类分数 logit_ac model(**pair_ac).logits[0][1] return 1 if logit_ab logit_ac else 0 # 1 代表 B 更相似这里取logits[0][1]是取正类概率的 logits 值之所以直接比大小而不是过 softmax是因为两对句子各自过 softmax 后概率之和都是 1直接比原始 logits 等价于比概率还省了一次 softmax 计算。推理速度和显存占用都比训练小得多如果你的显存还有富余可以把batch_size拉到 64 加速。4.2 提交格式与 judger.py官方评估到底怎么算法研杯的提交通常要求输出每一条测试样本的预测标签一行一个 0 或 1。这个项目的 judger.py 就是比赛官方的评判脚本它比对预测文件和标准答案文件算的是 top-1 准确率公式可以理解成accuracy (预测正确的样本数) / (总样本数)没有用 F1 或 AUC原因在于这是一个「三选一」的排序任务最终只需要保证排名正确而不需要关心相似度分数的绝对大小。所以你在调模型时真正应该盯的指标是 dev 集上的准确率而不是训练集上的 loss。如果 loss 一直在降但 dev 准确率不涨多半是模型把训练集背下来了这时候减少 epoch、加大 dropout、引入数据增强才是正道。跑评估的命令通常长这样# 用官方评判脚本对比预测结果与标准答案 python judger.py --pred_file submit/pred.txt --gold_file dev/answer.txt4.3 Docker 部署把环境锁进镜像里比赛提交环节最大的变数就是环境不一致本地能跑的代码评测机上跑不起来。这个项目的 docker/ 目录就是为了解决这个问题把 Python 版本、CUDA 版本、依赖包全部打成镜像评测机直接从镜像启动容器杜绝「在我电脑上是好的」这类经典翻车。仓库里的 requirements.txt 记录了依赖清单核心就是transformers、torch、sklearn、pandas这几件套。构建和提交的常见流程是# 在项目根目录构建镜像进入 docker 目录前确认 Dockerfile 存在 docker build -t cail2019-similar-case . # 本地先挂载目录跑一次推理确认容器内路径和代码里的一致 docker run --rm -v $(pwd)/submit:/app/submit cail2019-similar-case \ python cli_pred.py --input /app/submit/test.jsonl --output /app/submit/pred.txt构建时有个细节.dockerignore里要把train/、output/这种大目录排除掉否则一个 train 集几十 GB 直接打进镜像构建时间会从几分钟变成几小时。如果你是在本地复现而不是比赛提交这个 docker 镜像还有另一个用途它可以帮你把实验环境固定下来三个月后想重新跑这个项目不需要再装一遍依赖。5. 复现避坑手册五个真实翻车现场5.1 现象max_len512直接拉满关键判决部分被截没了我把max_len直接设成 512结果 dev 准确率比预期低了 3 个点。排查时发现裁判文书的「本院认为」部分往往出现在文本尾部而事实描述的开头又必须保留512 的窗口根本装不下整篇文书尾部判决被截掉了。原因BERT 的位置编码上限是 512但法律文书的有效信息分布在全篇不是只在开头简单截断丢掉了「罪名定性」这一块关键信号。解决改成「头尾截断」策略保留前 256 个 token 和后 224 个 token中间丢掉。这样既保住了开头的事实描述也保住了尾部的判决依据实测定点提升了 1 个点以上。5.2 现象训练 loss 正常下降dev 准确率却一直卡在 60% 左右我刚跑这个项目时dev 准确率稳定在 60% 上下死活上不去而论文和 README 里写的都是 75% 以上。原因data.py 里构造句子对时我把label1的样本拆成了(A,B)正样本和(A,C)负样本但拆(A,C)时把标签也直接沿用成了 1导致一半样本的标签是错的模型学到的是自相矛盾的信号。解决逐条打印features里的(text_pair, label)人工核对 20 条后立刻发现问题。修正逻辑后 dev 准确率直接跳回正常水平。这个坑的本质是「数据集构造一旦出错模型再强也白搭」建议每次换新数据集时先抽出 20 条样本做肉眼体检。5.3 现象inference 时显存爆炸batch_size1 也 OOM推理阶段报 CUDA out of memorybatch_size 已经调到 1 还是不行。原因我加载模型时没有调用.eval()和torch.no_grad()模型处于训练模式每个forward都会保存中间激活值用于反向传播记忆体占用是推理模式的 3 倍以上。解决推理前强制走一遍model.eval()并包住with torch.no_grad():还要顺手调用torch.cuda.empty_cache()清掉训练阶段残留的缓存。从那以后我每次写推理脚本都会先检查这两行有没有写全。5.4 现象Docker 构建时下载依赖超时镜像一直 build 失败在评测机上构建镜像时拉取 torch 和 transformers 的依赖包频繁超时一次 build 要重试五六次。原因默认从官方 PyPI 源拉包网络环境不稳定时大文件下载基本必挂另外.dockerignore没配好把几十 GB 的 train 集也打进了构建上下文。解决在 Dockerfile 里配置合适的 PyPI 镜像源把大文件源换成稳定镜像同时在.dockerignore里明确排除train/、output/、.git/。构建时间从 40 分钟降到 8 分钟重试次数归零。5.5 现象transformers 版本升级后原先的模型加载代码直接报错三个月后重新复现这个项目跑原来的 model.py 报AttributeError: BertForSequenceClassification object has no attribute logits。原因transformers 库从 3.x 升到 4.x 后BertForSequenceClassification的输出结构从tuple改成了ModelOutput对象原先按 tuple 下标取 logits 的代码自然失效。解决requirements.txt 里锁死版本例如transformers4.10.0、torch1.10.0升级版本时不要一次性跨大版本先在测试集上跑一遍原先的预测脚本确认输出格式没变。我的习惯是每个项目单独建虚拟环境把pip freeze requirements.txt作为一个强制流程。6. 进阶实战把这套方案改到自己的数据集上并验证先解决数据格式问题。你自己的法律文书数据如果已经是「一个查询案件 两个候选案件 标签」的结构那不需要改代码直接把 JSON 转成与 CAIL2019 相同的格式{A: [...], B: [...], C: [...], label: 0/1}。如果你的数据是「一个查询案件 N 个候选案件」的排序结构就需要先把候选案件两两配对构造成三元组再把相对顺序转成二分类标签这一步可以用负采样控制样本数量。微调的参数不用大改沿用第 3 章那张表即可唯一要重新调的是max_len如果你的文书比裁判文书更长就启动「头尾截断」如果明显更短可以降到 256 提高训练速度。训练结束后不要直接看 loss先跑一遍 judger.py 对 dev 集的完整评估。我用这套流程换过两个不同领域的数据集表现稳定。一个能让效果再上一个台阶的做法是「多模型融合 投票」。具体操作是用三个不同随机种子训练三个同结构的 BERT 模型推理时对两条句子对的 logits 取平均再比较大小。这个做法的收益远超调参相当于让三个模型各自从不同角度理解案件语义再综合意见。我还试过用轻量的 RoBERTa-wwm-ext 作为第二模型把它和 BERT 的输出做加权平均dev 准确率又涨了一个点。融合代码很简单把第 4 章的 predict 函数改一下返回 logits 而不是最终标签在外部聚合就行。最后说一个我自己的教训有一次我换了自己的数据集跑完整个流程后 dev 准确率很高但提交到评测机后分数低了十个点。排查了两天最后发现是数据里有几万条样本的 label 定义和官方相反——我按「1 表示 C 更相似」构造的数据而代码里默认的是「1 表示 B 更相似」。从那以后我每次接手新数据都强制先跑一遍 judger.py 对 100 条人工标注样本的 sanity check验证标签方向没有跑偏。模型再好数据方向错了就是零分这个习惯帮我避掉了大部分低级失误。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑