BERT+BiLSTM+CRF实战:交通肇事案判决书命名实体识别
简介这份资源面向计算机、数学、电子信息等专业的学生与NLP入门开发者提供一套基于BERTBiLSTMCRF的中文法律文书命名实体识别完整项目重点针对交通肇事案件进行事件要素抽取可作为课程设计、期末大作业或毕业设计的参考方案。压缩包共48个文件约693KB以21个Python源码文件为核心涵盖模型定义、数据加载、训练与预测脚本并配有配置文件、日志、词表与评估脚本等辅助材料目录结构清晰便于按模块阅读与调试。项目已积累383人学习说明其在法律文本挖掘方向具有一定参考价值。读者可借此理解预训练语言模型与序列标注模型的组合思路掌握从数据预处理、模型搭建到训练评估的完整流程并在此基础上迁移到其他法律事件要素抽取任务适合具备一定Python基础、愿意钻研代码的学习者。1. 从一份交通肇事案判决书说起BERTBiLSTMCRF 到底在抽什么一份交通肇事案的刑事判决书动辄两三千字里面真正决定量刑走向的信息其实就那么几项谁开的车、什么时间、什么路段、撞了几个人、伤情等级、有没有逃逸、有没有自首、赔了多少钱、取得谅解没有。人工从几百份判决书里把这些要素一条条抠出来做成结构化表格是很多法律科技团队、司法数据分析项目、保险理赔建模团队真实干过的苦活。这个标题讲的就是用 BERTBiLSTMCRF 这套经典的命名实体识别组合把交通肇事案判决书里的事件要素自动抽出来输入是一段判决书文本输出是带标签的实体序列。它适合两类人一类是刚学完 BERT 微调、想找一个完整中文 NER 项目练手的 Python 开发者另一类是做法律文本挖掘、需要一套能跑通、能改标签体系、能自己换数据重训的工程方案的人。整套东西不复杂但坑集中在数据标注格式、标签对齐和 CRF 转移约束这三处后面会逐个拆开讲。2. BERTBiLSTMCRF 这套组合为什么在法律文本上还站得住2.1 三个组件各自解决什么问题先把职责分清楚不然后面调参就是瞎调。BERT 负责的是「把字变成带上下文的向量」。法律文书里同一个词在不同语境下含义差别很大比如「逃逸」在事实描述里是行为在辩护意见里可能是被否认的指控BERT 的双向注意力能把这种语境差异编码进向量里这是 Word2Vec 那类静态词向量做不到的。BiLSTM 负责的是「在 BERT 输出的序列上再抓一层顺序依赖」。有人会问 BERT 不是已经双向了吗为什么还要加 LSTM实操里的理由是BERT 的每一层输出虽然带上下文但对「实体边界」这种强序列约束的建模不如循环网络直接BiLSTM 在字符级序列上再走一遍前后向能把「肇」「事」「车」「辆」这种连续片段的边界信息强化出来尤其是实体跨字较多的时候。CRF 负责的是「保证输出的标签序列合法」。这是整套结构里最容易被新手忽略、但收益最直接的一层。没有 CRF模型可能输出 B-PER 后面直接跟 B-LOC或者 I-PER 前面没有 B-PER这种序列在 BIO 标注体系下是非法的。CRF 通过一个转移矩阵学习「什么标签后面能接什么标签」把非法路径的概率压下去。2.2 为什么法律 NER 不直接用 BERTSoftmax很多教程到 BERT 接一个全连接分类头就结束了在通用领域效果也还行。但法律文书有两个特点让 Softmax 版本吃亏。第一是实体密度高且长交通肇事案里「2023年5月12日21时许」这种时间实体、『XX市XX区XX路与XX路交叉口』这种地点实体长度经常超过 6 个字Softmax 逐字独立分类容易在中间断掉。第二是标签之间的强约束比如「赔偿」相关实体后面往往跟金额实体这种相邻标签的搭配规律 CRF 能学到Softmax 学不到。我一般会建议如果你的实体平均长度小于 3 个字、标签种类少于 5 类BERTSoftmax 够用一旦超过这个规模加上 BiLSTMCRF 的收益就很明显了。2.3 标签体系怎么定交通肇事案的要素拆解这是动手前必须敲定的事标签定错了后面全白干。交通肇事案的事件要素我一般会拆成下面这几类你可以按自己的业务增减标签含义示例PER涉案人员被告人张某、被害人李某TIME时间2023年5月12日21时许LOC地点路段XX路与XX路交叉口VEH车辆小型轿车、重型半挂牵引车BEH肇事行为闯红灯、超速行驶、未按规定让行RES后果一人死亡、两人轻伤AMT金额人民币58万元LAW法条刑法第一百三十三条标注体系用 BIO即每个实体首字标 B-XXX后续字标 I-XXX非实体标 O。这套体系的好处是能处理任意长度实体坏处是边界容易出错后面避坑章节会专门讲。2.4 数据从哪来、怎么标判决书来源通常是裁判文书公开数据但要注意脱敏和合规人名地名做替换。标注工具用 Label Studio 或 brat 都行导出成 BIO 序列。一个常见的做法是先用规则抽一版粗标再人工修正比纯手工快很多。数据量上交通肇事案这种单一案由标注 800 到 1500 份判决书实体总数过万就足够训出一个能用的模型了。少于 500 份CRF 层基本学不出稳定的转移矩阵。3. 从零把 BERTBiLSTMCRF 跑起来环境、数据、模型三段式3.1 环境准备与依赖安装Python 环境建议 3.8 到 3.10太新的版本有些包会编译报错。用 conda 建一个独立环境避免和系统 Python 打架conda create -n legal_ner python3.9 conda activate legal_ner pip install torch1.13.1 transformers4.30.2 pip install pytorch-crf seqeval pandas scikit-learn这里几个包的作用要说清楚。transformers提供 BERT 的预训练权重和分词器中文场景用bert-base-chinese就够了不需要自己从头训。pytorch-crf是 CRF 层的现成实现比自己手写稳定它内部维护转移矩阵和发射矩阵的分数计算。seqeval是专门算 NER 指标的库它按实体级别算 P/R/F1而不是按 token 级别这个区别很大——token 级别准确率 99% 的模型实体级别 F1 可能只有 70%因为一个实体里错一个字整个实体就算错。pandas用来读标注数据scikit-learn用来切分训练验证集。提示如果你在 Windows 上装pytorch-crf报编译错误先升级 pip 和 setuptools或者直接用pip install pytorch-crf --no-build-isolation。3.2 数据读取与 BIO 标签对齐这一步是整个项目最容易翻车的地方。标注数据通常存成「字 标签」两列或者一行一句话加标签序列。下面是一个标准的读取和转 ID 的写法import pandas as pd from transformers import BertTokenizer # 标签列表O 必须在索引 0这是 pytorch-crf 的约定 label_list [O, B-PER, I-PER, B-TIME, I-TIME, B-LOC, I-LOC, B-VEH, I-VEH, B-BEH, I-BEH, B-RES, I-RES, B-AMT, I-AMT, B-LAW, I-LAW] label2id {l: i for i, l in enumerate(label_list)} id2label {i: l for l, i in label2id.items()} tokenizer BertTokenizer.from_pretrained(bert-base-chinese) def load_data(path): df pd.read_csv(path, sep\t, headerNone, names[char, label]) # 按空行切分句子 sentences, labels [], [] cur_chars, cur_labels [], [] for _, row in df.iterrows(): if pd.isna(row[char]) or str(row[char]).strip() : if cur_chars: sentences.append(cur_chars) labels.append(cur_labels) cur_chars, cur_labels [], [] continue cur_chars.append(str(row[char])) cur_labels.append(row[label]) if cur_chars: sentences.append(cur_chars) labels.append(cur_labels) return sentences, labels def encode(sentences, labels, max_len256): input_ids, attention_masks, label_ids [], [], [] for chars, labs in zip(sentences, labels): # 中文 BERT 按字切直接 join 再 encode text .join(chars) enc tokenizer(text, max_lengthmax_len, truncationTrue, paddingmax_length, return_tensorspt) ids enc[input_ids].squeeze(0).tolist() mask enc[attention_mask].squeeze(0).tolist() # 标签对齐BERT 会在首尾加 [CLS] [SEP]标签补 -100 让 loss 忽略 lab [-100] [label2id[l] for l in labs][:max_len-2] [-100] lab lab [-100] * (max_len - len(lab)) input_ids.append(ids) attention_masks.append(mask) label_ids.append(lab) return input_ids, attention_masks, label_ids逻辑说明load_data按空行切句这是 CoNLL 格式的通用约定。encode里最关键的是标签对齐——BERT 分词器会在序列首尾插入[CLS]和[SEP]这两个位置没有对应的实体标签必须补-100因为 PyTorch 的CrossEntropyLoss默认忽略-100。如果你忘了这一步模型会去学[CLS]位置的标签loss 曲线会一直下不去。参数max_len256是权衡判决书句子一般不超过 200 字设太大显存吃不消设太小长句被截断会丢实体。3.3 模型定义BERT 输出怎么接 BiLSTM 再接 CRF模型结构写成一个nn.Module三段拼接import torch import torch.nn as nn from transformers import BertModel from torchcrf import CRF class BertBiLstmCrf(nn.Module): def __init__(self, num_labels, lstm_hidden256, lstm_layers1, dropout0.3): super().__init__() self.bert BertModel.from_pretrained(bert-base-chinese) hidden self.bert.config.hidden_size # 768 self.bilstm nn.LSTM( input_sizehidden, hidden_sizelstm_hidden, num_layerslstm_layers, bidirectionalTrue, batch_firstTrue, dropoutdropout if lstm_layers 1 else 0 ) self.dropout nn.Dropout(dropout) # BiLSTM 双向输出拼接后维度是 lstm_hidden * 2 self.classifier nn.Linear(lstm_hidden * 2, num_labels) self.crf CRF(num_labels, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) seq_out outputs.last_hidden_state # [B, L, 768] lstm_out, _ self.bilstm(seq_out) # [B, L, 512] lstm_out self.dropout(lstm_out) emissions self.classifier(lstm_out) # [B, L, num_labels] # CRF 需要 mask 是 byte 类型 mask attention_mask.bool() if labels is not None: # 训练时算负对数似然注意 labels 里的 -100 要替换成 0 再传 crf_labels labels.clone() crf_labels[crf_labels -100] 0 loss -self.crf(emissions, crf_labels, maskmask, reductionmean) return loss else: # 推理时用维特比解码 return self.crf.decode(emissions, maskmask)逻辑说明BERT 输出维度是 768BiLSTM 设hidden_size256双向拼接后是 512分类头把 512 映射到标签数。CRF 层接收发射分数和 mask训练时返回负对数似然推理时用维特比算法解码出全局最优标签路径。参数dropout0.3是法律文本小数据集上的经验值数据量过万可以降到 0.1。lstm_layers1通常够用加到 2 层在小数据上容易过拟合。注意pytorch-crf的decode返回的是 list of list不是 tensor后面转标签的时候要按 batch 逐个处理别直接当 tensor 索引。3.4 训练循环与关键超参训练脚本的核心是优化器分组BERT 用小的学习率新增层用大的from torch.utils.data import DataLoader, TensorDataset import torch.optim as optim def train(model, train_loader, dev_loader, epochs10, lr_bert2e-5, lr_head1e-3): # 参数分组BERT 层和新增层用不同学习率 bert_params list(model.bert.named_parameters()) head_params list(model.bilstm.named_parameters()) \ list(model.classifier.named_parameters()) \ list(model.crf.named_parameters()) no_decay [bias, LayerNorm.weight] optimizer_grouped [ {params: [p for n, p in bert_params if not any(nd in n for nd in no_decay)], lr: lr_bert, weight_decay: 0.01}, {params: [p for n, p in bert_params if any(nd in n for nd in no_decay)], lr: lr_bert, weight_decay: 0.0}, {params: [p for _, p in head_params], lr: lr_head, weight_decay: 0.01}, ] optimizer optim.AdamW(optimizer_grouped) model.train() for epoch in range(epochs): total_loss 0 for batch in train_loader: input_ids, mask, labels [b.cuda() for b in batch] optimizer.zero_grad() loss model(input_ids, mask, labels) loss.backward() # 梯度裁剪防止 LSTM 梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() print(fepoch {epoch}, loss {total_loss / len(train_loader):.4f})逻辑说明BERT 微调学习率一般 2e-5 到 5e-5新增的 BiLSTM 和分类头是随机初始化的需要更大的学习率 1e-3 才能快速收敛这就是参数分组的意义。weight_decay对 bias 和 LayerNorm 不加这是 BERT 微调的常规操作。梯度裁剪max_norm1.0对 LSTM 很重要不加的话偶尔会出现 loss 突然变 NaN。训练轮数 10 轮是个起点法律数据一般 5 到 8 轮就收敛看验证集 F1 不再涨就可以停。4. 评估、调参与踩坑法律 NER 项目里最容易翻车的几处4.1 用 seqeval 算实体级指标训练完必须用实体级指标评估token 级准确率会骗人from seqeval.metrics import classification_report, f1_score def evaluate(model, dev_loader, id2label): model.eval() preds, trues [], [] with torch.no_grad(): for batch in dev_loader: input_ids, mask, labels [b.cuda() for b in batch] pred_ids model(input_ids, mask) for i, pred in enumerate(pred_ids): true_seq [] pred_seq [] for j, p in enumerate(pred): # 跳过 padding 和特殊符号位置 if labels[i][j].item() -100: continue true_seq.append(id2label[labels[i][j].item()]) pred_seq.append(id2label[p]) trues.append(true_seq) preds.append(pred_seq) print(classification_report(trues, preds)) return f1_score(trues, preds)逻辑说明seqeval接收的是标签字符串序列的列表不是 ID。评估时要跳过-100的位置否则会把 padding 当成 O 标签算进去指标虚高。classification_report会按每个实体类型给出 P/R/F1重点看 PER、TIME、LOC 这几类的 F1如果某一类特别低通常是该类样本太少或者边界标注不一致。4.2 避坑五个真实踩过的坑坑一标签里 O 不在索引 0CRF 训练直接报错或效果崩。现象是 loss 能降但解码出来全是 O或者pytorch-crf抛索引越界。原因是 CRF 内部默认把索引 0 当作「无转移」的起始状态如果你的 O 标签不在 0转移矩阵的语义就乱了。解决方法是构建label2id时强制把 O 放第一位其他标签顺序无所谓。坑二BERT 分词后标签没对齐模型学了个寂寞。现象是训练 loss 正常下降但验证集 F1 一直在 0.3 以下。原因是中文 BERT 虽然按字切但[CLS]和[SEP]会占两个位置如果标签序列没补-100模型会把这两个位置的标签也当监督信号导致有效标签整体错位。解决方法是 encode 时严格按「首补 -100、尾补 -100、中间截断」处理写完打印一条样本的 input_ids 和 label_ids 对照检查。坑三实体边界标注不一致模型学出模糊边界。现象是「2023年5月12日」有时标成 TIME有时只标「5月12日」模型预测出来的边界飘忽。原因是多人标注时对「年」要不要算进时间实体没有统一。解决方法是在标注规范里写死规则比如时间实体必须包含年份然后对已有数据做一致性检查用脚本找出同一实体在不同句子里标注长度差异过大的样本人工复核。坑四学习率设太大BERT 预训练权重被冲垮。现象是前两轮 loss 下降很快第三轮突然反弹验证集 F1 断崖下跌。原因是 BERT 层用了 1e-3 这种大学习率把预训练学到的语言知识覆盖掉了。解决方法是 BERT 层学习率控制在 2e-5 到 5e-5新增层才用 1e-3用参数分组隔开。坑五长判决书截断丢实体。现象是短句预测正常长段落里的金额、法条实体经常漏。原因是max_len256截断判决书里事实认定部分经常超过这个长度。解决方法是按句号、分号做句子切分把长文档拆成短句分别预测再合并或者用滑动窗口加重叠重叠部分取置信度高的结果。4.3 推理部署从模型到可调用的抽取接口训练完的模型要能对外提供服务写一个简单的预测函数def predict(text, model, tokenizer, id2label, max_len256): model.eval() enc tokenizer(text, max_lengthmax_len, truncationTrue, paddingmax_length, return_tensorspt) input_ids enc[input_ids].cuda() mask enc[attention_mask].cuda() with torch.no_grad(): pred_ids model(input_ids, mask)[0] # 去掉 [CLS] [SEP] 和 padding 对应的位置 tokens tokenizer.convert_ids_to_tokens(enc[input_ids][0]) entities [] cur_ent None for i, p in enumerate(pred_ids): if i 0 or i len(text): continue label id2label[p] if label.startswith(B-): if cur_ent: entities.append(cur_ent) cur_ent {type: label[2:], text: text[i-1], start: i-1} elif label.startswith(I-) and cur_ent and label[2:] cur_ent[type]: cur_ent[text] text[i-1] else: if cur_ent: entities.append(cur_ent) cur_ent None if cur_ent: entities.append(cur_ent) return entities逻辑说明这里用i-1映射回原文字符位置因为 BERT 序列第 0 位是[CLS]。B- 开头的标签启动一个新实体I- 且类型匹配的追加到当前实体其他情况结束当前实体。返回的实体列表可以直接转成 JSON 存库或喂给下游规则引擎。注意这个函数假设输入是单句长文本要先切句再逐句调用。5. 把抽取结果用起来从实体到事件要素表的最后一公里模型输出的是实体列表但业务要的是「一案一行」的结构化表。这一步需要写一个后处理规则层把散落的实体按角色归位。交通肇事案的核心要素映射关系大致是PER 里第一个通常是被告人TIME 里最早的那个是案发时间LOC 是案发地点VEH 是肇事车辆RES 是后果AMT 是赔偿金额LAW 是定罪法条。但实际判决书里 PER 可能有好几个需要靠位置和上下文区分被告人和被害人常见做法是看实体前面有没有「被告人」「被害人」这类触发词。我一般会写一个规则函数做这件事def build_event(entities, text): event {被告人: None, 被害人: [], 时间: None, 地点: None, 车辆: None, 后果: None, 赔偿金额: None, 法条: None} for ent in entities: # 用实体前 5 个字判断角色 prefix text[max(0, ent[start]-5):ent[start]] if ent[type] PER: if 被告人 in prefix or 肇事者 in prefix: event[被告人] ent[text] elif 被害人 in prefix or 死者 in prefix: event[被害人].append(ent[text]) elif ent[type] TIME and not event[时间]: event[时间] ent[text] elif ent[type] LOC and not event[地点]: event[地点] ent[text] elif ent[type] VEH and not event[车辆]: event[车辆] ent[text] elif ent[type] RES and not event[后果]: event[后果] ent[text] elif ent[type] AMT and not event[赔偿金额]: event[赔偿金额] ent[text] elif ent[type] LAW and not event[法条]: event[法条] ent[text] return event逻辑说明这个函数把 NER 结果按业务字段归位prefix取实体前 5 个字做角色判断是因为判决书里「被告人张某」这种表述很固定。时间、地点、车辆取第一个出现的因为判决书事实部分通常按时间顺序叙述第一个就是案发时的。这个规则层不是万能的遇到复杂案件需要结合句法分析但对交通肇事案这种结构相对固定的案由规则加 NER 的组合能覆盖八成以上。验证这套流程是否可靠我的习惯是留 50 份判决书做端到端测试人工填好标准事件表然后跑模型加规则逐字段比对。重点看「被告人」和「赔偿金额」这两个字段前者错通常是 PER 实体识别漏了后者错往往是 AMT 的边界没对齐。如果这两个字段准确率能到 90% 以上整套方案就值得往生产环境推了。做法律 NER 这几年最大的教训是模型指标好看不代表业务能用真正决定成败的是标注规范的一致性和后处理规则跟业务字段的贴合度这两件事花的时间应该比调模型多。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →