ARTICLE DETAIL

资讯详情

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

法律文书NER实战:BERT+BiLSTM+CRF抽取交通肇事案事件要素

法律文书NER实战:BERT+BiLSTM+CRF抽取交通肇事案事件要素 简介一份面向法律文本挖掘与自然语言处理学习者的完整项目包基于BERTBiLSTMCRF实现法律文书命名实体识别并针对交通肇事案进行事件要素抽取适合用作课程设计、期末大作业或毕设参考资料。压缩包共48个文件、约693KB主体为21个Python脚本覆盖数据读取、模型构建、训练验证和预测评估等环节另含配置文件、日志、标注数据、映射文件与README说明项目结构清晰便于对照学习与二次开发。已有383人浏览学习适合具备一定Python和深度学习基础、希望深入理解序列标注任务的中级学习者。通过该项目可掌握预训练模型微调、BiLSTM特征提取、CRF序列解码的经典组合方案实际体验法律文书命名实体识别与事件要素抽取的完整流程对开展法律自然语言处理相关课题具有直接参考价值。1. 法律文书NER为什么选bertBiLSTMCRF交通肇事案事件要素抽取的开局接到“法律文书命名实体识别”这类需求时最常被问到的不是模型多新而是“能不能把判决书里的事件要素直接抽出来”。所谓事件要素就是一起交通肇事案里事故发生的时间、地点、肇事人、肇事车辆、被害人、刑事责任认定这些关键字段。人工从一份判决书里摘出这七八个字段熟练的助理也要十分钟换成模型跑一次只需要几百毫秒。这个zip项目标题里锁定的技术路线恰好是这类任务里性价比最高的一套组合bert负责读句子、BiLSTM负责接住上下文、CRF负责保证标签不乱跳。这篇笔记就照着这个路线把原理、数据标注、训练代码、参数设置和踩坑点完整过一遍适合那些正在做法律文本挖掘、类案检索或信息抽取的工程师也适合准备拿这个题目做落地项目的同学。2. bertBiLSTMCRF的拆解三个组件各扛哪段活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输出维度768BiLSTM的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值能稳定涨13个点这就是这套组合在命名实体识别领域一直没被更花哨的模型替代的原因。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_mappingTrue) 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份完整判决书平均每份能切出400600个句子总体样本在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_hidden256, dropout0.1): super().__init__() self.bert BertModel.from_pretrained(bert_name) self.dropout nn.Dropout(dropout) self.bilstm nn.LSTM( input_sizeself.bert.config.hidden_size, hidden_sizelstm_hidden, num_layers2, batch_firstTrue, bidirectionalTrue ) self.fc nn.Linear(lstm_hidden * 2, num_tags) self.crf CRF(num_tags, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): bert_out self.bert(input_idsinput_ids, attention_maskattention_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, maskattention_mask.bool()) return self.crf.decode(emissions, maskattention_mask.bool())这里有个细节CRF的mask直接复用bert的attention_mask专门堵住padding位置。BiLSTM的hidden_size设256num_layers设2超过2层容易在小语料上让训练时间翻倍收益却不明显。emissions的维度和标签数对齐标签0还是O由id映射表决定。CRF.decode返回的就是整条标签序列推理时不需要再做什么argmax。4.2 训练主流程从数据集切分到loss打印训练脚本核心是数据和优化器的配合。bert要微调的参数量大学习率必须小常见设法是bert用5e-5BiLSTM和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_len512): 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, truncationTrue, paddingmax_length, max_lengthself.max_len, return_tensorspt ) return { input_ids: encoding[input_ids][0], attention_mask: encoding[attention_mask][0], labels: torch.tensor(label[:self.max_len], dtypetorch.long) } model BertBiLSTMCRF(bert_namebert-base-chinese, num_tagslen(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_decay0.01)代码里数据集直接做了max_len512截断这对中文法律文本是个明显风险点后面会单独说。训练轮次在中小语料上设812轮每轮结束用验证集算一下准确率或F1保存效果最好的那版参数。不要在最后一轮才保存模型法律文本数据集小到第6轮可能就是最优继续训练只会过拟合。4.3 推理函数与标签还原预测的时候要把模型切到eval模式关闭dropout再走一遍decode。标签还原这一点是关键坑模型的输出是idid必须映射回BIOES类型才能继续做事件要素模板。一段判决书往往超过512个token我常用的处理是滑窗式分段预测按512切重叠64个字同一个实体跨段时以重复部分投票决定避免实体的后半段被截掉。def predict_entities(model, tokenizer, text, id2label, max_len512): model.eval() tokens tokenizer(text, truncationTrue, max_lengthmax_len, return_tensorspt) 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_fastFalse避免fast tokenizer断词方式差异。最稳妥的是训练和推理都只从同一个目录加载即先从Hugging Face下载模型存到本地之后路径写本地目录。5.2 现象关键实体刚好被512截断砍掉判决书前一半是公诉机关指控、证人证言真正的“肇事时间”“肇事地点”偶尔出现在第一页末尾。用max_len512直接截断后模型连肇事人的影子都没看到自然什么都抽不出来。解法先把判决书按“。”和段落切句再做滑窗切分。每个窗口512个token窗口之间重叠64个token。预测时把重叠区的实体合并同一标签在重叠区里才保留避免一个实体变成两段。这套办法比直接截断在长文上能救回约15%的召回率。5.3 现象被害人、肇事车辆两类实体永远抽不全标注数据里“被告人”即肇事人出现频率远高于“被害人”和“肇事车辆”模型学到最后把“被告人”学得特别准其他类型F1普遍不到70%。这是标注语料不平衡面的典型表现。解法不能只靠随机过采样。把含被害人、车辆、违法行为的句子专门抽出来复制23份再配合前面讲到的实体替换增强。也可以用损失函数加权给低频实体类型的标签在cross entropy里把权重上调1.52倍。这个调整要在训练前就做模型训完再发现不平衡就晚了。5.4 现象CRF训练loss不降有时直接变NaNCRF层在训练初期对标签序列要求严格bert还没学明白就开始输出密集的实体标签容易出现整个batch的转移概率全部归零。另一个原因是attention_mask.bool()里如果混进了值为True的特殊tokenCRF的维特比会在这些位置死循环loss直接变成NaN。解法检查attention_mask的长度和input_ids一致CLS和SEP位置的mask置为Falsepadding位置也全为False。lrate设置上把bert的初始learning_rate降到3e-5CRF步长调到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模型转成ONNXBiLSTM和CRF留在PyTorch里请求层只做tokenize和decode。我踩过的教训是别为了方便全模型都转ONNXCRF的维特比解码如果算子不支持推理性能会反过来变慢。这套方案值不值得长期投入我的判断很直接只要你的业务还是从中文判决书、裁定书里抽结构化字段bertBiLSTMCRF在很长一段时间里仍然是性价比最高的底座。换更复杂的生成式模型标注成本和部署成本都会高一个量级换来的是几个点的长尾实体增益但对法律文书这种强规则文本并不划算。想快速看到效果先拿10份判决书跑通全流程再决定要不要把标注规模扩大过程里最花时间的地方是数据对齐和滑窗参数这两块打磨顺了后面就是体力活。希望这套思路能帮你少走几趟弯路。本文还有配套的精品资源点击获取
返回列表