ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

司法AI挑战赛源码复现:裁判文书预处理与多任务模型构建实战

司法AI挑战赛源码复现:裁判文书预处理与多任务模型构建实战 简介面向“中国法研杯”司法人工智能挑战赛的参赛源码与项目说明整合包聚焦司法场景下的算法设计与工程落地适合计算机、数学、电子信息等专业学生作为课程设计、期末大作业或毕业设计的参考资料。压缩包内共444个文件以307个Python源码文件为主体配合动态链接库、pyd扩展模块、可执行程序以及模型权重、配置文件和说明文档整体仅12.26MB体积小巧但结构完整。项目中既包含可直接运行的参赛代码也提供虚拟环境与依赖配置便于快速复现赛题基线深入理解司法AI中的数据处理、特征工程、模型训练与推理流程。已有194人学习下载适合希望借鉴完整竞赛方案、学习代码组织方式并自行扩展功能的开发者。1. 司法人工智能挑战赛参赛源码包解压之前先弄清这几件事拿到“中国法研杯-司法人工智能挑战赛参赛源码项目说明.zip”习惯性动作是解压、装环境、开训。我见过不少人在这一步翻车项目说明里的数据格式没读直接把所有 JSON 当普通数组加载跑完三个任务的分数全不对最后才怀疑是任务标签没对齐。这个挑战赛打的是司法人工智能里的三件套——罪名预测、法条推荐、刑期预测输入是裁判文书的案情描述输出是判决要素。源码包的价值不在能跑通而在于给你一条完整的、可复现的基线路径。下面按我自己复现这类源码的顺序来写先讲数据怎么化成训练样本再讲模型怎么搭然后落到评估口径和参数最后是五个容易让人怀疑人生的坑。2. 从裁判文书到训练样本源码包的预处理到底在处理什么这章解决一个核心问题把原始裁判文书变成模型能吃的张量之前有哪些步骤是省不得的。很多人直接跳过统计分布这一步上来就训练出了问题又回头检查数据来回折腾的时间比训练本身还长。2.1 把三行 JSON 拆成三个任务罪名、法条、刑期的标签设计CAIL 的数据不是一张表而是 JSON Lines每行一个独立样本。每条样本的核心字段是fact也就是法院查明的事实描述accusation是罪名 ID 列表articles是适用的法条 ID 列表term是刑期。注意accusation和articles都是列表不是单个整数这一点在后面会直接影响损失函数的选择。加载数据的代码是所有预处理的第一步也是很多人第一批踩坑的地方import json from typing import Dict, List def load_cail_samples(file_path: str) - List[Dict]: samples [] with open(file_path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue item json.loads(line) meta item.get(meta, {}) term_raw item.get(term, None) # term 在不同版本里可能是 0、None、字符串“无期徒刑” # 先原样保存不要在这里做类型转换 samples.append({ fact: item.get(fact, ).strip(), accusation: item.get(accusation, []), articles: item.get(articles, []), term_raw: term_raw, criminals: meta.get(criminals, []), }) return samples if __name__ __main__: data load_cail_samples(data/train.jsonl) print(fsamples: {len(data)}, first term: {data[0][term_raw]!r})逻辑说明文件按行读取每行一个 JSON 对象所以不能用json.load(open(...))一把梭meta是嵌套字段里面通常有criminals被告人信息先单独取出来备用term不做类型强转保留原始值因为不同赛题年份给的数据格式不一样提前转int遇到“无期徒刑”直接崩。参数说明文件路径指向官方发布的训练集 jsonl编码必须用utf-8Windows 下如果文件被记事本改过 BOM加载会报错可以把编码改成utf-8-sig。加载完成后先打印一条样本人工确认四个字段有没有空值空值比例超过 1% 的数据得在预处理阶段就标记出来不能悄悄扔给模型。2.2 长文本截断与关键段落定位512 上限怎么破裁判文书动辄两三千字而 BERT 系模型的输入上限通常是 512 个 token。粗暴做法是text[:512]但判决书的信息分布有规律前半段是当事人身份和诉讼过程中间是控辩意见最后才是“本院认为”和判决主文。罪名、法条、刑期这些关键结论集中在后半段所以常见策略是“从关键段起点截”而不是从头截。import re def cut_fact_for_bert(fact: str, max_len: int 480) - str: # 判决书里关键结论集中在这几个词之后 key_words [本院认为, 经审理查明, 本院查明, 综上] start 0 for kw in key_words: hit fact.find(kw) if hit ! -1: start hit break segment fact[start:] # 如果关键段没超过上限优先保留尾部完整再接上前面的案情 if len(segment) max_len: segment fact[-max_len:] return segment[:max_len]逻辑说明先定位“本院认为”这类承上启下的词一旦命中就从那里开始截取如果关键段本身没超过上限则从全文末尾往前取max_len个字符保证判决主文完整最后再用[:max_len]做一道保险。max_len设 480 而不是 512是为了给 tokenizer 加[CLS]、[SEP]以及可能的问答分段腾出位置。参数说明这个函数在字符层面截断不是 token 层面截出来的 480 个字符经过 tokenizer 后可能超过 512 token。实际训练时先过一遍 tokenizer做 token 级截断更稳妥这里保留字符级处理是为了快速验证语义定位是否有效。调max_len时注意显存变化从 256 提到 480训练速度会明显变慢。提示不要迷信“从尾部截”这个单一规律。我遇到过不少民间借贷、交通肇事的文书争议焦点在“被告人及其辩护人辩称”之后而“本院认为”反而很短。稳妥做法是先统计训练集里罪名关键词出现的平均位置再决定截取策略。2.3 先统计再建模标签分布与长尾现象不是玄学做任何模型之前先跑一遍标签分布。司法罪名是典型的长尾分布盗窃、危险驾驶、故意伤害这类高频罪名占掉一大半剩下的罪名可能只有几十个样本。macro-F1 对长尾极其敏感一个小类全军覆没整个 macro-F1 就被拉低一两个点。from collections import Counter def inspect_labels(samples, id2name): acc_counter Counter() art_counter Counter() for s in samples: acc_counter.update(s[accusation]) art_counter.update(s[articles]) print( accusation top 15 ) for label_id, cnt in acc_counter.most_common(15): name id2name.get(label_id, str(label_id)) print(f{name}\t{label_id}\t{cnt}) print(fnum classes: {len(acc_counter)}, total labels: {sum(acc_counter.values())})逻辑说明id2name是罪名 ID 到中文名的映射一般在项目说明或官方辅助文件里给到没有映射表时先打印 ID 也能看出分布但看不出罪名含义不便于人工检查错误。这里只打印前 15 个高频类先确认有没有“某个类占 40%”的极端情况。参数说明真正建模时num_classes用len(acc_counter)而不是照抄别人的源码写死 202不同年份赛题罪名数量不一样。接着还要统计两个容易被忽略的数字多标签样本占比——accusation长度大于 1 的样本比例term_raw特殊值占比——None、0、字符串“无期徒刑”各有几条。这两个统计结果直接决定后面选用哪种损失函数和标签编码。3. 参赛源码的模型框架为什么罪名、法条、刑期要放进同一个模型这章讲选型。法研杯这类司法 AI 任务有天然的多任务结构单任务模型不是不能跑而是浪费了罪名、法条、刑期之间的强相关性。理解这一点你才能看懂源码包里的模型为什么长这样也才知道改哪里。3.1 单任务微调能跑但不稳信息被浪费拿到源码包常见的第一个模型是 BERT 加一个分类头用罪名预测跑通全流程。这种单任务方式在验证集上能到 0.7 左右的 macro-F1但有两个问题第一罪名和法条强相关同一个编码器只学罪名“故意伤害罪对应刑法第 234 条”这种信号就浪费了第二刑期极度依赖罪名单独做回归时模型经常因为罪名判断错了刑期也跟着一起错。from transformers import BertModel import torch.nn as nn class SingleChargeModel(nn.Module): def __init__(self, num_charges: int): super().__init__() self.bert BertModel.from_pretrained(bert-base-chinese) self.dropout nn.Dropout(0.1) self.classifier nn.Linear(self.bert.config.hidden_size, num_charges) def forward(self, input_ids, attention_mask): out self.bert(input_idsinput_ids, attention_maskattention_mask) pooled out.pooler_output return self.classifier(self.dropout(pooled))逻辑说明pooler_output是[CLS]经过全连接和 tanh 的输出拿它做分类池化是 BERT 微调的默认姿势dropout 放在分类头前面防止训练后期过拟合。num_charges必须和第 2.3 节统计的罪名类别数一致否则最后一维对不上。参数说明bert-base-chinese是通用中文预训练权重跑法律文书领域效果够用如果项目说明里明确写了要加载某个法律领域预训练模型就按它的要求加载。加载权重时记得加map_locationcpu否则在没 GPU 的机器上直接报 CUDA 错误。3.2 共享编码层加三头输出多任务框架的典型搭法多任务是这类赛题的常规解法一个 BERT 共享参数上面接三个 head每个 head 管一个任务。共享编码层的理由是司法三任务共享“理解事实描述”的能力三个 head 的梯度会把这些能力推向不同侧面训练收敛后比三个单任务模型各自跑的分数更均衡。class LegalMultiTaskModel(nn.Module): def __init__(self, num_charges, num_articles): super().__init__() self.bert BertModel.from_pretrained(bert-base-chinese) hidden self.bert.config.hidden_size self.charge_head nn.Linear(hidden, num_charges) self.article_head nn.Linear(hidden, num_articles) self.term_head nn.Sequential( nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, 1), ) def forward(self, input_ids, attention_mask): out self.bert(input_idsinput_ids, attention_maskattention_mask) pooled out.pooler_output charge_logits self.charge_head(pooled) article_logits self.article_head(pooled) # 多标签先不做 sigmoid term_pred self.term_head(pooled).squeeze(-1) return charge_logits, article_logits, term_pred逻辑说明article_head的输出先不套 sigmoid因为后面用BCEWithLogitsLoss它在内部做 sigmoid数值上更稳term_head加了一层隐藏层因为刑期回归比分类头更需要非线性变换。损失函数是这套框架里最值得抄的部分def multitask_loss(charge_logits, article_logits, term_pred, batch): l1 nn.CrossEntropyLoss()(charge_logits, batch[charge_label]) l2 nn.BCEWithLogitsLoss()(article_logits, batch[article_label].float()) l3 nn.SmoothL1Loss()(term_pred, batch[term_label]) return 0.4 * l1 0.4 * l2 0.2 * l3逻辑说明刑期回归用SmoothL1Loss也就是 Huber loss它对长刑期样本的大误差不如 MSE 敏感避免个别无期、死刑样本把梯度拉爆。权重 0.4/0.4/0.2 是常见初值先保证罪名和法条两个主任务刑期作为辅助信号参与训练。参数说明三个损失的量纲不一样CE 和 BCE 是对数尺度回归 loss 是“月”尺度如果不加权回归会把分类任务淹没。加权原则是让三者在初始验证集的量级接近0.4/0.2 不合适就按验证集表现调不用迷信固定值。3.3 刑期预测的标签归一化年、月、无期不该混在一起回归刑期预测最容易被忽略的是标签编码。不同年份赛题给的term格式不完全一样有的直接给月数比如“有期徒刑一年”存成 12有的给原始裁判表述字符串里带着“年”“月”。无期徒刑和死刑在有的版本里是 0有的版本是 None还有的版本给一个很大的数比如 240 个月。把这些混在一起做回归模型会学到“0 是最长的刑期”整个回归头被带偏。import re def normalize_term(term_raw): # 返回 (label, is_valid)label 统一用月表示 if term_raw is None: return 0.0, False if isinstance(term_raw, (int, float)): if term_raw 0: return 240.0, True # 常见做法无期映射为 240 个月 return float(term_raw), True if isinstance(term_raw, str): text term_raw.strip() if 无期 in text: return 240.0, True if 死 in text: return 300.0, True # 死刑单独映射不要和 0 混淆 # 处理“十年”“一年六个月”这类中文表述 total 0.0 for part in re.findall(r(\d)年, text): total int(part) * 12 for part in re.findall(r(\d)个月, text): total int(part) return total, True return 0.0, False逻辑说明把 None、无期、死刑从“缺失”里摘出来映射成明确的语义标签240 和 300 是人为定的语义边界。映射完之后还要检查这个函数是否覆盖了所有term取值比如“拘役三个月”“管制”这些它们能不能回归取决于赛题是否把它们排除在评测之外。参数说明映射值 240/300 会影响回归头的输出范围和 loss 数值改成 180/240 也行但训练集和预测集必须共用同一个函数否则预测阶段会对不上。更稳的做法是不要硬回归特殊刑期在项目说明里确认评测时是否单独计算无期、死刑的准确率常见做法是模型再加一个“是否无期/死刑”的二分类头把硬映射改成概率判断。4. 训练参数与评估口径照着项目说明复现也要自己调一遍这章把训练侧的关键参数和评估口径分开讲。项目说明里通常写了参数但照着抄不够不同 GPU、不同 transformers 版本、不同随机种子下最优配置可能差很多。先拿一组可靠初值把流程跑通再做小范围搜索。4.1 关键训练参数与推荐初值下面这组参数是我在类似任务上跑过多次的起点适合 BERT base 级别模型和十万条以内规模的裁判文书数据。参数推荐初值调整方向说明max_seq_len480显存紧张降到 256关键段截取后的长度batch_size16梯度累积补偿小 batch单卡 11GB 显存比较稳learning_rate2e-5小数据降到 1e-5BERT 微调普遍用 1e-5 到 3e-5warmup_ratio0.1数据量大可降到 0.05先升温再余弦衰减weight_decay0.01过拟合时加到 0.05只对非 bias 参数生效epochs3看验证集 F1 是否仍上升超过 5 轮一般开始过拟合seed42复现不同 run 时改固定 seed 才能对齐项目说明里的基线训练骨架里最需要注意的是学习率调度optimizer torch.optim.AdamW( [p for p in model.parameters() if p.requires_grad], lr2e-5, weight_decay0.01, ) total_steps len(train_loader) * epochs scheduler torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr2e-5, total_stepstotal_steps, pct_start0.1, )逻辑说明AdamW 是 BERT 微调默认优化器OneCycleLR 在前 10% 的步数里把学习率升到max_lr再余弦衰减到接近 0比恒定学习率稳。pct_start0.1对应warmup_ratio0.1。参数说明lr2e-5不要改成通用分类任务的 1e-3 或 1e-4预训练模型微调的学习率比从头训练小两个量级这是很多源码注释里都会提示的血泪经验。如果显存只够 batch_size8可以用梯度累积等效 16后面 4.3 节给具体写法。4.2 多标签阈值搜索长尾法条召回低的后悔药法条推荐是多标签输出常规做法是取 sigmoid 后大于 0.5 的作为预测。但 0.5 对长尾法条不友好高频法条容易同时命中好几个低频法条因为训练样本少logits 普遍偏低。在验证集上做一次小网格搜索把所有样本的预测概率存下来再找最优阈值。import numpy as np from sklearn.metrics import f1_score def search_article_threshold(probs, labels, thresholdsNone): if thresholds is None: thresholds np.arange(0.1, 0.9, 0.05) best (0.5, 0.0) for th in thresholds: preds (probs th).astype(int) macro f1_score(labels, preds, averagemacro, zero_division0) if macro best[1]: best (th, macro) return best[0], best[1]逻辑说明labels是验证集真实多标签矩阵形状是(样本数, 法条数)probs是 sigmoid 之后的概率。阈值只影响法条多标签头罪名预测是单标签不要套这个逻辑直接取 argmax。参数说明阈值搜索只在验证集上做不要拿到测试集上反复调否则会把验证集过拟合。搜出来的阈值通常在 0.3 到 0.45 之间如果低于 0.3先怀疑模型没收敛而不是法条真的都该召回。zero_division0必须带上否则某个阈值下所有法条都预测为 0 时会抛警告甚至报错。4.3 显存与时间预算本地能跑到什么程度BERT base 在 512 token 输入时单样本显存占用约 1GB 量级。batch_size16、max_seq_len480 在 11GB 显存的 1080Ti 或 2080Ti 上刚好卡住边缘8GB 显存就把 batch_size 降到 8再用梯度累积凑等效 batch。scaler torch.cuda.amp.GradScaler() accum_steps 2 for step, batch in enumerate(train_loader): with torch.amp.autocast(cuda): charge_logits, article_logits, term_pred model( batch[input_ids], batch[attention_mask]) loss multitask_loss( charge_logits, article_logits, term_pred, batch) scaler.scale(loss).backward() if (step 1) % accum_steps 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()逻辑说明混合精度在大 batch 下能省 30% 到 40% 显存fp16 的梯度在浅层 head 上没有严重精度损失GradScaler负责把 loss 放大防止梯度下溢。累积每两步更新一次参数效果上等效 batch_size 翻倍但中间表示还是占原来的显存所以它解决的是“batch 不够大导致训练不稳”的问题不是让显存凭空变小。参数说明accum_steps2等效 batch 16。用 2080Ti 跑法研杯初赛几万条样本一个 epoch 大约 1.5 到 2.5 小时3 个 epoch 够验证思路想上分一般要跑 5 个 epoch 加早停。项目说明里如果标注了“在 4 卡 V100 上训练 6 小时”单卡复现的时间预算按卡数线性放大即可别按同一个时间预期。5. 避坑指南司法 AI 赛题里最常翻车的 5 个细节这五条是我在复现类似源码时真实踩过或帮别人排查过的坑。每一条都按现象、原因、解决的顺序写排查时直接照顺序对。5.1 截断方向反了全模型只会输出“盗窃罪”现象训练几轮后验证集分数很低看预测结果几乎全是“盗窃罪”。原因预处理用了从头截断模型只看到了“被告人×××男汉族初中文化”这类当事人信息罪名依据被截掉了。裁判文书的结论性内容集中在后半段从头截等于把最该学的部分切掉。解决改成第 2.2 节的关键段定位或者直接截取文本末尾 512 字符。改完一定要打印一条预处理后的样本人工确认“本院认为”“盗窃”这类关键词还在不在文本里再进训练。5.2 罪名列表是多标签强行取第一个把案件信息丢了现象多罪名的案件预测分数特别差验证集 macro-F1 比单标签模型低一大截。原因accusation是列表一个案件可能同时触犯盗窃罪和故意毁坏财物罪。有的源码把列表只取第一个元素当成单标签处理等于把多标签案件按顺序强行丢弃信息。解决先统计多标签样本占比。占比超过 5%罪名头就从CrossEntropyLoss换成BCEWithLogitsLoss线下评测用多标签 macro-F1如果赛题定义是按主要罪名评测也要在代码里明确标注取哪个罪名而不是默认取第一个。5.3 刑期单位混乱无期被当成 0 个月现象刑期预测 loss 一直很大甚至输出负数。原因term_raw里混着“一年”“12”“无期徒刑”多种格式把字符串直接float()会抛错把“无期徒刑”当成 0 会让模型把 0 学成最大刑期。解决用 3.3 节的normalize_term统一成月无期映射 240死刑单独映射。同时改回归头用Softplus保证输出非负self.term_head nn.Sequential( nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, 1), nn.Softplus(), # 保证刑期预测为正 )5.4 姓名和法院名成了最强特征验证集虚高现象本地验证集分数很好看换到测试集直接崩盘。原因裁判文书里的被告姓名、法院名与罪名存在表面相关。比如某类案件集中在某个地区模型记住了地区名而不是事实描述姓名也可能泄漏案由信息。这类伪特征在本地同分布的验证集上有效测试集一换分布就露馅。解决在预处理里做姓名掩码最稳的做法是把“被告人”后跟的 1 到 3 个中文字符替换成“某”import re def anonymize(text: str) - str: # 只替换“被告人”后面的姓名避免误伤普通词语 text re.sub(r被告人([\u4e00-\u9fa5]{1,3}), 被告人某, text) return text注意掩码要同时应用在训练集和测试集不能只在一边做否则训练和预测的特征分布不一致。法院名和地区名如果也出现明显泄漏按项目说明里的敏感词表一并替换。5.5 transformers 版本漂移复现不出项目说明里的分数现象同一份源码两个人跑出来的 macro-F1 差两个点以上。原因transformers 库版本不一致。BERT 的 tokenizer 和模型权重在不同版本里存在细微差异尤其是老版本对特殊 token 的处理、max_position_embeddings的默认值会让同样一段文本编码出不同的 id。解决项目说明里如果写了 requirements先核对 transformers 版本是否被固定没有固定就在本地固定一个明确版本比如 4.30.2。复现完成后在项目说明里补一行“已在 transformers 4.30.2 下复现”给后面接手的人留一个后悔药。6. 把参赛源码改成自己的司法 NLP 基线冻结编码层先验证任务价值6.1 用已训练好的三头模型低成本试出新任务有没有信号拿到这套源码框架后最值得做的不是继续刷法研杯的榜而是把三个任务共享的那层 BERT 变成一个可复用的司法文本编码器。迁到要素识别、争议焦点分类、民商事案由预测这些任务时我的习惯是“冻结 BERT只训练新 head”。# 冻结共享编码层只训练新任务的 head for param in model.bert.parameters(): param.requires_grad False # 替换成新任务 head 后用较小的学习率如 1e-4只更新 head这个做法的好处是省时间不用重新跑预训练没有独享 GPU 时冻结编码层的模型在 CPU 上也能一两个小时内验证出“这个任务有没有足够的文本信号”。如果冻结后新任务的验证集分数已经明显高于随机水平说明任务可学再解冻全部参数做完整微调如果冻结前就一点信号都没有那大概率是数据标注质量或任务定义的问题别急着上复杂模型。我在 CAIL 之外的几个司法文本项目里沿用过这套思路收益最大的一次是把“是否支持再审”的二分类接到了刑期预测头的编码器上只换了一个 head 和标签定义验证集 F1 从 0.55 提到 0.63。这种迁移不需要改数据格式也不需要重新设计损失函数是参赛源码里性价比最高的一部分。最后提醒一句改源码之前先把原始包留个备份改坏了对一下 diff 就是后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表