
简介这份资源面向自然语言处理方向的学生与开发者提供一套基于BERTBiLSTMCRF的法律文书命名实体识别完整源码重点针对交通肇事案的事件要素抽取场景。项目采用预训练语言模型提取语义特征结合双向LSTM与条件随机场完成序列标注可用于课程设计、期末大作业或法律文本信息抽取的入门实践。压缩包共48个文件约694KB以21个Python源码为核心辅以xml配置、训练日志、数据集文件、评估脚本及项目说明文档覆盖数据加载、模型定义、训练、预测与效果评估等环节目录结构清晰便于按模块阅读与二次开发。目前已有190人学习下载。读者可直接获得可运行的工程代码与项目说明理解BERT微调、BiLSTM编码与CRF解码的完整链路并借助内置的训练与测试数据快速复现交通肇事案要素抽取流程为后续迁移到其他法律文书场景提供参考。1. 法律文书要素抽取为什么难从一份交通肇事判决书说起一份交通肇事案的刑事判决书动辄两三千字里面塞满了时间、地点、车牌号、伤亡人数、责任认定、赔偿金额、刑期。人眼扫一遍能抓住2023年4月12日19时许××牌小型轿车致一人死亡负事故全部责任这些关键要素但要让程序自动抽出来麻烦立刻来了同一个王某在全文出现七八次有时是被告人王某有时是王某甲有时直接省略成王负事故全部责任和承担本次事故的主要责任是同一个意思的两种写法金额有人民币58万元也有58万余元。规则匹配在这种文本上翻车是常态正则写到第三十条就开始互相打架。这就是命名实体识别NER在法律垂直领域要解决的问题也是基于bertBiLSTMCRF的法律文书命名实体识别这个方案的核心场景。它做的事情很具体把判决书里属于时间、地点、人物、车辆、伤亡结果、责任认定、赔偿金额、罪名法条这些类别的片段逐字打上标签最终结构化成一条条可入库、可统计、可检索的字段。适合谁做司法信息化、法律科技产品、卷宗结构化、类案检索、裁判文书数据分析的工程师以及想拿一个真实中文 NER 项目练手 BERT 微调的人。整套东西是 Python 开发跑通的门槛不高但想让它在你自己的案由数据上真正好用参数和标注规范上有不少要抠的地方。2. BERTBiLSTMCRF 三段式到底各管什么选型理由与数据标注2.1 三个组件分工别把它们当成一个黑匣子很多人第一次看到这个组合会以为是把三个模型串起来投票其实它们是流水线上三道工序各管一段。BERT 负责看懂字。它把每个汉字映射成一个带上下文信息的向量。法律文书里责任这个词在负事故全部责任和不承担责任里含义完全相反BERT 的双向注意力能根据前后文给出不同的向量表示这是词表词向量的老方法做不到的。中文 BERT 一般按字切分一个字一个 token所以王某甲就是三个 token天然规避了分词错误传导的问题——法律文本里人名、地名、机构名用词古怪分词器经常切错按字反而稳。BiLSTM 负责看句子结构。BERT 输出的向量已经很强但它是逐位置的对实体边界这种需要序列依赖的判断再叠一层双向 LSTM 能进一步捕捉长距离的标签依赖。比如判断××市××区是不是一个完整地名需要看到后面的人民法院才能确定边界。BiLSTM 的前向和后向两层分别从左到右、从右到左扫一遍把这种依赖编码进去。CRF 负责保证标签序列合法。这是最容易被忽略但最关键的一层。如果只用 BERTBiLSTM 做逐字分类模型可能输出 B-PER、I-PER、I-PER、O 这种合法序列也可能输出 I-PER、B-PER 这种非法序列——实体还没开始就出现了内部标签。CRF 通过转移矩阵约束标签之间的转移概率把B 后面才能跟 IO 不能直接跳到 I这类规则学进解码过程输出全局最优的合法标签序列。提示如果你的数据量很小几百条以内可以先用 BERTSoftmax 跑个基线加上 CRF 通常能涨 1~3 个点 F1但训练会慢一些因为 CRF 的维特比解码有额外开销。2.2 标签体系设计BIO 还是 BIOES标签体系直接决定抽取质量这一步没设计好后面调参都是白费。常见两套体系标签形式优点缺点BIOB-X / I-X / O标签少标注快单字实体和实体边界易混BIOESB-X / I-X / E-X / S-X / O边界清晰单字实体用 S标签翻倍标注成本高法律文书里单字实体不少比如王作为姓氏简称、京作为车牌简称。用 BIO 时单字实体只能标成 B-X模型分不清它是实体开头还是完整实体边界容易错。我一般推荐 BIOES虽然标注累一点但后面省心。交通肇事案常用的实体类别可以这样定# 交通肇事案事件要素标签体系BIOES LABELS [ O, # 非实体 B-TIME, I-TIME, E-TIME, S-TIME, # 时间2023年4月12日19时许 B-LOC, I-LOC, E-LOC, S-LOC, # 地点××市××区××路 B-PER, I-PER, E-PER, S-PER, # 人物被告人王某 B-VEH, I-VEH, E-VEH, S-VEH, # 车辆××牌小型轿车 B-RES, I-RES, E-RES, S-RES, # 伤亡结果致一人死亡 B-DUTY, I-DUTY, E-DUTY, S-DUTY, # 责任认定负事故全部责任 B-AMT, I-AMT, E-AMT, S-AMT, # 赔偿金额人民币58万元 B-LAW, I-LAW, E-LAW, S-LAW, # 罪名法条交通肇事罪 ]标签设计有三个血泪经验第一类别不要贪多先覆盖判决书里高频且结构化的要素低频类别比如天气标注几十条也训不出效果第二类别之间不要语义重叠伤亡结果和责任认定要划清否则标注员自己都纠结第三留一个其他兜底类别遇到拿不准的片段标 O别硬塞进某个实体。2.3 数据标注格式与转换标注工具用 Label Studio、doccano 都行导出通常是 JSON 或 CoNLL 格式。BERT 训练需要的是字 标签对齐的序列所以中间要做一步转换。假设原始标注是 JSON每个样本包含文本和实体列表实体带 start、end、label转换脚本长这样import json def bioes_from_spans(text, entities, label_list): 把 span 形式的标注转成逐字 BIOES 标签 chars list(text) tags [O] * len(chars) for ent in entities: s, e, lab ent[start], ent[end], ent[label] # e 为开区间 if e - s 1: tags[s] fS-{lab} else: tags[s] fB-{lab} for i in range(s 1, e - 1): tags[i] fI-{lab} tags[e - 1] fE-{lab} # 校验标签必须在预定义集合内 for t in tags: assert t in label_list, f非法标签 {t} return chars, tags # 读取标注文件并转换 with open(anno.json, r, encodingutf-8) as f: data json.load(f) samples [] for item in data: chars, tags bioes_from_spans(item[text], item[entities], LABELS) samples.append({chars: chars, tags: tags}) with open(train_bioes.json, w, encodingutf-8) as f: json.dump(samples, f, ensure_asciiFalse, indent2)这段代码的关键在e - s 1的判断单字实体走 S 标签多字实体首字 B、中间 I、末字 E。assert那行别省标注数据里经常混进拼错的标签名早报错比训练到一半崩掉强。转换完建议抽十条人工核对尤其是实体紧挨着的情况比如王某驾驶××牌轿车两个实体边界相邻转换容易错位。3. 用 HuggingFace 把 BERTBiLSTMCRF 跑起来环境、模型与训练命令3.1 环境准备与依赖版本这套方案对环境的敏感度中等主要坑在 PyTorch 和 transformers 的版本匹配上。我一般用 conda 建独立环境避免和系统 Python 打架conda create -n legal_ner python3.9 -y conda activate legal_ner pip install torch2.0.1 --index-url https://download.pytorch.org/whl/cu118 pip install transformers4.35.0 pip install pytorch-crf0.7.2 pip install seqeval1.2.2 pip install numpy pandas tqdmpytorch-crf是 CRF 层的现成实现比自己手写维特比解码省事。seqeval专门算 NER 的实体级 F1比逐字准确率靠谱得多——逐字准确率在 O 标签占 90% 的数据上能虚高到 95%但实体可能一个都没抽对。CUDA 版本按你显卡驱动选没有 GPU 就把 torch 那行换成 CPU 版训练会慢十倍以上小数据量还能忍。3.2 模型结构BERT 输出怎么接 BiLSTM 再接 CRF模型定义是这套方案的核心三个组件怎么串、维度怎么对齐看代码import torch import torch.nn as nn from transformers import BertModel from torchcrf import CRF class BertBiLstmCrf(nn.Module): def __init__(self, bert_path, num_labels, lstm_hidden256, dropout0.3): super().__init__() self.bert BertModel.from_pretrained(bert_path) hidden self.bert.config.hidden_size # 中文 base 版是 768 self.bilstm nn.LSTM( input_sizehidden, hidden_sizelstm_hidden, num_layers1, batch_firstTrue, bidirectionalTrue, ) 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_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] if labels is not None: # CRF 的 loss 是负对数似然取负号做最小化 loss -self.crf(emissions, labels, maskattention_mask.bool(), reductionmean) return loss # 解码时返回最优标签序列 return self.crf.decode(emissions, maskattention_mask.bool())几个参数要盯住lstm_hidden256是经验值双向拼起来 512 维再大显存吃不消且容易过拟合dropout0.3在 BERT 微调里偏保守数据少于 2000 条可以提到 0.4~0.5num_layers1够用堆到 2 层收益很小还慢。注意attention_mask必须传给 CRF 的mask参数否则 padding 位置会被当成真实标签参与转移计算F1 会莫名其妙掉几个点这个坑我踩过。3.3 训练脚本与关键超参训练循环本身不复杂关键是超参设置和标签对齐from torch.utils.data import Dataset, DataLoader from transformers import BertTokenizer import torch class NerDataset(Dataset): def __init__(self, samples, tokenizer, label2id, max_len256): self.samples samples self.tokenizer tokenizer self.label2id label2id self.max_len max_len def __len__(self): return len(self.samples) def __getitem__(self, idx): item self.samples[idx] chars, tags item[chars], item[tags] # 按字切分中文 BERT 不做 wordpiece 拆分 enc self.tokenizer(chars, is_split_into_wordsTrue, truncationTrue, max_lengthself.max_len, paddingmax_length, return_tensorspt) label_ids [self.label2id[t] for t in tags][:self.max_len] # padding 位置标签设为 -100CRF 里用 mask 屏蔽 label_ids [-100] * (self.max_len - len(label_ids)) return { input_ids: enc[input_ids].squeeze(0), attention_mask: enc[attention_mask].squeeze(0), labels: torch.tensor(label_ids), } # 训练主循环 def train(model, loader, optimizer, device, epochs10): model.train() for epoch in range(epochs): total_loss 0 for batch in loader: input_ids batch[input_ids].to(device) mask batch[attention_mask].to(device) labels batch[labels].to(device) # CRF 不接受 -100需要把 padding 位置的标签替换成合法值 labels labels.clone() labels[labels -100] 0 loss model(input_ids, mask, labels) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss loss.item() print(fepoch {epoch1} loss {total_loss/len(loader):.4f})超参我一般这样起步learning_rate2e-5BERT 微调的标准值大了会灾难性遗忘小了收敛慢、batch_size16显存 8G 能跑不够就降到 8 并累积梯度、epochs10法律数据通常 5~8 轮就收敛多了过拟合、max_len256判决书段落长但单句要素抽取 256 够整篇输入要分段。clip_grad_norm_那行别删BERTCRF 组合偶尔会梯度爆炸裁剪到 1.0 能稳住。3.4 评估为什么必须用实体级 F1训练完不能只看 loss要用 seqeval 算实体级指标from seqeval.metrics import classification_report, f1_score def evaluate(model, loader, id2label, device): model.eval() preds, trues [], [] with torch.no_grad(): for batch in loader: input_ids batch[input_ids].to(device) mask batch[attention_mask].to(device) labels batch[labels] pred_ids model(input_ids, mask) for p, t, m in zip(pred_ids, labels, mask): length int(m.sum()) pred_tags [id2label[i] for i in p[:length]] true_tags [id2label[i] for i in t[:length].tolist() if i ! -100] preds.append(pred_tags) trues.append(true_tags) print(classification_report(trues, preds)) return f1_score(trues, preds)classification_report会按实体类别给出精确率、召回率、F1一眼能看出哪个类别拖后腿。交通肇事案里赔偿金额和责任认定通常 F1 最高表述规范地点和车辆最容易低写法太杂。如果某个类别 F1 低于 0.7先别调模型回去看标注一致性——十有八九是标注员对这个类别的边界理解不统一。4. 交通肇事案要素抽取的避坑清单五条踩坑记录4.1 坑一实体嵌套导致标签冲突现象标注时遇到××市××区人民法院既想标成 LOC地点又想标成 ORG机构转换脚本报标签冲突或后标覆盖前标。原因BIOES 是扁平标签体系一个字符只能有一个标签天然不支持嵌套实体。法律文书里××市公安局交通警察支队这类机构名内含地名是典型嵌套。解决要么在标签体系里合并成一个类别比如统一叫地点机构要么上嵌套 NER 方案多层标签或 span 分类。交通肇事案里我一般合并因为下游要的是事发地和处理机关两个字段用规则从合并实体里再切分即可没必要为嵌套上复杂模型。4.2 坑二长文本截断把关键要素切没了现象判决书正文两三千字max_len256一截断责任认定和赔偿金额这些出现在文末的要素全丢了模型在验证集上 F1 虚高上线后抽不出东西。原因BERT 有最大长度限制直接整篇输入会被截断而关键要素分布不均匀。解决按句或按段切分每段单独预测再合并。切分时用标点。做边界保证实体不被切断。合并时注意跨段的实体比如被告人王某在上一段末尾、驾驶××牌轿车在下一段开头需要做后处理拼接。我一般写个滑动窗口窗口 256、步长 128重叠部分用投票决定标签。4.3 坑三标签不平衡让模型全预测 O现象训练几轮后 loss 降得很快但 F1 接近 0模型把所有字都预测成 O。原因O 标签占比通常 85% 以上模型学到全预测 O 就能拿高准确率这个捷径。解决三个手段组合用。一是损失函数加权给非 O 标签更高权重二是在 CRF 转移矩阵里初始化时降低任意标签转移到 O的概率三是采样时对含实体多的样本过采样。最有效的还是加权nn.CrossEntropyLoss(weight...)那套在 CRF 里不直接适用可以手动给 emissions 里 O 对应的 logit 减一个偏置。4.4 坑四BERT 参数下载失败或加载错版本现象from_pretrained卡住或报连接错误或者加载了英文 BERT 导致中文效果极差。原因中文 BERT 权重文件几百兆网络不稳时下载中断或者路径写错加载了bert-base-uncased。解决提前把bert-base-chinese的权重下到本地目录from_pretrained传本地路径。判断加载对没对看tokenizer.tokenize(交通肇事)的输出——中文 BERT 应该输出[交, 通, 肇, 事]四个 token如果输出[[UNK]]或一堆##说明加载错了。4.5 坑五推理时 batch 内长度不一致导致解码错位现象单条推理正常批量推理时结果错乱实体位置对不上。原因批量推理时 padding 到同一长度crf.decode返回的是去掉 padding 后的序列但和原始文本对齐时没考虑每条的真实长度。解决解码后用attention_mask截取有效长度再和原始字符对齐。别直接用decode的返回值去索引原文一定先按 mask 截断。这个坑在写批量抽取接口时特别容易犯单条测试发现不了。5. 让抽取结果真正可用后处理规则与持续迭代的一个习惯模型输出 BIOES 标签序列只是半成品要变成能入库的结构化字段后处理这步不能省。最典型的是实体归一化模型可能把2023年4月12日19时许整段标成 TIME但下游要的是标准日期2023-04-12需要写规则把19时许这类模糊时间剥离用正则提取年月日。金额同理人民币58万元要转成数字580000注意万的换算和余字的处理。import re from datetime import datetime def normalize_time(text): 把模型抽出的时间片段归一化成标准日期 m re.search(r(\d{4})年(\d{1,2})月(\d{1,2})日, text) if not m: return None y, mo, d map(int, m.groups()) try: return datetime(y, mo, d).strftime(%Y-%m-%d) except ValueError: return None def normalize_amount(text): 把赔偿金额归一化成元 m re.search(r(\d(?:\.\d)?)\s*万?, text) if not m: return None val float(m.group(1)) if 万 in text: val * 10000 return int(val) # 对模型输出做后处理 raw_time 2023年4月12日19时许 raw_amt 人民币58万元 print(normalize_time(raw_time)) # 2023-04-12 print(normalize_amount(raw_amt)) # 580000后处理规则要和模型输出配合调不能各写各的。我的习惯是先把模型在验证集上的输出全部导出人工看一遍错误模式再决定加哪些规则。比如发现模型总把××牌和车型分开标就在后处理里做相邻实体合并发现负全部责任和负事故全部责任被标成不同片段就做同义归一。持续迭代上我坚持一个习惯每次上线新版本前把线上抽错的 case 攒起来每周补标 50~100 条加进训练集重训。法律文书的表述会随时间和地域变化模型不迭代三个月后 F1 就会肉眼可见地掉。这套 BERTBiLSTMCRF 的方案基线能到 0.85 左右的实体级 F1但真正决定它好不好用的是标注规范和后处理规则这些脏活。别指望一次训练就一劳永逸把它当成一个需要养的工程系统比当成一个模型来调心态会稳很多。希望帮到你。本文还有配套的精品资源点击获取