司法文本相似匹配:双塔BERT微调实战与法研杯高分方案
发布时间:2026/9/24 23:09:33来源:尧图网络
简介本资源是中国法研杯司法人工智能挑战赛‘相似案例匹配’赛道冠军方案的完整技术实现面向法学与人工智能交叉领域的研究者、算法工程师及高校相关专业学生聚焦司法场景下法律文书语义匹配这一核心任务。压缩包共28个文件含18个Python源码覆盖BERT微调、数据预处理、模型训练与评测全流程、4个JSON配置与标注数据、3个TXT说明文档、2个BIN模型权重及1个Markdown项目说明总大小仅116KB轻量但结构完整目录按datasets/model/utils/output等模块组织便于快速定位关键逻辑。已有38人学习下载可直接复现第一名方案包括基于BERT的双塔匹配架构、针对法律文本优化的损失函数设计、跨折叠数据划分策略以及可视化分析工具bertviz.py和训练回调机制等工程细节是深入理解司法AI落地实践的优质范例。1. 这不是又一个BERT微调Demo它在法研杯相似案例匹配任务上F1达0.892且整套流程能直接跑通司法文书长文本对齐你见过把「盗窃电动车」和「盗取共享自行车」判为高相似的模型吗法研杯赛题里这恰恰是错的——前者可能涉及刑事立案标准金额次数后者常按治安管理处罚。第一名方案没靠堆参数而是用双塔结构细粒度语义对齐法律实体感知注意力在官方测试集上F10.892比第二名高1.7个百分点。它不依赖外部知识库所有逻辑封在cail_dataset.py和net2.py里训练用train_bert.py单脚本启动连requirements.txt都锁死了torch1.12.1transformers4.18.0这种特定组合——因为更高版本的Trainer会破坏Callback中自定义的梯度裁剪逻辑。如果你正卡在司法文本长度超512、案由标签稀疏、判决书段落结构混乱这三座大山这份源码不是参考是能立刻拆解复用的工程基线。大学组队打比赛、法学院AI课设、律所技术验证原型都值得把它当“第一块砖”。2. 从数据加载到模型输出四层结构拆解与可复现命令链2.1 数据预处理为什么split_folds.py必须先跑且不能跳过--seed 2023法研杯原始数据是JSONL格式每行含query待匹配案例、candidates候选案例列表和labels0/1标注。但直接读取会导致内存爆炸——单个query平均关联32个candidate全量加载需12GB RAM。split_folds.py做了三件事按query_id哈希分5折非随机打乱确保同一案件的不同判决书不跨折泄露对每个candidate截断至512 token并用[SEP]拼接query与candidate生成input_ids、attention_mask、token_type_ids生成train_fold_0.npz等二进制缓存文件比纯文本快3.2倍加载。python datasets/split_folds.py \ --data_dir ./data/raw \ --output_dir ./data/processed \ --n_folds 5 \ --max_length 512 \ --seed 2023注意--seed 2023不是摆设。该种子控制哈希分折顺序若不固定train_bert.py中DataLoader的shuffleTrue会与折划分冲突导致验证集混入训练样本——我在v1.2版踩过这个坑F1虚高0.15后全崩。2.2 模型架构net2.py里的双塔交互式注意力到底在对齐什么net2.py是核心创新点。它没用常规的CLS向量拼接而是构建了双塔编码器Query塔BERT输出[batch, seq_len, 768]→LayerNorm→Linear(768, 256)→ReLU→Dropout(0.1)Candidate塔同构编码但权重独立非共享交互模块对两塔输出做cosine_similarity计算逐token相似度再用torch.einsum(bik,bjk-bij, q_emb, c_emb)生成[batch, query_len, cand_len]的对齐矩阵最后max_pooling沿cand_len维度得[batch, query_len]→Linear(256, 1)输出匹配分关键参数在model_utils.py第47行self.interaction_dropout nn.Dropout(0.3)。这个0.3不是调参结果而是针对司法文书“事实描述冗余、法律依据精炼”的特性设计的——过高则丢失细节过低则过拟合噪声。2.3 训练脚本train_bert.py如何绕过HuggingFace Trainer的坑官方Trainer在多GPU下会错误地将loss除以world_size而法研杯要求loss严格等于-log(p_true)。train_bert.py手动实现训练循环关键逻辑在trainer.py第128行# trainer.py def compute_loss(self, model, inputs, return_outputsFalse): outputs model(**inputs) logits outputs.logits labels inputs[labels] loss_fct nn.BCEWithLogitsLoss(reductionnone) # 注意none而非mean loss loss_fct(logits.view(-1), labels.view(-1)) # 手动加权正样本loss×2.0因正例仅占12.3% weights torch.where(labels.view(-1) 1, 2.0, 1.0) weighted_loss (loss * weights).mean() return (weighted_loss, outputs) if return_outputs else weighted_loss逻辑说明BCEWithLogitsLoss(reductionnone)保留每个样本loss再用weights按类别频率加权。若用reductionmean负样本会淹没正样本梯度——我在调试时发现验证集AUC掉到0.61就是这里没改。2.4 推理与评估tester.py的batch_size陷阱与viz_utils.py的可解释性验证tester.py默认batch_size8但司法文书平均长度427GPU显存占用达3.8GB/卡。若强行调大torch.cuda.OutOfMemoryError会静默失败——错误日志只显示CUDA out of memory无具体位置。解决方案是改tester.py第63行# tester.py dataloader DataLoader( dataset, batch_size4, # 原为8必须降为4 collate_fncollate_fn, num_workers2, # 原为4避免IO争抢 pin_memoryTrue )viz_utils.py提供visualize_attention()函数输入query_id和candidate_id输出热力图横轴是查询文书token纵轴是候选文书token颜色深浅表示交互权重。我验证过当query故意伤害致人轻伤与candidate殴打他人致轻微伤对齐时热力图高亮故意伤害↔殴打、轻伤↔轻微伤证明模型真在学法律语义映射而非表面词汇匹配。3. 避坑指南五个让模型F1暴跌20%的实操雷区3.1 现象验证集loss持续下降但F1停滞在0.72auc曲线呈“S”形原因cail_dataset.py中get_labels()函数未过滤None标签。原始数据存在1.3%的label字段为空的样本torch.utils.data.Dataset默认将其转为float(nan)BCEWithLogitsLoss计算时产生nan梯度optimizer.step()后权重更新失效。解决在cail_dataset.py第89行插入校验if label is None: label 0.0 # 统一置为负例避免nan3.2 现象train_bert.py报错KeyError: token_type_ids但tokenizer明明支持原因convert_tf_checkpoint_to_pytorch.py转换的BERT模型缺少token_type_ids初始化。法研杯用的是bert-base-chinese但参赛者用TF版checkpoint转换时漏了token_type_ids的embedding层。解决手动补全在model_utils.py第22行后添加# 补全token_type_ids embedding if not hasattr(model.bert.embeddings, token_type_embeddings): model.bert.embeddings.token_type_embeddings nn.Embedding(2, 768) model.bert.embeddings.token_type_embeddings.weight.data.normal_(mean0.0, std0.02)3.3 现象bertviz.py可视化时热力图全黑或出现离散白点原因bertviz依赖transformers4.18.0的model.config结构新版config.json中layer_norm_eps字段名改为layer_norm_eps但bertviz仍读epsilon。解决降级transformers并锁定版本pip install transformers4.18.0 --force-reinstall并在bertviz.py第35行修改# 原代码 eps config.epsilon # 改为 eps getattr(config, layer_norm_eps, 1e-12)3.4 现象main.py运行时报ModuleNotFoundError: No module named utils.logger原因utils/目录下__init__.py缺失Python无法识别为包。项目结构中utils.py和logger.py是平级文件但main.py用from utils.logger import setup_logger导入需utils/__init__.py暴露接口。解决在utils/目录新建空文件__init__.py并在其中添加# utils/__init__.py from .logger import setup_logger from .utils import load_config, save_model3.5 现象ckpts/下模型文件名含epoch_3_step_12345.pth但modelcheckpoint.py只保存best_model.pth原因modelcheckpoint.py的save_on_best逻辑有bug——它比较val_f1但未初始化best_f1首次比较时best_f1为None导致所有epoch都触发保存。解决在modelcheckpoint.py第52行初始化def __init__(self, save_path, monitorval_f1, modemax, save_best_onlyTrue): self.save_path save_path self.monitor monitor self.mode mode self.save_best_only save_best_only self.best_value -float(inf) if mode max else float(inf) # 关键修复4. 模型微调实战三步适配你的本地司法数据集4.1 数据格式对齐把你的判决书JSONL转成法研杯schema你的数据可能是{case_id: 2023BJ001, text: 北京市朝阳区人民法院认为...}而法研杯要求{query: ..., candidates: [{text: ..., label: 0}], query_id: q123}。用datasets/cail_dataset.py的convert_to_cail_format()函数改造# 新建 convert_mydata.py import json from datasets.cail_dataset import convert_to_cail_format def my_data_to_cail(input_path, output_path): with open(input_path, r, encodingutf-8) as f: raw_data [json.loads(line) for line in f] cail_data [] for i, item in enumerate(raw_data): # 构造query提取本院认为前的事实描述 fact_end item[text].find(本院认为) query_text item[text][:fact_end].strip()[:512] # 截断防溢出 # 构造candidates用同案由的其他判决书需你准备 candidates [ {text: 类似判决书文本..., label: 1}, {text: 无关判决书文本..., label: 0} ] cail_data.append({ query: query_text, candidates: candidates, query_id: fmy_q{i} }) with open(output_path, w, encodingutf-8) as f: for item in cail_data: f.write(json.dumps(item, ensure_asciiFalse) \n) if __name__ __main__: my_data_to_cail(./my_data.jsonl, ./data/my_cail.jsonl)4.2 修改train_bert.py适配新数据路径与超参原脚本硬编码--data_dir ./data/processed需改为你的路径。更重要的是学习率调整——法研杯用2e-5但你的数据若只有200个样本需升到5e-5python train_bert.py \ --data_dir ./data/my_processed \ --model_name_or_path ./pretrained/bert-base-chinese \ --output_dir ./ckpts/my_finetune \ --per_device_train_batch_size 4 \ --learning_rate 5e-5 \ # 小数据集必须提高 --num_train_epochs 10 \ --save_steps 500 \ --logging_steps 100 \ --seed 424.3 用visualization_utils.py验证法律语义对齐效果别只信F1值。运行viz_utils.py的debug_alignment()函数传入你关心的案由对# debug_viz.py from visualization_utils import debug_alignment # 加载你微调后的模型 model torch.load(./ckpts/my_finetune/best_model.pth) tokenizer BertTokenizer.from_pretrained(./pretrained/bert-base-chinese) query 被告人张三持刀抢劫银行 candidate 犯罪嫌疑人李四持械劫取金融机构现金 debug_alignment( modelmodel, tokenizertokenizer, query_textquery, candidate_textcandidate, output_path./viz/robbery_alignment.png )观察热力图若持刀↔持械、抢劫银行↔劫取金融机构高亮说明模型学到法律要件若张三↔李四高亮则还在学人名匹配——该加entity_mask模块了。5. 模型部署与性能压测从单卡推理到Docker服务化5.1tester.py改造为API服务Flask轻量封装tester.py是命令行工具生产需HTTP接口。新建app.py# app.py from flask import Flask, request, jsonify import torch from models.net2 import Net2 from utils.tokenizer import BertTokenizer from datasets.cail_dataset import CAILDataset app Flask(__name__) model Net2.from_pretrained(./ckpts/best_model.pth) tokenizer BertTokenizer.from_pretrained(./pretrained/bert-base-chinese) model.eval() app.route(/match, methods[POST]) def match_case(): data request.get_json() query data[query] candidates data[candidates] # list of strings # 构造batch inputs tokenizer( [(query, c) for c in candidates], paddingTrue, truncationTrue, max_length512, return_tensorspt ) with torch.no_grad(): logits model(**inputs).logits.squeeze(-1) scores torch.sigmoid(logits).cpu().numpy().tolist() return jsonify({scores: scores}) if __name__ __main__: app.run(host0.0.0.0, port5000)参数说明truncationTrue确保不超长squeeze(-1)去掉多余维度torch.sigmoid转概率。实测单请求耗时320msV100QPS≈3.1。5.2 Dockerfile编写隔离环境杜绝requirements.txt版本冲突requirements.txt锁死版本但pip install -r仍可能因系统库差异失败。Docker强制环境一致# Dockerfile FROM nvidia/cuda:11.3.1-cudnn8-runtime-ubuntu20.04 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt \ pip install torch1.12.1cu113 torchvision0.13.1cu113 -f https://download.pytorch.org/whl/torch_stable.html COPY . . CMD [gunicorn, --bind, 0.0.0.0:5000, --workers, 2, app:app]构建命令docker build -t law-match-api . docker run -p 5000:5000 --gpus all law-match-api5.3 压测报告Locust模拟100并发下的稳定性瓶颈用Locust写压测脚本locustfile.py# locustfile.py from locust import HttpUser, task, between import json class LawMatchUser(HttpUser): wait_time between(1, 3) task def match_endpoint(self): payload { query: 盗窃电动车价值2000元, candidates: [ 盗窃自行车价值1800元, 诈骗他人财物价值2500元, 抢夺电动车价值2200元 ] } self.client.post(/match, jsonpayload)压测结果100并发持续5分钟指标数值说明平均响应时间342ms符合预期95%延迟418ms可接受错误率0.0%稳定CPU使用率82%瓶颈在CPU非GPU内存泄漏无30分钟内内存波动50MB关键发现错误率0%证明tokenizer的padding和truncation鲁棒CPU 82%说明模型推理未充分利用GPU——需检查torch.cuda.synchronize()是否缺失。我在app.py第28行补了torch.cuda.synchronize()CPU使用率降至65%。5.4 模型瘦身ONNX导出与TensorRT加速实测提速2.3倍PyTorch模型部署慢转ONNX再用TensorRT# export_onnx.py import torch from models.net2 import Net2 model Net2.from_pretrained(./ckpts/best_model.pth) model.eval() dummy_input { input_ids: torch.randint(0, 10000, (1, 512)), attention_mask: torch.ones(1, 512), token_type_ids: torch.zeros(1, 512, dtypetorch.long) } torch.onnx.export( model, dummy_input, ./model.onnx, input_names[input_ids, attention_mask, token_type_ids], output_names[logits], dynamic_axes{ input_ids: {0: batch_size, 1: seq_len}, attention_mask: {0: batch_size, 1: seq_len}, token_type_ids: {0: batch_size, 1: seq_len} }, opset_version12 )TensorRT优化后单请求耗时降至142msQPS提升至7.0。但注意opset_version12是底线低于此版本BertModel的LayerNorm算子不支持。6. 法律AI落地的三个反直觉真相与我的血泪习惯6.1 真相一BERT不是万能钥匙法律文本需要“案由感知”的词嵌入重训法研杯第一名方案用bert-base-chinese但我在某省高院数据上复现时F1仅0.76。排查发现寻衅滋事在通用BERT中与故意毁坏财物余弦相似度0.81但法律上二者构成要件完全不同。解决方案不是换模型而是用train_utils.py的train_word_embeddings()函数在10万份判决书上微调BERT的word_embeddings层# train_utils.py def train_word_embeddings(model, dataloader, epochs3): # 冻结所有层只训练embedding for param in model.parameters(): param.requires_grad False for param in model.bert.embeddings.word_embeddings.parameters(): param.requires_grad True optimizer AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr1e-4) for epoch in range(epochs): for batch in dataloader: loss model(**batch).loss loss.backward() optimizer.step() optimizer.zero_grad()重训后寻衅滋事与故意毁坏财物相似度降至0.32F1回升至0.85。这印证了一个反直觉事实法律语义空间不能靠通用语料预训练必须用领域文本“重铸词典”。6.2 真相二验证集指标≠上线效果必须用“法官盲测”替代AUC法研杯用AUC评估但真实场景中法官只看Top3推荐。我曾用AUC0.92的模型上线法官反馈“推荐的案子根本没法参考”。根源在于AUC关注排序能力但法律匹配需“可解释的精准”。后来我们设计judge_blind_test.py随机抽50个query人工标注Top3应召案例计算Recall3。第一名方案Recall30.68而我们的Recall30.79——这才是真实价值。6.3 真相三模型越准越要加规则兜底否则会放大法律风险net2.py输出概率0.99时模型极度自信。但法律上0.99和0.95无实质区别都需人工复核。我们在app.py中加入规则引擎# app.py 规则兜底 def apply_legal_rules(scores, candidates): # 规则1若query含死刑candidate不含死刑则score置0 if 死刑 in query and not any(死刑 in c for c in candidates): scores [0.0] * len(scores) # 规则2若query案由为贪污candidate案由为挪用公款score×0.3 if 贪污 in query and any(挪用公款 in c for c in candidates): scores [s * 0.3 for s in scores] return scores上线后误判率下降40%法官信任度提升——技术再强也得给法律逻辑留条后路。从那以后我每次部署法律AI模型都强制走一遍judge_blind_test.pyapply_legal_rules()双校验。不是信不过代码是信不过自己没想全的法律边界。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网