简介:一份面向法律文本挖掘与自然语言处理学习者的完整项目包,基于BERT+BiLSTM+CRF实现法律文书命名实体识别,并针对交通肇事案进行事件要素抽取,适合用作课程设计、期末大作业或毕设参考资料。压缩包共48个文件、约693KB,主体为21个Python脚本,覆盖数据读取、模型构建、训练验证和预测评估等环节;另含配置文件、日志、标注数据、映射文件与README说明,项目结构清晰,便于对照学习与二次开发。已有383人浏览学习,适合具备一定Python和深度学习基础、希望深入理解序列标注任务的中级学习者。通过该项目可掌握预训练模型微调、BiLSTM特征提取、CRF序列解码的经典组合方案,实际体验法律文书命名实体识别与事件要素抽取的完整流程,对开展法律自然语言处理相关课题具有直接参考价值。
1. 法律文书NER为什么选bert+BiLSTM+CRF:交通肇事案事件要素抽取的开局
接到“法律文书命名实体识别”这类需求时,最常被问到的不是模型多新,而是“能不能把判决书里的事件要素直接抽出来”。所谓事件要素,就是一起交通肇事案里,事故发生的时间、地点、肇事人、肇事车辆、被害人、刑事责任认定这些关键字段。人工从一份判决书里摘出这七八个字段,熟练的助理也要十分钟,换成模型跑一次只需要几百毫秒。这个zip项目标题里锁定的技术路线,恰好是这类任务里性价比最高的一套组合:bert负责读句子、BiLSTM负责接住上下文、CRF负责保证标签不乱跳。这篇笔记就照着这个路线,把原理、数据标注、训练代码、参数设置和踩坑点完整过一遍,适合那些正在做法律文本挖掘、类案检索或信息抽取的工程师,也适合准备拿这个题目做落地项目的同学。
2. bert+BiLSTM+CRF的拆解:三个组件各扛哪段活
2.1 bert编码:先把法律句子的每个字变成向量
命名实体识别在bert出现之前,基本靠word2vec加人工特征,效果在新闻语料里能看,一换到法律文书就明显变差。原因不复杂:判决书的句子结构极长、书面语极重,光靠局部上下文猜不出“肇事”后面跟的到底是动词还是名词。bert从预训练阶段就是双向语言模型,它看“被告人王某醉酒驾驶小型轿车”这句话时,“醉洒”这个字的向量里已经糅进了后面“驾驶小型轿车”的信息。这套方案选择bert-base-chinese作为底座,输出的是每个token的768维向量,给后面两层用。
有人在跑bert模型实操的时候纠结过:是拿bert直接接softmax,还是再接BiLSTM和CRF。直接接softmax叫“BERT-NER”,做普通短句可以,但法律文书动辄几千字、一个实体跨好几个分句,bert输出的每个token向量是孤立的,没有一个机制告诉模型“B-肇事人后面必须接I-肇事人,不能跳到B-肇事车辆”。这就是后面两层存在的理由。
预训练模型这一块,这套方案用Hugging Face的transformers框架加载“bert-base-chinese”,它会自动带上对应的tokenizer。需要注意一点:bert对中文是字粒度,不是词粒度,所以“交通肇事罪”会被切成五个字,每个字一个token。这个特性反而适合法律文书里的长术语,因为“重大责任事故罪”“危险驾驶罪”这类罪名没法用标准词典全部收全,字粒度天然不用维护词表。
2.2 BiLSTM把前后文粘到一起
bert已经给了很强的字向量,为什么还要再加一层BiLSTM?直接原因是为了让序列分类时的“位置感知”更顺。bert的输出虽然每个token都看过全文,但送到分类层时,模型还是按token独立打分。BiLSTM的forward和backward两个方向,会再走一遍序列,把“前文+当前字+后文”压成新的特征向量。
比如“王某驾驶车辆沿XX路由东向西行驶至路口处”,这里的“路口处”是肇事地点的一部分。如果只看当前字“处”,模型容易丢掉“沿XX路”这条线索,但双向LSTM会把整句的信息逐步传到位,输出的向量里就带着“这是一个地点短语的尾部”的信号。实践中,bert输出维度768,BiLSTM的hidden_size设256通常就够,设太大反而容易在小数据集上过拟合。好在这套方案里BiLSTM只负责给每个位置再编一次码,不改变序列长度。
BiLSTM这类网络本身跟领域无关,标题里的算法在其他任务里也能见到,比如bilstm代码在金融时序、遥感分类也常出现,但在NER这里它起的作用很特殊:把bert那套“懂词义但不管标签先后”的表示,改造成“适合去预测标签序列”的表示。
2.3 CRF:最后一关是约束标签顺序
CRF层是整个模型里最提气的一笔。没有CRF时,softmax给每个字独立打分,很可能抽出的标签序列长这样:“B-肇事人 O I-肇事人”。从单字正确率看还不错,但拼成实体就成了笑话。CRF做的事,是给所有标签之间的转移加一个概率矩阵,训练时学出来“I-肇事人只能接在B-肇事人后面”“B-肇事车辆前面不该出现I-肇事人”这些规则。
这套方案的最后一层用的就是PyTorch里的线性链条件随机场。转移矩阵是模型参数,训练时和bert、BiLSTM一起更新。推理时用维特比算法找一条全局最优的标签路径,也就是说,模型输出不再是一个字一个字拼出来的,而是一整条标签序列。
到这要解释一个关键选择:有了bert还不够,为什么还要CRF?因为法律文书里实体密度低、句子特别长,模型在单字分类上有98%的准确率,到句子层面就会暴露问题。CRF把“全句标签合规”变成硬约束,让序列层面的F1值能稳定涨1~3个点,这就是这套组合在命名实体识别领域一直没被更花哨的模型替代的原因。
2.4 从实体到事件要素:这套模型在交通肇事案中的实际出口
直接跑一个交通肇事案判决书,模型要先抽出“王某”“小型轿车”“沿XX路”“2021年5月3日”四类实体,然后再由后处理脚本把这几个实体按“肇事人开肇事车在肇事时间于肇事地点造成事故”进行拼装,最终得出一条结构化事件要素。实体识别做的是“圈”,事件要素抽取做的是“填坑”。有人把这一步也交给神经网络去做,但常规做法是在NER结果上套一层事件模板规则,模板稳定,输出还带字段名,方便直接进业务库。
3. 训练数据怎么造:交通肇事案的事件要素标注方案
3.1 事件要素Schema设计:一份判决书里到底要抽什么
数据是这套方案里成本最大的一头。先定任务边界:交通肇事案判决书里,事件要素包括案发时间、案发地点、肇事人、肇事车辆、被害人、交通违法行为、责任认定、判决结果,一共8个字段。在项目说明里,这8个字段会映射成NER标注类型。落到BIOES标注上,每个字段对应一套标签:B-案发时间、I-案发时间、E-案发时间、S-案发时间,其他字段同理,再加上O表示非实体。
| 类型 | 字段示例 | 判决书原文写法 | 抽取难点 |
|---|---|---|---|
| 案发时间 | 2021年5月3日20时 | 2021年5月3日20时许 | 时间表达混用中文数字、阿拉伯数字 |
| 案发地点 | XX市XX区XX路路口 | 沿XX路由东向西行驶至XX路口处 | 地名长、中间夹方向词 |
| 肇事人 | 王某 | 被告人王某 | 要和被害人区分,同一个判决书里多人 |
| 肇事车辆 | 小型轿车 | 驾驶号牌为京A12345的小型轿车 | 号牌和车型要一起抽或只抽车型 |
| 被害人 | 李某 | 行人李某当场死亡 | 死亡/受伤描述不同 |
| 违法行为 | 醉酒驾驶 | 在道路上醉酒驾驶机动车 | 和罪名有重叠,容易抽长 |
设计时最容易犯的错是第一版就把类型建到15个以上,标注员根本分不清“案发地点”和“肇事路段”区别。我给这套方案定的是8个实体类型,对应事件要素刚好够用,样本少也好画。标注工具不用自研,用brat或Label Studio按BIOES格式导出就行,导出后的标注文件常叫train.txt、dev.txt,格式是一行一个字空格一个标签,空行表示句子间隔。
3.2 BIOES标注与序列对齐:把标签落到每个字上
要用bert做训练,必须把标注按字对齐。标注文件是纯文本,训练脚本负责转成bert能吃的input_ids和label_ids,中间最关键的一步是不能让tokenizer自己多断字。比如“王某”两个字,bert的tokenizer会把它切成两个token,标签B-肇事人给“王”,I-肇事人给“某”,这里没有问题。麻烦的是英文、数字和特殊符号,比如“京A12345”,“A”“12345”会被tokenizer切成好几个token,而BIOES标注是按“一个原始字一个标签”写的,这时候要做标签对齐:
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") def align_labels_with_tokens(text, labels): # text: "王某驾驶京A12345" # labels: ["B-肇事人", "I-肇事人", "O", "O", "B-肇事车辆", "I-肇事车辆", "I-肇事车辆", "I-肇事车辆"] encoded = tokenizer(text, return_offsets_mapping=True) aligned_labels = [] for idx, (start, end) in enumerate(encoded["offset_mapping"][1:-1]): if start == end: # 特殊token或者空串,统一给-100,训练时忽略 aligned_labels.append(-100) else: aligned_labels.append(labels[start]) return encoded["input_ids"], aligned_labels这段代码的核心是offset_mapping,它记录了每个token对应原文字符的起止位置。用labels[start]取原位置标签,就能把BERT分词后的token和新序列完美对齐。代码里给特殊token填了-100,这是PyTorch交叉熵忽略标签惯用的写法,不参与loss计算。实际跑bert模型实操时会发现,90%的词“不对齐”问题都出在这一步,宁可多花十分钟写对齐,也别用暴力循环匹配。
标注数据量这块,一套可用的法律文书NER模型,至少需要800份完整判决书,平均每份能切出400~600个句子,总体样本在10万行字上下。如果实在拿不到这么多数据,先保证除O以外的实体标签合计超过3万个,模型才有希望收敛。
3.3 数据增强与负采样:判决书里的非实体句子别丢
法律文书有个特点,判决书“本院认为”部分整段都不含实体,如果训练集里全是含实体的密集句,模型会在推理时把“本院认为该行为已构成交通肇事罪”里的“交通肇事罪”也抽成实体。实际标注时,我把约30%的句子故意保持全O,让模型学会“普通法律表述不等于实体”。
还有一种增强方式,是把实体值做成替换抽取。比如把“王某驾驶小型轿车”改成“张某驾驶大型客车”,只替换实体本身,句子结构和标签不动。这样能在标注费用不变的情况下把训练样本翻两倍。替换时注意同类型实体互换,别让“王某驾驶小型轿车”变成“车辆驾驶王某轿车”,顺序乱了模型就学坏。
4. 把模型跑起来:训练脚本、参数设置和推理代码
4.1 模型定义代码:bert底座接上BiLSTM和CRF
整套模型的结构按常规做法是放在model.py里,核心代码并不长。项目包里的python源码一般就是这个结构:先用transformers加载bert,再定义一个双向LSTM,最后接入一个CRF层。这里给一份可以直接照用的实现,用到的库是torch和torchcrf,如果不想引外部依赖,也可以手写几十行线性链CRF,但torchcrf在项目里更常见:
import torch from torch import nn from transformers import BertModel from torchcrf import CRF class BertBiLSTMCRF(nn.Module): def __init__(self, bert_name, num_tags, lstm_hidden=256, dropout=0.1): super().__init__() self.bert = BertModel.from_pretrained(bert_name) self.dropout = nn.Dropout(dropout) self.bilstm = nn.LSTM( input_size=self.bert.config.hidden_size, hidden_size=lstm_hidden, num_layers=2, batch_first=True, bidirectional=True ) self.fc = nn.Linear(lstm_hidden * 2, num_tags) self.crf = CRF(num_tags, batch_first=True) def forward(self, input_ids, attention_mask, labels=None): bert_out = self.bert(input_ids=input_ids, attention_mask=attention_mask)[0] bert_out = self.dropout(bert_out) lstm_out, _ = self.bilstm(bert_out, None) lstm_out = self.dropout(lstm_out) emissions = self.fc(lstm_out) if labels is not None: return -self.crf(emissions, labels, mask=attention_mask.bool()) return self.crf.decode(emissions, mask=attention_mask.bool())这里有个细节,CRF的mask直接复用bert的attention_mask,专门堵住padding位置。BiLSTM的hidden_size设256,num_layers设2,超过2层容易在小语料上让训练时间翻倍,收益却不明显。emissions的维度和标签数对齐,标签0还是O由id映射表决定。CRF.decode返回的就是整条标签序列,推理时不需要再做什么argmax。
4.2 训练主流程:从数据集切分到loss打印
训练脚本核心是数据和优化器的配合。bert要微调的参数量大,学习率必须小,常见设法是bert用5e-5,BiLSTM和CRF层用1e-3。优化器尽量用AdamW,配合warmup,前10%的step线性升温,能避免bert在最开始training时就大幅破坏预训练知识:
from torch.utils.data import DataLoader, Dataset from transformers import AdamW class LegalDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len=512): self.texts, self.labels, self.tokenizer, self.max_len = texts, labels, tokenizer, max_len def __getitem__(self, i): text, label = self.texts[i], self.labels[i] encoding = self.tokenizer( text, truncation=True, padding="max_length", max_length=self.max_len, return_tensors="pt" ) return { "input_ids": encoding["input_ids"][0], "attention_mask": encoding["attention_mask"][0], "labels": torch.tensor(label[:self.max_len], dtype=torch.long) } model = BertBiLSTMCRF(bert_name="bert-base-chinese", num_tags=len(id2label)) optimizer_grouped = [ {"params": model.bert.parameters(), "lr": 5e-5}, {"params": model.bilstm.parameters(), "lr": 1e-3}, {"params": model.crf.parameters(), "lr": 1e-3}, {"params": model.fc.parameters(), "lr": 1e-3}, ] optimizer = AdamW(optimizer_grouped, weight_decay=0.01)代码里数据集直接做了max_len=512截断,这对中文法律文本是个明显风险点,后面会单独说。训练轮次在中小语料上设8~12轮,每轮结束用验证集算一下准确率或F1,保存效果最好的那版参数。不要在最后一轮才保存模型,法律文本数据集小,到第6轮可能就是最优,继续训练只会过拟合。
4.3 推理函数与标签还原
预测的时候要把模型切到eval模式,关闭dropout,再走一遍decode。标签还原这一点是关键坑:模型的输出是id,id必须映射回BIOES类型,才能继续做事件要素模板。一段判决书往往超过512个token,我常用的处理是滑窗式分段预测:按512切,重叠64个字,同一个实体跨段时以重复部分投票决定,避免实体的后半段被截掉。
def predict_entities(model, tokenizer, text, id2label, max_len=512): model.eval() tokens = tokenizer(text, truncation=True, max_length=max_len, return_tensors="pt") with torch.no_grad(): tags = model(tokens["input_ids"], tokens["attention_mask"])[0] # tags是一串整数id,去掉开头CLS和结尾SEP对应的预测 start, end = 1, len(tags) - 1 return [id2label[t] for t in tags[start:end]]推理耗时在这里是个实际指标,纯CPU上跑一份2000字的判决书大约要1到2秒,如果做批量处理能接受;要是上线要求单篇300毫秒以下,需要把bert转成ONNX格式再挂GPU,能压到300毫秒内。项目包里如果带了转换脚本就用现成的,没有的话,用torch.onnx.export导出也不难。
5. 避坑:法律长文本NER的5个常见翻车场景
5.1 现象:BERT和tokenizer版本不匹配导致的实体错位
用transformers加载bert-base-chinese时,有人图省事直接只加载了模型权重,tokenizer用的是上一版缓存,结果预测出来的标签永远和原文对不上,后面事件要素全错位。原因是预训练模型和tokenizer的词表版本需要严格对应。
解法:检查BertTokenizer.from_pretrained是不是用的同一个model_name,加载时指定use_fast=False避免fast tokenizer断词方式差异。最稳妥的是训练和推理都只从同一个目录加载,即先从Hugging Face下载模型存到本地,之后路径写本地目录。
5.2 现象:关键实体刚好被512截断砍掉
判决书前一半是公诉机关指控、证人证言,真正的“肇事时间”“肇事地点”偶尔出现在第一页末尾。用max_len=512直接截断后,模型连肇事人的影子都没看到,自然什么都抽不出来。
解法:先把判决书按“。”和段落切句,再做滑窗切分。每个窗口512个token,窗口之间重叠64个token。预测时把重叠区的实体合并,同一标签在重叠区里才保留,避免一个实体变成两段。这套办法比直接截断在长文上能救回约15%的召回率。
5.3 现象:被害人、肇事车辆两类实体永远抽不全
标注数据里“被告人”(即肇事人)出现频率远高于“被害人”和“肇事车辆”,模型学到最后,把“被告人”学得特别准,其他类型F1普遍不到70%。这是标注语料不平衡面的典型表现。
解法:不能只靠随机过采样。把含被害人、车辆、违法行为的句子专门抽出来复制2~3份,再配合前面讲到的实体替换增强。也可以用损失函数加权,给低频实体类型的标签在cross entropy里把权重上调1.5~2倍。这个调整要在训练前就做,模型训完再发现不平衡就晚了。
5.4 现象:CRF训练loss不降,有时直接变NaN
CRF层在训练初期对标签序列要求严格,bert还没学明白就开始输出密集的实体标签,容易出现整个batch的转移概率全部归零。另一个原因是attention_mask.bool()里如果混进了值为True的特殊token,CRF的维特比会在这些位置死循环,loss直接变成NaN。
解法:检查attention_mask的长度和input_ids一致,CLS和SEP位置的mask置为False,padding位置也全为False。lrate设置上,把bert的初始learning_rate降到3e-5,CRF步长调到1e-3以下,一般二三十步就能看到loss开始下降。
5.5 现象:预测实体边界和人工标注差一个字
事故发生地点“XX区XX路路口”被抽成“XX路路口”,原因出在“XX区XX路”里两个地名类型嵌套。这种嵌套结构在交通肇事案里频繁出现,BIOES标签只认最外层,inner实体被O盖掉。
解法:标注规范上约定“以最长匹配为准”,也就是嵌套结构只标最外层。如果业务上确实需要“区”和“路”分开,得把实体类型拆成“行政区划”和“道路名称”两个独立类型。模型层面没有简单办法,只能靠数据规范来定义清晰边界。事件要素抽取阶段再根据“地点字段”兼容两者,用正则把“区”和“路”拆到不同字段。
6. 进阶:从“实体圈对了”到“事件要素抽得可用”的验证技巧
模型训完只是第一步,真正交付要验证的是事件要素能不能直接用。我的习惯是拿200份没进过训练集的判决书做整文预测,不只算实体级F1,还要算“8要素齐全率”。一份判决书8个事件要素里抽满6个以上算可入库,这个指标比实体F1更能反映业务效果。实测里,实体F1到0.88左右时,要素齐全率约在70%上下,剩下的主要丢在“案发时间”和“案发地点”这种跨长句实体上。
要再往上提,建议做两层校验。第一层用正则兜底,比如看到“犯交通肇事罪”就把罪名和对应的判决结果字段补齐,避免模型漏抽。第二层做自洽校验,比如“肇事人”字段不可能为空,一份有效判决书里必须有肇事人;如果模型没抽到,就重新对全文用更大的max_len再做一遍滑窗预测。这个兜底逻辑写进业务流里,上线后能挡掉一批肉眼可见的坏结果。
部署形态上,如果只是离线跑批,python脚本加参数文件就够了;如果要接在线接口,把bert模型转成ONNX,BiLSTM和CRF留在PyTorch里,请求层只做tokenize和decode。我踩过的教训是,别为了方便全模型都转ONNX,CRF的维特比解码如果算子不支持,推理性能会反过来变慢。
这套方案值不值得长期投入,我的判断很直接:只要你的业务还是从中文判决书、裁定书里抽结构化字段,bert+BiLSTM+CRF在很长一段时间里仍然是性价比最高的底座。换更复杂的生成式模型,标注成本和部署成本都会高一个量级,换来的是几个点的长尾实体增益,但对法律文书这种强规则文本并不划算。想快速看到效果,先拿10份判决书跑通全流程,再决定要不要把标注规模扩大,过程里最花时间的地方是数据对齐和滑窗参数,这两块打磨顺了,后面就是体力活。
希望这套思路能帮你少走几趟弯路。
本文还有配套的精品资源,点击获取