news 2026/9/12 21:56:39

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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真题看法律文档的结构化难题

一份刑事判决书里,事实描述、证据列举、诉讼请求和判决依据混在几千字连续文本里,人类法官能迅速定位到“被告人张某以非法占有为目的……”这类关键句,但让机器在同样的文本里找出定罪依据、匹配法律条文、估算刑期,难度完全不同。CAIL(China AI and Law Challenge)司法人工智能挑战赛2018到2020年连续三届比赛,就是把这些问题拆成罪名预测、法条推荐、刑期预测等具体任务,用真实裁判文书构建评测集,要求参赛者提交能在分类准确率和刑期误差上同时过关的模型方案。这篇博文适合正在做法律NLP或中文文本分类的工程师。我会从数据预处理、模型选型、多任务实现到评测提交,把一套不依赖特殊硬件也能跑通的参赛源码思路完整讲清楚,包括那些只有实际调过CAIL数据才会踩到的坑。

2. 数据预处理细节:判决书如何被切成模型能用的训练样本

2.1 CAIL官方案例格式与标签体系

每届CAIL给出的原始比赛数据通常是JSON格式,每条样本包含案件的事实描述、参与人信息、所涉法条、罪名以及刑期区间。以常见格式为例:

{ "fact": "被告人张三以非法占有为目的,采用秘密窃取手段盗窃他人财物,数额较大,其行为已构成盗窃罪。", "meta": { "criminals": ["张小明"], "defendant_penalty": ["有期徒刑八个月"], "relevant_articles": [264], "accusations": ["盗窃罪"] } }

代码里的fact字段是模型唯一能直接利用的输入文本,metarelevant_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_months

parse_penalty函数先通过正则表达式分别匹配“X年”“X个月”“X日”三种时间单位,再统一折算成月数。这里没有用复杂的时间解析库,是因为判决书文本里格式相对固定,正则反而更可控,出错了也容易定位。如果碰到“管制”“拘役”这类非自由刑,我一般会单独映射成特殊类别,不强行折算。

2.2 数据划分与文本截断策略

CAIL数据集的官方划分往往训练集和测试集不平衡,同一件案子的多个被告人可能同时出现在训练和测试两段,直接随机切分会造成轻微数据泄露,F1分数虚高零点几。稳妥的做法是在划分前先去重:

from sklearn.model_selection import train_test_split def split_by_defendant(data, test_size=0.1): """按被告人ID分组后划分,避免同一人的案件跨集""" case_ids = list({item["case_id"] for item in data}) train_ids, test_ids = train_test_split( case_ids, test_size=test_size, random_state=42 ) 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_len=512): 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_ids=input_ids, attention_mask=attention_mask, return_dict=True ) 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_logits

accusation_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_len
learning_rate2e-5对BERT头部使用32倍学习率,线性层用2e-4
epochs8-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_weight="balanced", classes=np.unique(classes), y=classes ) class_weights = torch.tensor(class_weights, dtype=torch.float).to(device) loss_fct_ce = nn.CrossEntropyLoss(weight=class_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_size=6): """按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_size=6): """从预测区间反推中心值,用于误差计算""" return int((bin_idx + 0.5) * bin_size)

penalty_to_bin把原始月数除以6再取整,比如8个月对应bin 1,7个月对应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), dtype=int) y_pred_bin = np.zeros((len(y_pred), num_classes), dtype=int) 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, average="micro") macro = f1_score(y_true_bin, y_pred_bin, average="macro") 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.binconfig.jsonvocab.txttokenizer_config.json等文件,是模型复现和迁移部署的最小完整集合,缺任何一个都会在加载时报错或静默降低精度。之后无论要微调还是直接推理,都无需再依赖原始竞赛数据集路径。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 21:56:24

MIPI与SerDes技术解析:智能视觉系统数据传输核心

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 21:55:33

4G模组挂载为网卡:Linux下Socket网络通讯实战指南

1. 先把方案定下来&#xff1a;4G模组上网的几种路子作为常年和嵌入式Linux打交道的工程师&#xff0c;我在很多项目里都碰到过同一个需求&#xff1a;板子放在野外机房、车上或者偏远站点&#xff0c;没有网线&#xff0c;没有WiFi&#xff0c;唯一能联网的办法就是插一张SIM卡…

作者头像 李华
网站建设 2026/9/12 21:55:20

CMSIS-6:嵌入式开发的源码静态工程范式重构

1. CMSIS-6不是升级补丁&#xff0c;而是嵌入式开发范式的结构性重置CMSIS-6这个编号本身就有误导性。很多人第一反应是“CMSIS-5的下一个版本”&#xff0c;就像Linux内核从5.x升到6.x那样平滑过渡。但实际完全不是——CMSIS-6是一次彻底推倒重来的架构重构&#xff0c;它不再…

作者头像 李华
网站建设 2026/9/12 21:55:18

STM32驱动MLX90614红外测温:SMBus时序、PEC校验与发射率修正实战

简介&#xff1a;一款面向毕业设计与课程实训的STM32红外测温项目源码&#xff0c;聚焦MLX90614非接触测温模块的驱动开发与软硬件联调&#xff0c;适合电子、通信、自动化等专业学生及嵌入式入门开发者使用。代码按HARDWARE、SYSTEM、CORE、USER等目录分层&#xff0c;包含完整…

作者头像 李华
网站建设 2026/9/12 21:55:04

Python+微信小程序构建家电维修系统实战

1. 项目概述&#xff1a;Python微信小程序构建家电维修系统这个家电维修售后系统本质上是一个连接用户、维修师傅和商家的三方平台。用户通过微信小程序提交报修订单&#xff0c;维修师傅接单处理&#xff0c;商家管理库存和配件&#xff0c;而Python后端负责协调整个业务流程。…

作者头像 李华
网站建设 2026/9/12 21:50:56

智慧养老平台Java实战:规则引擎+Redis报警去重全解析

简介&#xff1a;基于SpringBoot的智慧养老平台Java源码&#xff0c;面向计算机、电子信息等专业学生&#xff0c;适合毕业设计、课程设计及期末大作业。项目采用B/S架构与MVC模式&#xff0c;整合SpringBoot、MyBatis、MySQL、Vue等技术栈&#xff0c;涵盖前台展示与后台管理功…

作者头像 李华