CAIL司法NLP赛题三年数据复现:从文本清洗到模型调优指南
简介这是一份覆盖中国法研杯司法人工智能挑战赛2018至2020年三个年度的完整资料包面向法律科技研究者、NLP竞赛选手及对智慧司法感兴趣的Python开发者。包内含1596个文件以971个py脚本为核心算法实现搭配155个json参数文件、123个txt语料与说明、多个model及config模型配置文件并附有dockerfile、sh部署脚本等压缩包整体37.68MB结构清晰便于分类查阅。已有250人学习下载适合用于复盘赛题思路、复现司法文本分类、要素抽取、相似案例匹配等典型任务也可直接借鉴其中的数据预处理与模型调优技巧。资料中提供了roberta_wwm等预训练模型的多种config配置能够帮助使用者快速搭建实验环境理解司法AI落地中的关键环节。1. 一个压缩包装下了三年司法NLP赛题先回答值不值得拆做中文NLP落地的同行对海量裁判文书一直又爱又恨爱它真实、量大、任务场景明确恨它长文本、多标签、类别分布极度不均衡。CAIL2018-2020这份资源恰好把司法AI最常见的三类任务——罪名预测、刑期预测、法条推荐——连同官方评测脚本一起打包了。它不是一份简单的“练习题”而是一套经过三年迭代的数据基准从2018年的多标签文本分类到2019年的阅读理解再到2020年的要素抽取与论辩点识别覆盖了从文本分类到机器阅读理解再到信息抽取的完整链路。对正在做长文本分类、多标签预测、预训练模型在垂直领域落地的人来说这份包比很多开放数据都值得先拆开摸一遍。适合三类人算法岗练手、法律信息化项目选型调研、以及准备参加同类比赛的选手。2. 数据仓库先摸清CAIL2018-2020的任务差异与文件结构拆开这个包的第一件事不是跑代码而是把三年的文件结构和平摊开看一遍。这三年数据在任务设计、标注粒度、评测指标上差别都不小直接拿同一套预处理脚本硬跑大概率会翻车。2.1 三年任务变迁从分类到理解再到抽取CAIL2018的核心是“一罪一案”的多标签分类给定起诉书中的“事实描述”字段预测被告触犯的罪名可能多个、对应刑法条文可能多个、以及刑期范围。这也是整个资源里最经典、复现成本最低的一部分。CAIL2019引入了问答形式。它不再只给一个事实描述就让模型输出标签而是给出问题、事实材料让模型回答“被告人是否构成某某罪”“该行为属于哪种量刑情节”这类是非题。这个转变意味着模型要具备一定的推理能力输入组织要从“单一文档”变成“问题文档”。CAIL2020又往抽取方向走了一步出现了类似“从文书中抽取关键要素”的任务形态。整体看下来三年数据的任务难度递进关系非常明显先学会分类再学会阅读理解最后学会抽取。如果刷过其中任何一年的题另外两年的数据也能顺着接上因为底层数据格式始终保持了类似的JSON结构只有字段数量和嵌套层级在变化。2.2 2018年基础数据一个真实的裁判文书长什么样2018年数据的核心文件是JSON格式每条数据对应一篇起诉书片段。常见的顶层字段包括案件ID、事实描述fact字段、罪名标签accusation、相关法条relevant_articles、以及刑期imprisonment。刑期单位是月这个坑后面专门说。一份事实描述通常有几百到上千字包含完整的案情叙述包括作案过程、涉案金额、因果关系这跟普通新闻文本的最大区别在于它的事实密度高但口语化表达少几乎没有“标题党”成分。结构大致是{ id: 1, fact: 某市某区人民检察院指控被告人张三于2021年6月..., accusation: [盗窃罪], relevant_articles: [264], imprisonment: 12 }这类数据对文本分类模型来说相当友好因为它不需要抽取论点直接对整段文本做多标签分类即可。唯一的问题是类别数太多CAIL2018罪名标签有202个法条标签也有上百个长尾分布极其严重部分罪名在训练集里只出现几次甚至一次。处理这种分布普通的交叉熵损失学不好尾部类别后文会讲重采样和阈值调整的做法。2.3 2019与2020的数据形态变化2019年的数据里案件不再是“一篇文书对应一套标签”而是“一篇文书对应若干问题对”。每一条样本通常包含一段事实描述、一个问题、一个答案选项可能标注了是非判断。最初跑的时候我犯过一个惯性错误拿2018的清洗流程直接处理2019数据结果发现切分和标签完全对不上。问题在于2018的样本是“文档级标签”而2019的样本是“片段级问答”输入输出都不一样。2020年的数据形态又变了部分文件采用了两层嵌套先按案由分大类再在内部标记要素标签。准备数据管线时建议按年份各建一个解析脚本不要试图用一套代码解决三年数据。虽然这类资源包的代码里通常带了官方baseline但看文件列表时会发现不同年份用了不同的脚本文件组织方式硬统一起来反而容易丢字段。2.4 评测脚本决定了训练目标包里附带的评测脚本是整个资源里容易被忽略、但其实最值得先读的部分。以2018年为例官方评测对罪名预测和法条推荐使用的是宏观F1对刑期预测则使用了特定的区间惩罚逻辑预测值落在真实刑期附近一定范围内算部分得分偏离太远直接零分。这意味着训练时如果把刑期当成21分类从“十年以上”到“无罪”分段来做损失函数和评测指标之间的对齐是有点“反直觉”的——交叉熵算出来的最优解不见得是评测上的最高分。我的习惯是拿到包之后第一个周末先不训练模型而是读评测脚本把score计算函数跑一遍自己构造三五个假标签验证输出格式。这一步做好了后面训练出来的模型才有意义否则模型再准提交格式不合规也是白搭。3. 把裁判文书变成样本清洗、标签构建与张量化实战这一章是整个复现流程里最不性感但最容易出bug的部分。CAIL数据不是工业级干净数据里面混着全角半角符号、乱码段落、空fact字段、以及被错误切分的长句子。处理顺序建议固定为字段读取 → 清洗 → 标签编码 → 截断与张量化。3.1 清洗流程去噪音、统一符号、保留事实主体裁判文书的第一段通常是“某检察院指控”或“本院认为”清洗时不需要做严格的依赖解析但要注意把杂讯控制在影响模型训练之前。常见做法是先做一轮基础正则import re def clean_fact(text: str) - str: t text.replace(\u3000, ).replace(\xa0, ) # 统一全角字符为半角注意保留中文标点 t re.sub(r[“”], , t) t re.sub(r[‘’], , t) # 去掉连续空白符但保留换行用于分段 t re.sub(r[ \t], , t) t re.sub(r\n{3,}, \n\n, t) # 去掉控制字符、非法字节 t .join(ch for ch in t if ch or ch \n) return t.strip()这段逻辑里两个地方容易踩坑。一是全角逗号、顿号不要粗暴替换成英文逗号中文模型对“”和“,”的语义感知不同强制统一反而会损失细粒度信息二是不要在这一步做停用词过滤CAIL文本里的“本院”“公诉机关”“被告人”等词本身就是关键信号不同于通用语料里的“的”“了”。清洗的目标是防崩不减肥重点处理会让tokenizer报错或让模型学到空模板的低质片段。3.2 标签构建多标签罪名、法条索引与刑期分段CAIL2018的罪名和法条都是多标签。训练时要先把全部类别收集成id映射再为每条样本生成一个多热向量。但直接生成一个202维的多热向量有两个问题类别太多导致标签稀疏训练集没出现过的罪名在验证集和测试集里也可能存在映射表必须以训练集验证集全量构建。def build_label_mapping(labels_list): # labels_list: 全量样本中所有的罪名/法条集合并集 mapping {} for lab in labels_list: for item in lab: if item not in mapping: mapping[item] len(mapping) return mapping def multi_hot(labels, mapping, num_classes): vec [0] * num_classes for lab in labels: if lab in mapping: vec[mapping[lab]] 1 return vec刑期处理的思路则不同。CAIL2018官方将刑期划分为若干个区间常见做法是设定边界为“0无罪或免予刑事处罚、半年以下、半年到一年、一年到三年……十年以上、无期、死刑”分类数量大概是二十几类。这里要注意的是把“单位”彻底焊死在月读入时imprisonment是月数比如24代表两年写边界时也用月不要中途切换成年来比较否则后面看loss曲线时数值符号会非常困惑。3.3 长文本截断与分块策略BERT的512限制CAIL的事实描述很多超过1000字直接把整段塞进BERT必然截断。业界常用的三条路线直接截前512丢尾部信息、取前128后384保住首尾、或者分成两段过两个BERT再pool。对CAIL这类文书事实的关键信息涉案金额、时间、因果链在全文都有分布只截头部会明显掉F1。我一般会在datasets层面做一个动态截断from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) def encode_fact(fact: str, max_len: int 512): tokens tokenizer.tokenize(fact) if len(tokens) max_len - 2: token_ids tokenizer.convert_tokens_to_ids([CLS]) \ tokenizer.convert_tokens_to_ids(tokens) \ tokenizer.convert_tokens_to_ids([SEP]) else: head tokens[: int(max_len * 0.3)] tail tokens[-int(max_len * 0.7) :] token_ids tokenizer.convert_tokens_to_ids([CLS]) \ tokenizer.convert_tokens_to_ids(head) \ tokenizer.convert_tokens_to_ids(tail) \ tokenizer.convert_tokens_to_ids([SEP]) return token_ids注意参数30%给头部70%给尾部符合判决文书“首段定性末段结论”的叙事结构。如果做司法要素抽取这类细粒度任务建议不要用这种截断法而是用滑窗切段重叠段投票否则很容易把跨段的证据链截断。数据矩阵构建完成后建议顺手存成torch的TensorDataset并做一次shape打印验证标签维度必须是(num_samples, num_classes)。发现维度对不上先查build_label_mapping那里是不是把字符串直接当成了list传进去。4. 训练与评测对齐BERT参数选择和三任务输出格式数据管线就位后训练部分的核心不是“调个高精度模型”而是让模型的输出格式与官方评测脚本对齐。很多参赛者在模型F1刷到85%以上之后提交分数反而比本地低问题基本都出在输出格式和类别顺序上。4.1 基座模型选择通用BERT起步领域模型做增量CAIL数据量不小但202个罪名类别加上上百个法条类别通用BERT直接微调是完全可以起飞的。不要一上来就找法律领域的预训练模型——它们参数更大、部署成本更高在训练不充分时反而会比通用BERT差。通常先跑一个bert-base-chinese作为基线记录验证集分数再决定是否要换法律领域基座。如果计划把三个任务放进同一个模型常见方案是共享BERT编码器然后接三个分类头一个多标签分类头罪名、一个多标签分类头法条、一个单标签分类头刑期。刑期分类头使用softmax罪名和法条使用sigmoid阈值。损失函数方面多标签任务用binary_cross_entropy_with_logits单标签任务用cross_entropy。注意三个头的损失scale不要默认都为1.0刑期分类的损失如果太小会主导不了模型我一般设置罪名:法条:刑期1.0:1.0:0.8此比例不是定论具体以验证集F1波动为准。4.2 训练超参数批次、学习率与类别重采样CAIL文本长长文本BERT的显存占用比普通文本高不少。base模型512长度下batch_size设8到16比较稳妥。学习率使用BERT微调常见的2e-5但多标签长尾分布下可以试试分层学习率optimizer_grouped_parameters [ {params: bert_model.parameters(), lr: 1e-5}, {params: cls_heads.parameters(), lr: 5e-5}, ]理由很简单BERT部分是通用语义动太多容易遗忘预训练知识分类头是随机初始化需要更大步长去拟合任务。另外对长尾罪名的重采样用weights给rare类别更高的loss权重比欠采样更稳妥。司法文本中某些罪名出现的频次差几百倍欠采样会直接丢掉刑期分布信息。4.3 推理输出与评测脚本的格式对齐评测脚本期望的输出通常是文本格式的提交文件每行包含案件ID、预测罪名列表、预测法条列表、预测刑期。这里有一个非常隐蔽的问题类别顺序必须和官方给的标签顺序一致不能用训练时自己构建的mapping顺序去生成提交文件。预测结果先映射回官方类别ID再写入文件。def write_submission(pred_ids, pred_articles, pred_penalty, official_label2id): with open(submission.json, w, encodingutf-8) as f: for idx in range(len(pred_ids)): # 将内部模型索引转换回官方比赛ID accusation_names [ official_label2id[i] for i in pred_ids[idx] ] record { caseid: case_ids[idx], accusation: accusation_names, articles: pred_articles[idx], penalty: pred_penalty[idx], } f.write(json.dumps(record, ensure_asciiFalse) \n)清洗掉内部索引之后最好在本地跑一遍官方评测脚本而不是直接提交到平台上。如果发现分数对不上先看是不是多标签阈值选得太激进——sigmoid输出的概率不要直接取0.5而是在验证集上搜索最佳阈值比如0.3到0.7之间按0.05步长扫一遍。这个搜索对小类别的F1影响非常大常常可以涨2到3个点。5. 避坑指南五个让模型分数对不上的常见问题复现CAIL系列数据时我踩过的坑基本都集中在数据处理和格式对齐上。模型本身的问题反而是最少的。以下五条按触发频率排序。5.1 清洗后出现空fact分词直接报错现象数据加载正常打进模型时tokenizer报警检查发现部分样本的fact字段经过clean函数后变成了空字符串或只剩“\n\n”。原因原始文本里有少量纯空白或只含标点的文书片段正则把可见字符也过滤掉了。解决在Dataset构建时加一层非空校验清洗后如果len(text) 10直接跳过该样本。训练数据量大丢个十来条不影响统计意义。注意跳过时要同步丢掉label否则后来的样本和数据错位。5.2 刑期单位混用月和年现象损失在下降但刑期分类的验证准确率始终在某个阈值附近抖动调阈值也没用。打印预测结果时发现真实刑期12个月被预测成“一年以上三年以下”这一类但真实标签明明也是同一个范围为什么F1还是低原因模型输出的概率分布是对的但刑期区间的切分点在“月”和“年”之间写岔了。比如真实边界区间为“12-36月”被错误地设置为“1-3年”由于label编码不一致导致同类样本被分类到相邻类别。解决建一个penalty_boundries字典统一以月为key训练前检查一遍边界列表是否单调递增且不与其它区间重叠。再写一个单元测试随机选20个样本手动验证区间划分结果和官方JSON里的imprisonment数值范围是否匹配。5.3 多标签阈值固定0.5导致小类全军覆没现象罪名F1整体看起来还可以但检查per-class报告时低频罪名的F1全是0。原因sigmoid输出后直接取0.5作为正负类边界低频类别学习不充分logits整体偏低。解决把验证集的预测概率保存下来用网格搜索找每类独立阈值。具体做法是先用默认阈值0.5得到一个初步mask再统计每个罪名在验证集上的最大概率分布把阈值调整到使得该类F1最大的位置。低频类别阈值经常会降到0.2以下。5.4 验证集划分泄漏现象本地验证F1很高榜单分数差一大截。看起来是过拟合但降参后本地分数也跟着降。原因读取官方数据时直接用train_test_split做随机切分没有考虑到同一案件可能同时出现在训练和验证文件里。CAIL某些年份里同一案件的多个片段是分开存放的随机切分会把同一个案件的不同片段分到两侧造成标签泄漏。解决按案件ID去重划分不是按数据行划分。如果一个案件ID有多条数据全部放进同一边。划分后再检查验证集中是否出现了训练集里的案件ID用set交集做断言。5.5 评测脚本里的大小写和格式容错现象生成submission.json后本地跑eval提示“KeyError: caseid”或者“unexpected article id”。原因官方评测脚本对code字典的key要求是特定类型部分参赛者把案件ID转成了int但官方期望是字符串或者法条ID写成中文而非数字。解决读评测脚本的开头部分把ID字段的数据类型完全按官方格式导出。写submission之前先用5条假数据在官方评测脚本里跑一遍冒烟测试确认大部分字段能解析出来再上全量。这个习惯在所有比赛中都通用不限于CAIL。6. 把这份资源焊进自己的模板一个最小可复现的提交格式校验器每次拿到新的NLP比赛数据我做的第一件事不是训练模型而是搭一个“提交格式校验器”。CAIL这个包让我养成了这个习惯现在分享一个可以直接套用的精简版。校验器的核心任务有三个数据字段完整、ID类型正确、标签值合法。import json import sys def validator(submission_path, gold_path): with open(submission_path, r, encodingutf-8) as f: preds [json.loads(line) for line in f if line.strip()] with open(gold_path, r, encodingutf-8) as f: golds [json.loads(line) for line in f if line.strip()] assert len(preds) len(golds), 行数不一致检查是否过滤了空样本 pred_ids set(p[caseid] for p in preds) gold_ids set(g[caseid] for g in golds) assert pred_ids gold_ids, caseid集合不一致存在漏预测或多预测 for p, g in zip(preds, golds): assert type(p[caseid]) type(g[caseid]), caseid类型不一致 assert isinstance(p[accusation], list), accusation必须是列表 for art in p[articles]: assert art in g_all_articles, f非法法条id {art} print(校验通过可以提交) if __name__ __main__: validator(sys.argv[1], sys.argv[2])这个脚本的另一个用途是快速发现数据切分漏洞如果验证集的caseid集合和训练集有交集运行脚本时会直接报错避免带着泄漏数据去调参。实际使用中我还习惯在代码里加一段对“gold所有类别并集”的缓存逻辑保证每次跑之前先看一遍全量类别防止类别映射表老化。从那以后我每次复现这类大型比赛资源包都会强制先做三件事读评测脚本、空跑提交格式、清洗后打印文本长度分布。这三个动作花费的时间不到一小时但能挡掉后面几天的无效调参。希望这份拆包笔记帮你在CAIL2018-2020上少走几个弯路。本文还有配套的精品资源点击获取