简介:这份资源是2018年中国法研杯法律智能挑战赛(CAIL2018)的完整参赛源码与学习说明,面向计算机、数学、电子信息等专业的大学生及竞赛爱好者,可作为法律文本分类与司法智能方向的实战参考。压缩包共30个文件,约2.36MB,以18个Python脚本为核心,覆盖数据预处理、分词、标签编码、数据增强及TextCNN、双向GRU、ResNet等多种模型实现,另含10张训练曲线与预测结果截图、1份README说明文档,便于对照理解整体流程。已有112人学习下载,适合希望复现赛题方案、研究多模型对比与注意力机制的读者。通过阅读代码与说明,可掌握法律罪名预测任务的数据变换、模型搭建与评估思路,并借助成绩截图与预测演示直观感受不同网络结构的性能差异,为后续调试与二次开发提供清晰起点。
1. 从 CAIL2018 说起:一份法律 AI 竞赛源码到底能拿来干什么
如果你手上正好有一份「中国法研杯法律智能挑战赛参赛源码+学习说明(CAIL2018).zip」,先别急着解压跑train.py。CAIL2018 是中文法律智能领域被引用最多、复现需求最集中的一批数据集之一,任务本质是给定一段案情描述,预测所涉法条、罪名和刑期。它跟通用 NLP 竞赛最大的区别在于:标签空间是法律体系本身,法条有几百条、罪名有上百个、刑期是区间回归,三者还互相约束。所以这份源码真正的价值不是「跑出一个分数」,而是让你看清一条从原始判决文书到多任务预测的完整工程链路:数据怎么清洗、标签怎么编码、多任务 loss 怎么加权、评测脚本怎么对齐官方口径。
这份材料适合三类人:想入门法律 NLP 的学生、需要做司法辅助原型的产品团队、以及想拿中文长文本做多任务学习练手的算法工程师。下面我按「数据 → 模型 → 训练 → 评测 → 避坑 → 进阶」的顺序,把这条链路拆开讲清楚,每一步都给到能直接抄的参数和命令。
2. 数据管线:从判决文书到可训练样本的四个关键动作
2.1 先搞清楚 CAIL2018 的三个子任务和标签结构
CAIL2018 的原始数据是 JSON 行格式,每条样本大致包含fact(案情描述)、meta(含accusation罪名、relevant_articles相关法条、term_of_imprisonment刑期)。三个子任务分别是:
| 子任务 | 输入 | 输出 | 标签空间 | 评测指标 |
|---|---|---|---|---|
| 法条预测 | fact | 相关法条 ID 列表 | 约 200+ 条 | macro-F1 |
| 罪名预测 | fact | 罪名 | 约 100+ 类 | macro-F1 |
| 刑期预测 | fact | 刑期区间 | 离散区间 | 准确率 |
这里第一个坑就来了:法条预测是多标签,罪名预测是单标签。很多参赛源码把两者都当单标签处理,法条 F1 直接掉十几个点。你在读源码时先确认relevant_articles是用MultiLabelBinarizer还是LabelEncoder编码的,这一步决定了后面 loss 用BCEWithLogitsLoss还是CrossEntropyLoss。
2.2 案情文本清洗:长度截断策略比清洗规则更重要
法律文书里的fact动辄上千字,直接喂给 BERT 会爆显存。常见做法是截断到 512 token,但截断位置很讲究:案情的关键信息(作案手段、金额、后果)往往集中在中间偏后,从头截断会丢掉定罪依据。我一般用「头 128 + 尾 384」的拼接策略,或者按句切分后取包含数字和金额的句子优先保留。
import re from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") def clean_fact(text): # 去掉多余空白和不可见字符 text = re.sub(r"\s+", "", text) text = re.sub(r"[\u200b-\u200f]", "", text) return text def truncate_head_tail(text, max_len=512, head=128): ids = tokenizer.encode(text, add_special_tokens=False) if len(ids) <= max_len - 2: return ids # 头尾拼接,保留开头背景和结尾结果 head_ids = ids[:head] tail_ids = ids[-(max_len - 2 - head):] return head_ids + tail_ids逻辑说明:clean_fact处理的是原始文书里常见的全角空格和零宽字符,这些字符会让 tokenizer 产生大量无意义 token。truncate_head_tail的核心参数是head,设 128 是因为案情开头通常是当事人和背景,信息密度低但不能全丢;尾部保留 384 是因为判决结果和金额常在末尾。如果你的数据里金额集中在开头,把head调到 256 再试。
2.3 标签编码:法条 ID 不是连续整数,别直接当类别索引
原始数据里的法条 ID 是刑法条文号(如 234、264),不连续。如果你直接article_id - min_id当类别索引,会凭空造出几百个空类别,F1 计算全乱。正确做法是先用LabelEncoder拟合出「法条 ID → 连续索引」的映射,保存这个映射表,推理时再反查。
from sklearn.preprocessing import MultiLabelBinarizer import numpy as np # 假设 all_articles 是训练集里出现过的所有法条 ID 列表 mlb = MultiLabelBinarizer() mlb.fit([all_articles]) # 注意传入的是列表的列表 def encode_articles(article_list): # article_list 形如 [234, 264] return mlb.transform([article_list])[0].astype(np.float32) # 保存映射,推理时用 mlb.classes_ 反查 np.save("article_classes.npy", mlb.classes_)参数说明:MultiLabelBinarizer的classes_属性就是有序的法条 ID 数组,索引即模型输出维度。务必在训练集上 fit,验证集和测试集只 transform,否则会引入标签泄漏。这一步在参赛源码里经常被忽略,导致线下分数虚高、线上翻车。
2.4 刑期离散化:区间边界怎么切直接影响准确率
刑期预测官方是按区间算准确率的,常见切法是:死刑/无期、有期徒刑按月份分桶。我一般用[0, 6, 12, 24, 36, 60, 120, 240, inf]这组边界(单位月),对应 9 个类别。边界不是随便定的,要参考数据分布:如果 80% 样本集中在 6-36 个月,就在这段多切几刀。
import numpy as np BINS = [0, 6, 12, 24, 36, 60, 120, 240, np.inf] def term_to_class(months): # months 为 0 表示免于刑事处罚,-1 表示无期/死刑 if months < 0: return 8 # 单独一类 return np.digitize(months, BINS) - 1逻辑说明:np.digitize返回的是区间索引,减 1 是为了从 0 开始。无期和死刑单独归到最后一类,因为它们的样本量少但法律意义重大,混进有期徒刑会拉低整体准确率。这个映射表要跟评测脚本对齐,否则你算的准确率和官方口径对不上。
3. 模型搭建:多任务共享编码器的三种接法
3.1 为什么用共享 BERT + 三头输出,而不是训三个模型
法条、罪名、刑期三者高度相关:定了罪名基本就锁定了法条范围,法条又约束刑期区间。分开训三个模型不仅费资源,还丢掉了这种约束信息。共享编码器 + 三个分类头的结构能让底层语义表示同时服务三个任务,实测比单任务模型在法条 F1 上高 3-5 个点。
import torch import torch.nn as nn from transformers import BertModel class MultiTaskBert(nn.Module): def __init__(self, num_articles, num_charges, num_terms): super().__init__() self.bert = BertModel.from_pretrained("bert-base-chinese") hidden = self.bert.config.hidden_size self.dropout = nn.Dropout(0.3) self.article_head = nn.Linear(hidden, num_articles) self.charge_head = nn.Linear(hidden, num_charges) self.term_head = nn.Linear(hidden, num_terms) def forward(self, input_ids, attention_mask): out = self.bert(input_ids=input_ids, attention_mask=attention_mask) pooled = self.dropout(out.pooler_output) return { "article": self.article_head(pooled), "charge": self.charge_head(pooled), "term": self.term_head(pooled), }参数说明:dropout=0.3是我在法律文本上试出来的经验值,比默认 0.1 更能抑制过拟合,因为法律文书模板化严重、容易记住表面模式。三个头的输出维度分别对应法条数、罪名数、刑期桶数,从前面保存的映射表里取。
3.2 多任务 loss 加权:别用简单相加,用不确定性加权
三个任务直接loss = loss1 + loss2 + loss3是最常见的写法,但法条是多标签、罪名是单标签、刑期是单标签,量级差很多。我一般用 Kendall 的不确定性加权,让模型自己学任务权重:
class UncertaintyWeighting(nn.Module): def __init__(self, num_tasks=3): super().__init__() # log_sigma 是可学习参数,初始为 0 self.log_sigma = nn.Parameter(torch.zeros(num_tasks)) def forward(self, losses): total = 0 for i, loss in enumerate(losses): precision = torch.exp(-self.log_sigma[i]) total += precision * loss + self.log_sigma[i] return total逻辑说明:log_sigma越大表示该任务噪声越大,权重越低。这个公式来自 Kendall 2018 的多任务不确定性论文,实测比手工调权重省事,而且不会出现某个任务梯度被完全压制的情况。如果你不想引入额外参数,退而求其次用loss1 * 1.0 + loss2 * 0.5 + loss3 * 0.5也能跑,但法条任务要占大头。
3.3 训练脚本的关键超参:学习率和 warmup 怎么设
BERT 微调最忌讳大学习率。我一般用2e-5起步,warmup_ratio=0.1,weight_decay=0.01。batch size 受显存限制,单卡 16G 用batch_size=8+ 梯度累积 4 步等效 32。
python train.py \ --model_name bert-base-chinese \ --max_len 512 \ --batch_size 8 \ --grad_accum 4 \ --lr 2e-5 \ --warmup_ratio 0.1 \ --epochs 5 \ --weight_decay 0.01 \ --seed 42参数说明:grad_accum=4是在显存不够时的标准操作,注意学习率要按等效 batch size 缩放,等效 32 时2e-5是安全的。epochs=5是因为法律数据量不大,超过 5 轮验证集 F1 就开始掉。seed=42固定随机种子,方便复现——法律竞赛里同一份代码不同种子差 1-2 个点是常事,别忽略。
4. 评测与复现:怎么让本地分数和官方口径对齐
4.1 法条预测的 macro-F1 计算陷阱
macro-F1 是每个类别算 F1 再平均,稀有法条的权重和大法条一样。如果你的模型只学会预测高频法条,macro-F1 会很难看。评测时要用sklearn.metrics.f1_score(average="macro"),并且只对测试集中出现过的类别计算,否则会引入大量零样本类别拉低分数。
from sklearn.metrics import f1_score import numpy as np def eval_articles(y_true, y_pred, threshold=0.5): # y_true, y_pred 都是 multi-hot 矩阵 y_pred_bin = (y_pred > threshold).astype(int) return f1_score(y_true, y_pred_bin, average="macro", zero_division=0)参数说明:threshold=0.5是默认值,但法条预测的最优阈值往往在 0.3-0.4,因为多标签任务正样本稀疏。建议在验证集上扫一遍阈值,选 macro-F1 最高的那个。zero_division=0避免某类没有预测时报警告。
4.2 刑期预测的准确率口径
刑期预测官方是按「预测区间与真实区间完全一致」算准确率,不是回归误差。所以你的模型输出是分类 logits,取 argmax 后映射回区间,再和真实区间比对。这里容易翻车的是边界样本:真实刑期正好在桶边界上(如 12 个月),你的分桶规则和官方不一致就会判错。复现时先拿官方评测脚本跑一遍小样本,确认口径一致再全量跑。
4.3 复现实验的最小验证集
拿到源码后别直接全量训练,先切 500 条做冒烟测试:跑通数据加载、前向、loss 反传、评测全流程,确认没有维度错误和标签泄漏。这一步能省你几个小时的无谓等待。
# 冒烟测试:只取前 500 条 small_dataset = full_dataset.select(range(500)) # 跑 1 个 epoch,看 loss 是否下降、F1 是否非零逻辑说明:冒烟测试的核心是验证「链路通不通」,不是验证效果。如果 500 条上 loss 不降,大概率是标签编码或 loss 函数写错了,别急着上全量。
5. 避坑与排查:五个让参赛源码跑不起来的真实原因
5.1 现象:训练 loss 正常下降,但验证集 F1 一直是 0
原因:标签编码时用了LabelEncoder处理法条多标签,导致每个样本只保留了一个法条,而验证集是多标签,预测结果和真实标签完全错位。解决:确认法条任务用MultiLabelBinarizer,罪名任务才用LabelEncoder,两者不能混。
5.2 现象:显存溢出,报CUDA out of memory
原因:max_len=512且batch_size=16在 16G 卡上必爆。解决:降到batch_size=8加梯度累积,或者用max_len=384。别用torch.cuda.empty_cache()硬撑,那只是缓解不是解决。
5.3 现象:法条 F1 比论文低十几个点
原因:评测时对全部法条类别算了 macro-F1,包括测试集里没出现过的类别,这些类别 F1 为 0 拉低了平均。解决:只对测试集中出现过的类别计算,或者用官方评测脚本对齐。
5.4 现象:刑期预测准确率异常高(90%+)
原因:刑期分布极度不均衡,大部分样本集中在某个区间,模型全预测那个区间就能拿高分。解决:看混淆矩阵,确认不是「全预测多数类」的假象,必要时用 macro 指标或加权准确率。
5.5 现象:换台机器跑结果差很多
原因:随机种子没固定,或者transformers版本不一致导致 BERT 初始化不同。解决:固定seed,在requirements.txt里锁死transformers和torch版本,别用latest。
6. 进阶技巧:用标签约束做后处理,白捡几个点
多任务模型有个天然优势:三个头的输出可以互相校验。法条和罪名之间有强关联(比如盗窃罪对应刑法 264 条),刑期又和法条挂钩。我一般会在推理阶段加一层规则后处理:如果模型预测的罪名是「盗窃罪」但法条里没有 264,就把 264 的概率提上去;如果法条对应的法定刑期和预测刑期区间冲突,就调整刑期桶的概率。
# 简化的法条-罪名约束表 CHARGE_ARTICLE_MAP = { "盗窃罪": [264], "故意伤害罪": [234], "诈骗罪": [266], } def post_process(charge_logits, article_probs, term_logits): charge_idx = charge_logits.argmax() charge_name = idx_to_charge[charge_idx] # 如果罪名对应法条不在预测里,提升其概率 for art in CHARGE_ARTICLE_MAP.get(charge_name, []): if art in article_to_idx: article_probs[article_to_idx[art]] = max( article_probs[article_to_idx[art]], 0.5 ) return article_probs逻辑说明:CHARGE_ARTICLE_MAP不用全,覆盖高频罪名即可,几十条规则就能带来 1-2 个点的法条 F1 提升。注意阈值 0.5 是经验值,太高会引入误报,太低没效果,在验证集上扫一下。这个后处理不依赖额外训练,纯规则,性价比很高。
另一个技巧是模型集成:用不同种子训 3 个模型,推理时对 logits 取平均。法律文本噪声大,单模型方差高,集成能稳定涨 1-2 个点。代价是推理时间翻三倍,看你场景能不能接受。
最后说个我自己的习惯:每次改完数据管线或 loss,先跑 500 条冒烟测试,确认 loss 下降、F1 非零,再上全量。这个习惯帮我省过至少两次「训了一晚上发现标签编码错了」的血泪教训。法律 AI 这方向数据脏、标签空间大,耐心比技巧重要。希望帮到你。
本文还有配套的精品资源,点击获取