ARTICLE DETAIL

资讯详情

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

CAIL法律NLP实战:基于BERT的多任务模型构建与调参

CAIL法律NLP实战:基于BERT的多任务模型构建与调参 简介这份资源收录了中国法研杯司法人工智能挑战赛CAIL2018至2020年参赛源码与项目说明面向具备一定Python和深度学习基础的算法学习者、竞赛参与者以及计算机相关专业学生。压缩包共1595个文件以971个py源码文件、155个json配置、123个txt文档、61个html页面、53个md说明及多种config/yaml/sh脚本为主其中包含large级roberta模型各环节配置文件、dockerfile与日志等可支撑从数据处理、模型训练到服务部署的完整链路。资源整体37.69MB体积适中已吸引199人前来学习。内容覆盖多届赛题中法律阅读理解、要素抽取、罪名预测等典型任务既有可直接运行的代码也有项目结构说明与实验记录。尤其适合用于课程设计、期末大作业或毕业设计参考读者可借助源码理解司法AI任务的建模思路并基于自身课题进行二次开发。1. 从CAIL真题看法律文档的结构化难题一份刑事判决书里事实描述、证据列举、诉讼请求和判决依据混在几千字连续文本里人类法官能迅速定位到“被告人张某以非法占有为目的……”这类关键句但让机器在同样的文本里找出定罪依据、匹配法律条文、估算刑期难度完全不同。CAILChina AI and Law Challenge司法人工智能挑战赛2018到2020年连续三届比赛就是把这些问题拆成罪名预测、法条推荐、刑期预测等具体任务用真实裁判文书构建评测集要求参赛者提交能在分类准确率和刑期误差上同时过关的模型方案。这篇博文适合正在做法律NLP或中文文本分类的工程师。我会从数据预处理、模型选型、多任务实现到评测提交把一套不依赖特殊硬件也能跑通的参赛源码思路完整讲清楚包括那些只有实际调过CAIL数据才会踩到的坑。2. 数据预处理细节判决书如何被切成模型能用的训练样本2.1 CAIL官方案例格式与标签体系每届CAIL给出的原始比赛数据通常是JSON格式每条样本包含案件的事实描述、参与人信息、所涉法条、罪名以及刑期区间。以常见格式为例{ fact: 被告人张三以非法占有为目的采用秘密窃取手段盗窃他人财物数额较大其行为已构成盗窃罪。, meta: { criminals: [张小明], defendant_penalty: [有期徒刑八个月], relevant_articles: [264], accusations: [盗窃罪] } }代码里的fact字段是模型唯一能直接利用的输入文本meta中relevant_articles是法条编号列表accusations是罪名列表。刑期没有直接给数字而是藏在defendant_penalty这种自然语言字符串里需要靠自己解析成月数或天数。把defendant_penalty字符串转成可用标签时常见做法是import re def parse_penalty(text): 从判决文本提取刑期月支持年/月/日混合表达 total_months 0 year_m re.search(r(\d)年, text) month_m re.search(r(\d)个月, text) day_m re.search(r(\d)日, text) if year_m: total_months int(year_m.group(1)) * 12 if month_m: total_months int(month_m.group(1)) if day_m: total_months round(int(day_m.group(1)) / 30) return total_monthsparse_penalty函数先通过正则表达式分别匹配“X年”“X个月”“X日”三种时间单位再统一折算成月数。这里没有用复杂的时间解析库是因为判决书文本里格式相对固定正则反而更可控出错了也容易定位。如果碰到“管制”“拘役”这类非自由刑我一般会单独映射成特殊类别不强行折算。2.2 数据划分与文本截断策略CAIL数据集的官方划分往往训练集和测试集不平衡同一件案子的多个被告人可能同时出现在训练和测试两段直接随机切分会造成轻微数据泄露F1分数虚高零点几。稳妥的做法是在划分前先去重from sklearn.model_selection import train_test_split def split_by_defendant(data, test_size0.1): 按被告人ID分组后划分避免同一人的案件跨集 case_ids list({item[case_id] for item in data}) train_ids, test_ids train_test_split( case_ids, test_sizetest_size, random_state42 ) train_data [d for d in data if d[case_id] in set(train_ids)] test_data [d for d in data if d[case_id] in set(test_ids)] return train_data, test_data拆分逻辑是先把所有案件ID去重再用scikit-learn的train_test_split按比例切分最后根据案件ID回捞原始样本。random_state固定为42保证每次实验划分一致。按被告人而不是按行切分是为了防止同一个人的多起诉讼文档在训练和验证集间互相“泄露”信息。文本长度方面CAIL的判决书事实描述通常在200到1500字之间BERT类模型的输入上限一般是512个token直接全部塞进去会溢出。我常用的做法是前512后256的拼接截断def truncate_text(text, tokenizer, max_len512): tokens tokenizer.tokenize(text) if len(tokens) max_len: return tokens head tokens[: int(max_len * 0.8)] tail tokens[-int(max_len * 0.2):] return head tail把前80%和后20%拼起来是因为判决书的关键定罪信息通常集中在开头的事实描述和末尾的判决依据中间的过程性叙述对分类帮助有限。这种做法比单纯从头截断在CAIL上能稳定提升1到2个百分点的F1。3. 模型主体实现罪名预测、法条推荐与刑期预测的多任务模型3.1 为什么不能把三个任务拆成独立模型CAIL官方评测同时考察罪名预测的micro/macro F1、法条推荐的准确率和刑期预测的距离误差。如果罪名、法条、刑期各训练一个独立模型除训练成本高外还会损失任务间共享的语义关联。盗窃罪对应的法条集中在刑法第264条刑期区间通常在三年以下三者是强耦合关系。多任务共享底层编码层可以让罪名分类的梯度帮助法条推荐收敛法条推荐的信号也能约束刑期预测的边界范围。3.2 基于BERT的多任务模型结构常规的bert-base-chinese加上三个输出头即可构成CAIL多任务框架import torch import torch.nn as nn from transformers import BertModel class CAILMultiTaskModel(nn.Module): def __init__(self, num_accusations, num_articles, num_sentence_bins): super().__init__() self.bert BertModel.from_pretrained(bert-base-chinese) self.accusation_head nn.Linear(768, num_accusations) # 罪行多分类 self.article_head nn.Linear(768, num_articles) # 法条多标签 self.penalty_head nn.Linear(768, num_sentence_bins) # 刑期区间分类 def forward(self, input_ids, attention_mask): outputs self.bert( input_idsinput_ids, attention_maskattention_mask, return_dictTrue ) cls_vec outputs.last_hidden_state[:, 0, :] # [CLS]向量 accusation_logits self.accusation_head(cls_vec) article_logits self.article_head(cls_vec) penalty_logits self.penalty_head(cls_vec) return accusation_logits, article_logits, penalty_logitsaccusation_head是一个线性层把BERT输出的768维[CLS]向量映射到罪名类别数输出维度代表每个罪名的得分。article_head输出维度是法条数但这是一个多标签分类因为一个案件可能同时适用多条法律训练时用BCEWithLogitsLoss而非交叉熵。penalty_head把刑期映射成若干个区间类别原始刑期数值先离散化成桶模型只负责预测落在哪个桶里。3.3 多任务损失函数如何配权重三个任务的输出规模差异很大罪名分类约200多类法条多标签约100多类刑期区间约几十类。直接用加法合并损失罪名和法条的梯度会主导学习刑期预测很难收敛。我一般会动态调整三个损失的权重loss_fct_ce nn.CrossEntropyLoss() loss_fct_bce nn.BCEWithLogitsLoss() a_logits, art_logits, p_logits model(input_ids, attention_mask) loss_a loss_fct_ce(a_logits, accusation_labels) loss_art loss_fct_bce(art_logits.float(), article_labels.float()) loss_p loss_fct_ce(p_logits, penalty_labels) final_loss 1.0 * loss_a 0.8 * loss_art 0.6 * loss_p罪名的交叉熵权重最高法条次之刑期的权重最低初始比值可以按1.0、0.8、0.6设置。法条用BCEWithLogitsLoss要求标签先转成多热向量即每条样本的法条编号位置置1其余置0。权重不要一开始就调得过狠先让模型跑通观察三个loss的下降速度如果某个loss在第3轮后仍不下降再把它的权重提高0.2到0.3。3.4 训练超参与文本编码的最终配置参数推荐值说明预训练模型bert-base-chinese中文裁判文书直接可用无需领域预训练就能到baseline水平max_len512超过部分按前80%后20%截断batch_size16-32显存不足时优先减半batch不降max_lenlearning_rate2e-5对BERT头部使用32倍学习率线性层用2e-4epochs8-15CAIL测试集上通常第8轮后micro F1开始饱和warmup_ratio0.1前10%步数线性预热防止BERT剧烈扰动weight_decay0.01只用在全连接层不进BERT学习率这一项值得展开。整个模型因为承载多任务收敛比纯分类慢把head层的学习率调大成BERT主体的10倍可以让新初始化的线性层快速找到合适梯度方向老编码层缓慢更新。warmup_ratio设为0.1意味着比如1000个训练step前100步学习率从0线性升到目标值。训练时的优化器用的是AdamW具体配置不特殊关键在于weight_decay不要设置在BERT的全部参数上可以用get_parameter_names()方法筛选出非LayerNorm和bias的参数做衰减。4. 踩坑与调参从baseline到可复现的F1提升路径4.1 标签不平衡的3个常用处理手法CAIL2018罪名分布极不均衡盗窃罪、危险驾驶罪的样本量可能是非法持有毒品罪的几十倍。直接拿原样本训练macro F1会比micro F1低将近10个百分点。处理手法不外乎三种按成本从低到高排列。第一种是损失函数加权统计每类罪名的样本数并计算逆频率from sklearn.utils.class_weight import compute_class_weight import numpy as np classes np.array(all_accusations) class_weights compute_class_weight( class_weightbalanced, classesnp.unique(classes), yclasses ) class_weights torch.tensor(class_weights, dtypetorch.float).to(device) loss_fct_ce nn.CrossEntropyLoss(weightclass_weights)用scikit-learn的compute_class_weight根据训练集所有罪名的出现频次生成权重出现数量越少的类别权重越高。在CrossEntropyLoss里传入weight参数相当于在交叉熵计算中乘上这个系数梯度更新时稀有罪名会被放大。注意class_weight必须在数据加载时提前算好不能每个batch现算否则会导致权重不稳定。第二种是设计合理的训练集采样器正常的多任务数据加载可以自己写一个WeightedRandomSampler让每个batch尽量包含少量类别的样本第三种方法是用标签平滑label smoothing把原始one-hot标签改成软标签比如把正确类别的概率设为0.95错误类别均分剩下的0.05能缓解模型对重复罪名的过拟合。CAIL赛场上主流方案里第一种方法对macro F1的提升最明显容易操作且不增加训练时间。第二种采样器适合数据量特别大的场景但可能改变数据分布导致micro F1变差。第三种对多标签法条推荐的作用大于对罪名分类的作用。4.2 刑期预测的离散化方案刑期连续回归在训练时很容易被极端长刑期样本拉偏所以多数解决方案会把它离散化。常见区间划分有两种import numpy as np def penalty_to_bin(months, bin_size6): 按6个月等距切分刑期区间上限为20年 max_val 240 # 20年 240个月 if months max_val: return max_val // bin_size - 1 return int(months // bin_size) def bin_to_penalty(bin_idx, bin_size6): 从预测区间反推中心值用于误差计算 return int((bin_idx 0.5) * bin_size)penalty_to_bin把原始月数除以6再取整比如8个月对应bin 17个月对应bin 1这样相邻月的样本被划进同一桶降低模型区分难度。上限设为20年超过的直接归到最后一个桶。预测时从bin索引还原成中心月份数用于计算距离误差。离散化粒度需要根据数据分布微调如果绝大多数刑期在3年以下6个月的桶显得过于粗糙如果改用3个月一个桶会得到更精细的区分但类别数增多同样样本下每个桶的数据变少准确率会下降。CAIL2018数据集里三年以下刑期占六成以上我用5个月桶能平衡区分度和准确率。4.3 同一原告多标签问题的边界处理判决书经常一次包含多个罪名且法条有多条比如盗窃加故意伤害。罪名分类的输出是softmax多分类天然假设一个样本只属于一个类别而事实上一个案件可以有多个罪名。CAIL2018的罪名任务其实允许单标签预测但线上评测时如果黄金标准有两个罪名而你只预测一个F1会吃亏。实现阶段我见过不少人忽略这个问题直接对多罪名样本取第一个做标签导致模型完全没有预测复合罪名的能力。正确做法是罪名任务同样使用多标签二分类即将nn.CrossEntropyLoss换成BCEWithLogitsLoss并设置一个阈值判断哪些罪名成立。当然这样处理会增加输出空间的稀疏性所以阈值一般设在0.3到0.5之间具体值可在验证集上用小范围搜索确定。4.4 测试集效果不升反降时先查这3个位置发现测试集指标不如验证集时不要急着调模型结构。首先检查tokenizer的词典是否覆盖了判决书里的生僻字和法条编号BERT自带词典会把这些token拆成[UNK]直接影响CLS向量质量解决办法是自定义加词后重训tokenizer其次检查是否把验证集做过数据清洗而测试集没有格式不统一导致分布漂移最后看是否有测试集专属的噪声文本例如“本案因被告人未上诉判决已发生法律效力”这类固定套话会干扰模型判断可以在预处理阶段用正则删掉。5. 提交评测与机器学习方案迁移从CAIL竞赛到法律NLP质检5.1 复现官方评测指标的计算公式提交到CAIL官网前先用本地评测脚本跑一遍。法条推荐和罪名预测均计算micro F1和macro F1刑期预测计算距离误差。下面这段本地评测脚本可以对照自己的预测结果做验证def compute_cail_scores(y_true, y_pred, num_classes): y_true/y_pred: list of list每个元素是该样本的类别编号 from sklearn.metrics import f1_score import numpy as np y_true_bin np.zeros((len(y_true), num_classes), dtypeint) y_pred_bin np.zeros((len(y_pred), num_classes), dtypeint) for i, labels in enumerate(y_true): for lbl in labels: y_true_bin[i][lbl] 1 for i, labels in enumerate(y_pred): for lbl in labels: y_pred_bin[i][lbl] 1 micro f1_score(y_true_bin, y_pred_bin, averagemicro) macro f1_score(y_true_bin, y_pred_bin, averagemacro) return micro, macro先构造全零的多热矩阵真实标签和预测标签分别在对应类别位置置1然后交给sklearn的f1_score分别计算micro和macro。micro相当于把每个样本每个类别都当成一个独立的二分类实例来统计全局的precision和recall对多数类更敏感macro先算每个类别的F1再取算术平均对少数类影响更大。两个分数差距过大时说明你在稀有罪名的召回上还有明显短板。5.2 用CAIL方案迁移到合同风险审查CAIL竞赛的源码骨架本质上是一个多任务法律文本理解引擎这套结构迁移到合同审查场景时只需要替换任务定义事实描述换成合同条款片段罪名换成风险类型法条换成风险等级刑期换成建议修改幅度。BERT编码层不需要替换三个输出头的维度换成新任务的类别数即可。因为底层的“法律文本-语义标签”映射能力在预训练阶段已经建立即使新的标注数据只有几千条也能在较短训练轮数内达到还不错的准确率。5.3 最后一点保存模型时同时保存tokenizer训练完提交前把模型权重和分词器一起保存是很多人忽略的细节。只用代码保存权重文件换机器预测时会因为tokenizer版本不一致产生完全不同的切分结果。推荐做法是model.save_pretrained(./cail_final_model) tokenizer.save_pretrained(./cail_final_model)第1行保存模型权重和配置文件第2行保存词表与分词规则。重新加载时只需要一行from transformers import BertModel, BertTokenizer model BertModel.from_pretrained(./cail_final_model) tokenizer BertTokenizer.from_pretrained(./cail_final_model)这两个方法会生成pytorch_model.bin、config.json、vocab.txt、tokenizer_config.json等文件是模型复现和迁移部署的最小完整集合缺任何一个都会在加载时报错或静默降低精度。之后无论要微调还是直接推理都无需再依赖原始竞赛数据集路径。本文还有配套的精品资源点击获取
返回列表