简介:本资源面向自然语言处理方向的学生与开发者,提供一套基于BERT+BiLSTM+CRF的法律文书命名实体识别完整项目源码,聚焦交通肇事案的事件要素抽取任务,可作为课程设计、期末大作业或NLP入门实战参考。压缩包共48个文件,约694KB,以21个Python脚本为核心,涵盖模型定义、数据加载、训练与预测流程,另含xml配置、train/test/dev数据集、日志、pkl映射文件及README说明文档,结构清晰便于二次开发。目前已有190人学习下载。项目整合预训练语言模型与序列标注架构,配套项目说明与预训练权重加载脚本,读者可据此理解法律领域实体抽取的完整链路,包括数据预处理、模型搭建、训练评估与结果输出,并可直接运行验证,省去从零搭建的调试成本,适合希望快速上手NLP序列标注实践的学习者。
1. 法律文书要素抽取:BERT+BiLSTM+CRF 到底在解决什么
交通肇事案的卷宗里,真正决定定性和量刑的往往就那么几个词:肇事时间、肇事地点、车牌号、伤亡人数、责任认定结果。一份判决书动辄几千字,人工摘这些要素,一个熟手一天也就处理几十份,还容易漏。把这件事交给模型,就是命名实体识别(NER)要干的活。而 BERT+BiLSTM+CRF 这套组合,是中文法律文书 NER 里被验证过很多次的经典结构:BERT 负责把「肇事逃逸」和「驾车驶离」这种语义相近但字面不同的表述压到相近的向量空间,BiLSTM 负责捕捉实体跨词的上下文依赖,CRF 负责保证输出的标签序列合法——比如「B-地点」后面不会突兀地跟一个「I-人名」。这篇笔记就围绕这个标题,把数据怎么标、模型怎么搭、参数怎么调、坑在哪,一条条讲清楚,适合手里有法律文书数据、想跑通要素抽取的 Python 开发者照着复现。
2. 为什么是 BERT+BiLSTM+CRF:三层结构各管什么
2.1 单用 BERT 做序列标注差在哪
很多人第一反应是直接在 BERT 后面接一个线性分类层,每个 token 输出一个标签。这条路能跑,但在法律文书上会暴露两个问题。第一,BERT 的输出是逐 token 独立的,标签之间的转移约束它学不到,模型可能吐出「I-时间」开头这种非法序列。第二,法律文书里实体边界经常靠长距离依赖判断,比如「被告人张某于 2023 年 5 月 12 日 19 时许」,「19 时许」是不是时间实体,要看前面有没有「于……日」这个框架,单层分类对这类结构不敏感。
CRF 解决的正是第一类问题。它把标签序列当成一个整体来打分,转移矩阵里「B-地点 → I-地点」的分数高,「B-地点 → I-人名」的分数低,解码时用 Viterbi 找全局最优路径,非法序列自然被压下去。这也是为什么在法律、医疗、金融这些对实体边界要求严的领域,CRF 几乎是标配。
2.2 BiLSTM 夹在中间不是多余的
既然 BERT 已经很强,为什么还要塞一层 BiLSTM?这里有个实操层面的理由:BERT 的预训练目标让它更擅长抓全局语义,但对相邻 token 之间的细粒度模式(比如「肇」「事」「车」三个字连在一起才是完整实体)捕捉得不够锐利。BiLSTM 双向扫描一遍,前向 LSTM 记住「被告人」开头,后向 LSTM 记住「驾驶」结尾,把局部序列特征再强化一次,再交给 CRF。在中文法律文书这种实体嵌套多、简称多的语料上,加这一层通常能把 F1 拉高 1 到 3 个百分点。
代价是参数量和推理时间增加。如果只是做个 demo,BERT+线性层够用;如果要上生产、对边界敏感,BiLSTM 这层值得留。
2.3 标签体系怎么定:BIO 还是 BIOES
标签体系直接决定后面所有代码的写法。BIO 只有 B(实体开始)、I(实体内部)、O(非实体)三种,简单但边界模糊;BIOES 多了 E(实体结束)和 S(单字实体),边界更清晰,代价是标签数翻倍。
交通肇事案要素抽取,我一般用 BIOES,因为「车牌号」这种实体经常是「京 A12345」这种字母数字混排,BIO 下模型容易把边界切错。下面是一份典型的标签定义:
| 标签 | 含义 | 示例 |
|---|---|---|
| B-TIME | 时间实体开始 | 2023 年 |
| I-TIME | 时间实体内部 | 5 月 |
| E-TIME | 时间实体结束 | 12 日 |
| S-LOC | 单字地点实体 | 京 |
| B-PLATE | 车牌开始 | 京 A |
| I-PLATE | 车牌内部 | 12345 |
| O | 非实体 | 被告人 |
标签定完,写进一个labels.txt,一行一个,顺序固定。后面模型输出的 id 和这个文件必须严格对应,错一位整个训练就白跑。
3. 从原始判决书到可训练数据:标注与预处理
3.1 数据从哪来、怎么切
法律文书公开渠道能拿到判决书文本,但直接拿来训练不行,得先做几件事:去掉页眉页脚、去掉审判人员签名段落、把全角标点统一成半角。交通肇事案的要素集中在「经审理查明」到「本院认为」之间,可以按这个区间截取,减少无关噪声。
切分粒度上,中文 NER 一般按字切,不按词切。原因是法律文书里专有名词多,分词器容易把「交通事故责任认定书」切成奇怪的组合,按字切反而稳定。BERT 的中文预训练模型本身就是按字做的,衔接也顺。
3.2 标注工具与格式转换
标注可以用 brat、Label Studio 这类工具,导出成 JSON 或 TSV。核心是把「字符位置 + 标签」转成模型要的「每字一标签」。下面这段代码把「实体区间列表」转成 BIOES 序列:
def spans_to_bioes(text, spans): """ text: 原始字符串 spans: [(start, end, label), ...] end 为开区间 返回: 与 text 等长的标签列表 """ tags = ["O"] * len(text) for start, end, label in spans: if end - start == 1: tags[start] = f"S-{label}" else: tags[start] = f"B-{label}" for i in range(start + 1, end - 1): tags[i] = f"I-{label}" tags[end - 1] = f"E-{label}" return tags逻辑说明:单字实体直接标 S,多字实体首字 B、末字 E、中间全 I。参数上要注意end是开区间,如果标注工具给的是闭区间,调用前要end + 1。这一步错了,后面标签全错位,而且不报错,属于最隐蔽的翻车点。
3.3 构建 Dataset 与对齐 BERT 分词
BERT 分词器会在字之间插[CLS]、[SEP],还可能把某些字符拆成子词。中文按字切时基本一字一 token,但仍要对齐标签,保证input_ids和labels长度一致:
from torch.utils.data import Dataset import torch class NerDataset(Dataset): def __init__(self, texts, tags_list, tokenizer, label2id, max_len=256): self.texts = texts self.tags_list = tags_list self.tokenizer = tokenizer self.label2id = label2id self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): text = self.texts[idx] tags = self.tags_list[idx] # 按字切,不加特殊符号以外的处理 chars = list(text) input_ids = self.tokenizer.convert_tokens_to_ids(chars) # 手动加 [CLS] 和 [SEP] cls_id = self.tokenizer.cls_token_id sep_id = self.tokenizer.sep_token_id input_ids = [cls_id] + input_ids[:self.max_len - 2] + [sep_id] label_ids = [self.label2id["O"]] + \ [self.label2id[t] for t in tags[:self.max_len - 2]] + \ [self.label2id["O"]] return { "input_ids": torch.tensor(input_ids), "labels": torch.tensor(label_ids), "attention_mask": torch.ones(len(input_ids), dtype=torch.long) }逻辑说明:这里没有用tokenizer()的自动编码,而是手动convert_tokens_to_ids,目的是完全掌控对齐关系。参数max_len=256是经验值,交通肇事案要素句一般不超过这个长度,超长截断时优先保留「经审理查明」段落。[CLS]和[SEP]位置标 O,不参与实体损失计算时可以在 loss 里 mask 掉。
提示:如果换用其他中文预训练模型,先确认它的分词粒度是不是按字。按词分词的模型直接套这段代码会错位。
4. 模型搭建:BERT 输出怎么接 BiLSTM 再接 CRF
4.1 整体结构与前向传播
结构顺序是 BERT → BiLSTM → 线性层 → CRF。BERT 输出每个 token 的隐状态,BiLSTM 再扫一遍,线性层把维度映射到标签数,CRF 负责序列打分。下面是一个可运行的 PyTorch 实现:
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_tags, lstm_hidden=256, dropout=0.3): super().__init__() self.bert = BertModel.from_pretrained(bert_path) hidden = self.bert.config.hidden_size # 通常 768 self.bilstm = nn.LSTM( input_size=hidden, hidden_size=lstm_hidden, num_layers=1, bidirectional=True, batch_first=True ) self.dropout = nn.Dropout(dropout) self.classifier = nn.Linear(lstm_hidden * 2, num_tags) self.crf = CRF(num_tags, batch_first=True) def forward(self, input_ids, attention_mask, labels=None): outputs = self.bert(input_ids, attention_mask=attention_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_tags] if labels is not None: # CRF 的 loss 是负对数似然,取负号 loss = -self.crf(emissions, labels, mask=attention_mask.bool(), reduction="mean") return loss else: # 解码返回最优路径 return self.crf.decode(emissions, mask=attention_mask.bool())逻辑说明:lstm_hidden=256双向拼接后是 512,和classifier输入对上。dropout=0.3是法律文书这种中等规模语料的常用值,数据量上万条可以降到 0.1。CRF 的mask参数必须传,否则 padding 位置会参与打分,训练 loss 会虚低,验证时 F1 却上不去,这是很典型的黑匣子现象。
4.2 三个必调参数:学习率、batch size、CRF 学习率
BERT 微调的学习率不能大,一般 2e-5 到 5e-5。BiLSTM 和 CRF 是随机初始化的,需要更大的学习率,常见做法是分组设置:
bert_params = list(model.bert.named_parameters()) other_params = [(n, p) for n, p in model.named_parameters() if not n.startswith("bert")] optimizer = torch.optim.AdamW([ {"params": [p for _, p in bert_params], "lr": 3e-5}, {"params": [p for _, p in other_params], "lr": 1e-3} ], weight_decay=0.01)逻辑说明:BERT 用 3e-5,BiLSTM+CRF 用 1e-3,差两个数量级。如果统一用一个学习率,要么 BERT 被训崩,要么 CRF 学不动。weight_decay=0.01是 AdamW 的常规值,对 BERT 微调有正则效果。
batch size 方面,单卡 16GB 显存,max_len=256时 batch 开到 16 比较稳。显存不够就降到 8,同时把学习率按比例调小一点,否则梯度噪声大,收敛曲线会抖。
4.3 训练循环与验证指标
训练时每个 epoch 结束在验证集上算实体级 F1,用seqeval库,不要用 token 级准确率——后者在 O 标签占 90% 以上的语料上能到 0.9,但模型可能一个实体都没识别出来。
from seqeval.metrics import f1_score, classification_report def evaluate(model, dataloader, id2label): model.eval() preds, trues = [], [] with torch.no_grad(): for batch in dataloader: logits = model(batch["input_ids"], batch["attention_mask"]) for pred, true in zip(logits, batch["labels"]): preds.append([id2label[i] for i in pred]) trues.append([id2label[i] for i in true.tolist()]) return f1_score(trues, preds)逻辑说明:seqeval按实体整体算 P/R/F1,边界错一个字就算错,符合法律要素抽取的实际要求。验证时记得把[CLS]、[SEP]和 padding 位置的标签去掉,否则会拉低指标。
5. 避坑与排查:训练不收敛、F1 上不去的五个真实原因
5.1 现象:loss 一直降,F1 却卡在 0.3 不动
原因通常是标签对齐错了。手动拼input_ids时如果[CLS]加了但标签没加对应占位,或者截断时input_ids截了而labels没截,模型学到的就是错位映射。排查方法:取一条样本,把input_ids解码回文字,和labels逐位对照,看实体位置是否吻合。解决就是统一在 Dataset 里做对齐,别在别处再动。
5.2 现象:验证集 F1 波动极大,相邻两个 epoch 差 0.2
原因一般是 batch size 太小加上学习率偏高,梯度噪声大。法律文书语料如果只有几千条,batch=8 时尤其明显。解决:把 BERT 学习率降到 2e-5,BiLSTM 那组降到 5e-4,同时开梯度累积,累积 4 步等效 batch=32。另外验证集本身如果只有几十条,指标波动也正常,建议验证集至少 500 条以上。
5.3 现象:模型把「被告人张某」整体标成一个人名
原因是标签体系里人名和称谓没分开,或者训练数据里「被告人+姓名」总是连在一起标注。解决:在标注规范里明确「被告人」是 O,「张某」才是 B-PERSON,重新标一批数据。如果重标成本高,可以在后处理里用规则把「被告人」「被害人」前缀切掉。
5.4 现象:车牌号识别率特别低
车牌是字母数字混排,BERT 中文预训练时这类字符见得少,向量质量差。解决有两个方向:一是把车牌里的字母数字在预处理时统一大写,减少形态变化;二是在 BiLSTM 后单独给车牌类实体加权,loss 里对 B-PLATE、I-PLATE 乘一个 1.5 的系数。实测第二种能把车牌 F1 从 0.6 拉到 0.78 左右。
5.5 现象:推理时 CRF 解码报 mask 维度错误
torchcrf的decode要求 mask 是 bool 类型且形状和 emissions 前两维一致。常见错误是传了attention_mask但没转 bool,或者 batch 里最后一条样本长度不同导致 padding 没对齐。解决:在 collate_fn 里用pad_sequence统一长度,mask 用attention_mask.bool(),别用 int。
6. 进阶:用规则后处理把 F1 再抬一截
模型跑通之后,真正上生产前还有一步:规则后处理。法律文书的要素有很强的格式规律,模型漏掉的,规则能补;模型标错的,规则能纠。我一般会加三层规则。
第一层是时间归一化。模型识别出「2023 年 5 月 12 日 19 时许」,后处理统一转成2023-05-12 19:00这种标准格式,方便入库。第二层是车牌校验。中国大陆车牌有固定格式,用正则[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼][A-Z][A-Z0-9]{5}过滤一遍,模型输出不符合这个模式的直接丢弃。第三层是实体去重。同一份文书里「张某」可能出现多次,按字符位置去重,只保留首次出现。
import re PLATE_RE = re.compile( r"[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼]" r"[A-Z][A-Z0-9]{5}" ) def post_process(entities): """entities: [{'text':..., 'label':..., 'start':...}, ...]""" cleaned = [] seen = set() for ent in entities: key = (ent["text"], ent["label"]) if key in seen: continue if ent["label"] == "PLATE" and not PLATE_RE.fullmatch(ent["text"]): continue seen.add(key) cleaned.append(ent) return cleaned逻辑说明:fullmatch要求整个字符串匹配车牌模式,避免「京 A12345 号车」这种带后缀的被误判。去重按(text, label)做,同一实体不同位置只留一个。这一步不复杂,但在实际项目里能把精确率抬 3 到 5 个百分点。
验证后处理效果,别只看整体 F1,要分实体类型看。时间、地点这类规则强的实体,后处理提升明显;人名、责任认定这类语义强的,后处理基本没帮助,甚至可能误伤。我一般会留一个--use_postprocess开关,A/B 对比后再决定线上开不开。
最后说个习惯:每次改完标签体系或预处理逻辑,先拿 20 条样本过一遍全流程,人工核对输入输出,再启动训练。这个动作花不了十分钟,但能省下几个小时的无效训练。法律文书 NER 这活,模型结构是骨架,数据对齐是血肉,规则后处理是衣服,哪一层偷懒都会在最终指标上现形。希望帮到你。
本文还有配套的精品资源,点击获取