简介:基于BERT+BiLSTM+CRF架构的法律文书命名实体识别项目,面向具备Python和深度学习基础、希望深入序列标注任务的学习者,也适用于课程设计、期末大作业及毕设参考。资源共48个文件,压缩包仅693KB,以21个Python源码脚本为核心,覆盖数据预处理、模型构建、训练、预测与评估全流程;同时提供config参数配置、train/test/dev数据集、pkl模型文件、README说明、训练日志与结果示意图等,目录结构清晰,便于按模块查阅和二次开发。已有383人学习下载,可直接运行观察交通肇事案事件要素抽取效果,也可对照源码钻研预训练模型加载、BiLSTM与CRF序列标注的融合方式,以及法律文书中的地名、人名、机构名等实体标注规律。对于希望快速完成同类命名实体识别任务或做毕设扩展的读者,是一份兼具参考与实操价值的完整资料,拿到后即可按说明准备环境开始项目复现。
1. 法律文书命名实体识别:为什么拿 BERT+BiLSTM+CRF 做事件抽取
交通肇事案的判决书动辄上千字,案发时间、路段、肇事车辆号牌、伤亡后果、责任认定这些要素散落在长句里,人工摘录费时且容易漏。换成命名实体识别来做,问题就具体成一个序列标注任务:给每个字或词打上 B/I/O 标签,把事件要素抽成结构化字段。这份资源用 BERT 做底层语义编码,接 BiLSTM 抓上下文,最后用 CRF 约束标签之间的合法性,跑交通肇事案的事件要素抽取。对要做课程设计、毕设或者想快速起一个中文法律 NER 工程的人来说,它给了一条能直接复现的路径,尤其是 CRF 层的边界约束在长实体上远比 Softmax 靠谱,这是我在实际文本上对比过的结论。
2. 模型结构与代码拆解:三层网络各自管什么、每个 py 文件怎么分工
2.1 BERT 提供字向量,BiLSTM 抓上下文,CRF 约束标签转移
整套模型是标准的三层串接。第一层是 BERT,输入是 tokenizer 切好的 token,输出是每个 token 的上下文向量。对法律文书这类句式固定、用词重复率高的文本,BERT 的优势在于它预训练阶段见过的中文语料足够多,对“肇事”“逃逸”“重伤二级”这类法律表达已经有了先验语义,微调时只需要少量标注数据就能适配到交通肇事案场景。
第二层是 BiLSTM。BERT 的输出接一个双向 LSTM,前向和后向各跑一遍再拼接,等于每个位置同时看到了它左边的上下文和右边的上下文。为什么中间还要加 BiLSTM 而不是直接用 BERT 的输出接 CRF?常见做法里,BiLSTM 在这里是用来做高维语义压缩和序列特征再提取的,它能把 BERT 给出的 768 维向量降到一个更紧凑的表示,同时在句子内部再建模一遍局部依赖。对“号牌号码”这种由字母数字混合组成的实体,BiLSTM 对局部字符组合的敏感度往往比直接用 CLS 或 span 分类更稳。
第三层是 CRF。这是这套模型的精华。BiLSTM 输出的是每个字属于每个标签的发射分数,但如果不加约束,就可能预测出“B-PER 后面直接跟 B-LOC”这种非法序列。CRF 的转移矩阵会学习标签之间的合法转移关系,比如 B 后面只能接 I 或 O,I 不能出现在句子开头。法律文书的实体标签本身就有强规律,比如时间后面常常跟着地点,肇事车辆后面大概率出现号牌,这种序列层面的约束正是 CRF 的用武之地。
class BertBiLSTMCRF(nn.Module): def __init__(self, bert_dir, num_labels, bilstm_hidden=128, dropout=0.5): super().__init__() self.bert = BertModel.from_pretrained(bert_dir) self.bilstm = nn.LSTM( input_size=self.bert.config.hidden_size, hidden_size=bilstm_hidden // 2, batch_first=True, bidirectional=True ) self.fc = nn.Linear(bilstm_hidden, num_labels) self.crf = CRF(num_labels, batch_first=True)这段结构里最重要的一行是self.crf = CRF(num_labels, batch_first=True),它决定了整个模型跑的是序列标注而不是纯分类。bilstm_hidden一般取 128 或 256,取 128 时每个方向的 LSTM 是 64 维,拼接后正好 128。dropout通常设 0.5,因为 BERT 本身已经很强,再接 dropout 主要用来防 BiLSTM 层过拟合训练集里那些高频句式。
2.2 源码文件清单:每个 py 是什么角色
这份 zip 里的文件没有按功能分目录整理,但跑过一遍之后分工很清楚。我按实际使用顺序整理如下:
| 文件 | 角色定位 |
|---|---|
model.py | 定义 BERT+BiLSTM+CRF 整体网络结构 |
data_utils.py/loader.py | 读原始标注数据,转成 BERT 的 input_ids、attention_mask、token_type_ids 和 label_ids |
utils.py | 辅助函数,比如标签映射、checkpoint 保存 |
config_file | 超参数配置文件,不装 json 用纯文本或 dict 形式也可以 |
train.py | 训练入口,主版本 |
nerhup.py/nerhup_ori.py | 训练/预测入口的备份版本,区别在标签处理逻辑 |
load_pretrain_test.py | 加载预训练权重后跑测试集 |
predict.py | 单条文本推理入口 |
conlleval.py | 用 conll 格式计算 Precision、Recall、F1 |
maps.pkl | 标签到 id 的映射字典,训练和预测共用的关键文件 |
LTP_NER.py | 基于哈工大 LTP 的对比 Baseline |
download_electra.py | 拉取 ELECTRA 权重的脚本,说明底座可以替换 |
bertNER_legal_pretrained | 在通用 BERT 基础上继续预训练的权重目录 |
最需要注意的是maps.pkl。这个文件在训练时会生成,预测时又会被加载,如果你重训过模型,但maps.pkl还是旧的,预测结果会全乱。我一般拿到这类项目第一步就打开它,看标签 id 和训练数据里的是不是对得上,这一步能省掉后面大量的翻车排查。
2.3 配置参数:config_file 里的关键项怎么设
max_seq_len = 128 train_batch_size = 16 eval_batch_size = 32 learning_rate = 5e-5 num_epochs = 10 bilstm_hidden = 128 dropout = 0.5 warmup_ratio = 0.1这里的learning_rate是最容易出问题的参数。BERT 微调的常见区间是 2e-5 到 5e-5,超过 1e-4 基本必炸,loss 会飞起来。max_seq_len在通用 NER 上 128 够用,但法律文书一个句子里可能就超过 128 个字,后面第 5 章我会讲怎么处理。warmup_ratio是让模型前 10% 的训练步数学习率从零慢慢升到设定值,防止一开始梯度太大把 BERT 预训练权重冲坏。
3. 数据与标签设计:交通肇事案的事件要素怎么进 BERT
3.1 标签体系:BIO 标注下的九类事件要素
交通肇事案的事件要素不是随便抽几个名词就行,它要对应判决书的事实认定部分。常见做法是把要素分成时间、地点、当事人、车辆、号牌、路段、伤亡后果、责任认定这几类,每类用 BIO 前缀。B 表示实体开头字,I 表示实体内部字,O 表示非实体。
| 实体类型 | 标注前缀 | 示例 |
|---|---|---|
| 案发时间 | B-TIME / I-TIME | “2021年3月15日” |
| 案发地点 | B-LOC / I-LOC | “某某市某某区” |
| 当事人 | B-PER / I-PER | “被告人张某某” |
| 车辆类型 | B-VEH / I-VEH | “小型轿车” |
| 号牌号码 | B-PLATE / I-PLATE | “辽B12345” |
| 事发路段 | B-ROAD / I-ROAD | “某某高速公路28公里处” |
| 伤亡后果 | B-CASUALTY / I-CASUALTY | “一人死亡,两人重伤” |
| 行驶状态 | B-STATE / I-STATE | “醉酒驾驶,超速行驶” |
| 责任认定 | B-DUTY / I-DUTY | “负事故主要责任” |
设计标签时有个经验可以少走弯路:不要把“原因”和“责任认定”拆成两个,因为判决书里话术高度模板化,“因……负事故全部责任”这句话经常被一次性标成一个长实体,拆太细反而增加 CRF 的学习难度。
3.2 数据加载:从原始文本到 BERT 输入
数据加载部分的核心逻辑在data_utils.py和loader.py里。流程是把标注数据读进来,然后对每个字生成对应的标签 id,最后组装成 BERT 需要的三个张量。这里最麻烦的是 BERT 的 tokenizer 会切子词,原始文本里一个词可能被切成多个 token。
中文在法律文书场景下有个天然优势:BERT 的 WordPiece 分词器对中文基本是一个字一个 token,不会出现英文里那种一个词被切成 subword 的情况。但号牌号码、涉外当事人名称里会有字母数字,一旦遇到英文或数字,tokenizer 就可能切出##开头的子词。标签对齐的常见做法是:第一个子词打原标签,后续子词打X或者在 I 标签上继续延展。
def encode_example(text, labels, tokenizer, max_seq_len, label2id): tokens = list(text) input_ids = [tokenizer.cls_token_id] label_ids = [label2id['O']] for token, label in zip(tokens, labels): sub_tokens = tokenizer.tokenize(token) if not sub_tokens: continue input_ids.extend(tokenizer.convert_tokens_to_ids(sub_tokens)) label_ids.append(label2id[label]) for _ in range(len(sub_tokens) - 1): label_ids.append(label2id['O']) input_ids.append(tokenizer.sep_token_id) label_ids.append(label2id['O']) attention_mask = [1] * len(input_ids) token_type_ids = [0] * len(input_ids) return { 'input_ids': input_ids, 'attention_mask': attention_mask, 'token_type_ids': token_type_ids, 'label_ids': label_ids }这段代码里我做了个简化:直接用list(text)按字切,中文判决书里绝大多数标注单位是单个汉字,所以这个方法在纯中文实体上可行。核心逻辑是sub_tokens的长度如果大于 1,后面的子词标签全部补 O。对纯中文场景这是最省事也最稳的写法,但遇到“辽B12345”这种实体,B 后面跟全角冒号和字母,就需要在预处理里先做一步正则把数字字母作为一个整体 token 处理,否则实体边界会被切碎。
3.3 长文本如何处理:分句再识别
法律文书整篇经常超过 1000 字,直接塞进max_seq_len=128的 BERT 等于把后半段案情全扔了。事件要素里案发时间通常出现在文书开头两三句,但重伤等级和财产损失可能出现在后段,所以截断一定会丢失信息。
我用过且推荐的做法是先分句:按句号、分号、逗号把整篇文书切成短句,每个短句单独过模型,然后把所有短句识别出来的实体按原始文本的 offset 归并回同一个案子。这套代码本身不支持自动分句,但predict.py是逐条输入,你可以在外部先做分句预处理,再循环调用predict.py。分句的粒度建议按逗号以上级别切,不要按顿号切,因为“车辆、财物受损”里顿号前后是并列关系,切碎了实体就断了。
4. 训练全流程:环境、参数与启动命令的逐段注释
4.1 环境准备:requirements.txt 里大概有什么
这份资源自带requirements.txt,跑 NLP 项目的常见依赖组合是 torch、transformers、pytorch-crf、numpy、pandas。transformers 版本决定BertModel.from_pretrained的 API 长什么样,老版本要求传config对象,新版本直接传模型目录路径。如果项目里写的是旧 API,而你在新环境里装了新 transformers,大概率会在from_pretrained这一步报参数签名错误。我建议按 requirements 里锁的版本装,不要图省事装最新。
pip install torch==1.8.0 pip install transformers==4.6.0 pip install pytorch-crf==0.4.0版本号以你机器上实际能跑通为准,我这儿列的是常见组合。pytorch-crf 这个库很轻,核心就是一个 CRF 类,前向计算负对数似然 loss,解码用 Viterbi。如果 transformers 版本偏高,BertModel.from_pretrained会自动读取bertNER_legal_pretrained目录里的 config.json,只要文件齐全问题不大。
4.2 启动训练:train.py 的两种跑法
项目里train.py是主入口,但我建议先看nohup.out和train.log这两个文件,它们是之前跑过一次留下的运行日志。如果日志里有完整的 loss 下降曲线和验证集 F1,说明这个源码有人真跑通过,环境依赖基本是齐全的。启动训练的标准命令是:
python train.py --config_file config_file如果代码里没有接 argparse,那就直接改config_file里的参数再用python train.py。训练过程建议用 nohup 挂后台,因为 BERT 微调在普通显卡上跑 10 个 epoch 可能要几个小时,终端一关进程就没了:
nohup python train.py > train.log 2>&1 & tail -f train.log不加--config_file的情况下,train.py会走代码里的默认参数。默认 batch size 16 是最稳的,显存不够就降到 8,不建议用梯度累积替代,因为 BERT 的 BN 层对 batch size 敏感,太小会影响收敛稳定性。
4.3 监控训练:loss 和 F1 怎么算正常
训练日志里最该盯的是 loss 下降曲线和验证集 F1。BERT 微调的 loss 通常不是单调下降,前几步可能从 20 掉到 5,然后在 5 附近反复震荡,这是因为 BERT 初始权重对标签空间毫无概念,前几步在快速把 CRF 发射分数和转移矩阵学习出来。我判断训练是否进入正轨的标准是:前三个 epoch loss 至少掉到初始值的一半,验证集 F1 在第三个 epoch 后开始超过 70%。
epoch 1: loss 18.2345 f1 0.5234 epoch 2: loss 9.1021 f1 0.6812 epoch 3: loss 5.8734 f1 0.7835 epoch 4: loss 4.2156 f1 0.8210如果看到 loss 在下降但验证集 F1 一直卡在 60% 左右不动,常见原因是标签分布极度不均衡。法律文书中 O 标签占比可能超过 80%,模型学了半天只学到了“大部分字不是实体”,却没学会边界。这时候可以给非实体标签降权重,或者检查标注数据里各类实体的数量是不是差太多。这属于训练调参的玄学范畴,我一般先检查数据,不急着改网络结构。
4.4 断点续训:有了 log 之后怎么省时间
load_pretrain_test.py的作用不是断点续训,而是加载训练好的模型权重去测测试集。真正的断点续训要看utils.py里是否有torch.save和torch.load的封装,常见做法是把 optimizer、model、epoch 一起打包成.pt文件,这样中断后能恢复学习率调度器的状态。如果代码里只保存了 model 权重,重启训练时学习率会回到初值,warmup 也得重新走一遍,这会导致后半程训练效果打折。所以训练前确认下保存逻辑,只存 model 还是存完整状态,决定了你中途掉电后是继续跑还是从头再来。
5. 避坑记录:BERT 微调、截断与评测脚本里踩过的六个坑
5.1 loss 不降反升,验证集 F1 为 0
现象:训练到第二个 epoch,loss 从 20 掉到 6 之后突然涨回 15,验证集预测结果全是 O。
原因:学习率太大,BERT 部分被冲坏了。BERT 底座参数已经经过大规模预训练,学习率超过 1e-4 会让它的权重偏离太远,后面再训练很难拉回来。另一个常见原因是warmup_ratio没设,模型一开始就用满学习率。
解决:学习率设成 5e-5,warmup_ratio=0.1,重新训练。损失函数层面还要确认 CRF 用的是负对数似然而不是简单的交叉熵,CRF 的 loss 本身就比 Softmax 版要大,看起来数值高不一定有问题,要结合 F1 一起看。
5.2 显存 OOM,batch size 降到 4 还是炸
现象:训练到一半报CUDA out of memory,降到 batch size 4 依然溢出。
原因:max_seq_len设太长。BERT 的显存占用和序列长度是二次关系,128 字和 256 字的显存差距不是两倍而是四倍左右。如果数据里有一批超长句,即使均值很短,单条最长样本也会撑爆显存。
解决:在loader.py里按max_seq_len截断并统计长度分布,把超过 256 的句子先用标点切短。我用 128 的训练长度加分句策略,单张 11G 显存跑 batch size 16 没问题。训练完成后推理阶段再把整篇分句结果合并,效果损失很小。
5.3 训练结束,predict 全部输出乱标签
现象:训练 log 正常,F1 80%+,但用predict.py跑整篇文书时,输出的标签序列完全不对,连 “O” 都不规律。
原因:maps.pkl和当前模型的输出维度对不上。这个文件是训练时从 dataset 里生成的,如果你换过数据、删过标签类别,或者用另一台机器跑过,里面 label 到 id 的顺序就变了。模型输出的 id 和 maps.pkl 里解出来的标签名对不上,结果自然全错。
解决:训练和预测前后确保maps.pkl是同一个文件。我每次重训前会删掉旧的 maps.pkl 让它重新生成,预测前再打印一遍标签映射确认 B-PER 对应 id 是 1,I-PER 是 2,而不是靠猜。
5.4 号牌和金额实体被切成碎段
现象:“辽B12345”被识别成“辽”“B”“12345”三段,其中只有一段命中了标签。
原因:BERT 的 tokenizer 遇到字母数字会切子词,而我们的标签对齐逻辑里后续子词补了 O,导致实体标签在中间断掉。中文文本这问题不严重,一旦涉及车牌号、金额、身份证号,错误率明显飙升。
解决:在预处理里加入正则,把[A-Za-z0-9]+整体作为一个 token 占位,不进 BERT 子词切分。具体做法是用正则先匹配号牌模式,替换成特殊占位符,识别完成后再映射回原文本。这部分逻辑需要自己补,源码里没有现成处理,属于法律文书场景里必须动手改的地方。
5.5 conlleval.py 报错或者结果恒为 0
现象:跑完测试集,用 conlleval.py 计算指标,输出全是 0,或者报格式错误。
原因:conlleval.py 期望输入是三列用空格分隔的文本,每行是 token、gold 标签、pred 标签,而且文件末尾不能有空行。很多人直接把预测结果导成 CSV 丢给它,格式对不上。
解决:写一个转换函数,把预测结果统一导出成token gold pred一行一条,标签统一用 BIO 格式,然后:
python conlleval.py < result.txt跑完会输出整体 Precision、Recall、F1,以及每个类别的分项指标。如果只关心事件要素抽取质量,重点看 B-PLATE 和 B-DUTY 这两个 F1,它们最容易暴露边界问题。
5.6 ELECTRA 权重换不上去
现象:按 download_electra.py 下载权重,替换 BERT 目录后直接from_pretrained报结构不匹配。
原因:ELECTRA 和 BERT 虽然都是 transformer 底座,但 embedding 层和 attention 的权重命名有差异,需要转换脚本把 ELECTRA 的 checkpoint 重命名。直接替换目录是行不通的。
解决:想实验底座替换的话,先用load_pretrain_test.py跑通 BERT 版本,再单独写一个 ELECTRA 的权重映射脚本。不要一上来就换底座,先把管线跑通,再谈效果优化。
6. 推理与验证:predict.py 的用法和 conlleval 指标怎么读
模型训练完,真正要面对的是整篇文书的抽取。把预测流程做成标准步骤:先分句,再逐句 predict,最后合并实体字段。predict.py输入是一行文本,输出是对应的标签序列。
import torch from model import BertBiLSTMCRF from transformers import BertTokenizer model = BertBiLSTMCRF(bert_dir='bertNER_legal_pretrained', num_labels=len(label2id)) model.load_state_dict(torch.load('best_model.pt')) model.eval() text = '2021年3月15日,被告人张某驾驶辽B12345号小型轿车行驶至某某高速28公里处' tokens = list(text) ids = tokenizer.encode(tokens, add_special_tokens=True) with torch.no_grad(): logits = model(input_ids=torch.tensor([ids]))[0]这段代码里logits是 CRF 解码后的预测序列,[0]表示 batch 里第一条。注意load_state_dict之前必须保证model.py里的num_labels和训练时的 m 一致,否则权重文件会报 mismatch。推理时把 dropout 关掉,模型切到 eval 模式。
conlleval.py 输出的指标里,我最关注的是按实体类别分项的 F1。交通肇事案里 B-PER 通常最高,因为人名在文中反复出现且边界清晰;B-CASUALTY 往往偏低,因为“一人死亡、三人受伤”这种表达里数字和名词穿插,边界容易标乱。如果你跑出来的分项显示 B-CASUALTY 只有 70% 出头,而其他项都在 85%,那说明数据标注里后果类实体的标注标准不一致,该回去修标注,而不是继续调参。
验证完成后,把分句迭代合并的代码固定成自己的工具函数。我一般把整篇文书切成句后,记录每个实体在原始文本里的字符偏移,最后按偏移排序合并,塞回原文位置。这样输出的结构可以直接对接后续的事件抽取或案件要素表,不会因为分句丢失跨句信息。
这套代码值得下,是因为它提供了一个完整可跑的法律 BERT 微调基线。但你拿到的只是一个起点,号牌处理、分句边界、maps.pkl 一致性这三个点,是任何真实法律文书场景里绕不过去的改造项。从那以后,我每次拿到一份 NER 源码,第一步永远先看 maps.pkl 和数据的标签顺序对不对,再跑最小样例,最后才动完整训练。先验证再训练这个习惯,帮我避免了好几轮浪费算力的无效迭代,希望帮到你。
本文还有配套的精品资源,点击获取