
简介基于BERTBiLSTMCRF架构的法律文书命名实体识别项目面向具备Python和深度学习基础、希望深入序列标注任务的学习者也适用于课程设计、期末大作业及毕设参考。资源共48个文件压缩包仅693KB以21个Python源码脚本为核心覆盖数据预处理、模型构建、训练、预测与评估全流程同时提供config参数配置、train/test/dev数据集、pkl模型文件、README说明、训练日志与结果示意图等目录结构清晰便于按模块查阅和二次开发。已有383人学习下载可直接运行观察交通肇事案事件要素抽取效果也可对照源码钻研预训练模型加载、BiLSTM与CRF序列标注的融合方式以及法律文书中的地名、人名、机构名等实体标注规律。对于希望快速完成同类命名实体识别任务或做毕设扩展的读者是一份兼具参考与实操价值的完整资料拿到后即可按说明准备环境开始项目复现。1. 法律文书命名实体识别为什么拿 BERTBiLSTMCRF 做事件抽取交通肇事案的判决书动辄上千字案发时间、路段、肇事车辆号牌、伤亡后果、责任认定这些要素散落在长句里人工摘录费时且容易漏。换成命名实体识别来做问题就具体成一个序列标注任务给每个字或词打上 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 或 OI 不能出现在句子开头。法律文书的实体标签本身就有强规律比如时间后面常常跟着地点肇事车辆后面大概率出现号牌这种序列层面的约束正是 CRF 的用武之地。class BertBiLSTMCRF(nn.Module): def __init__(self, bert_dir, num_labels, bilstm_hidden128, dropout0.5): super().__init__() self.bert BertModel.from_pretrained(bert_dir) self.bilstm nn.LSTM( input_sizeself.bert.config.hidden_size, hidden_sizebilstm_hidden // 2, batch_firstTrue, bidirectionalTrue ) self.fc nn.Linear(bilstm_hidden, num_labels) self.crf CRF(num_labels, batch_firstTrue)这段结构里最重要的一行是self.crf CRF(num_labels, batch_firstTrue)它决定了整个模型跑的是序列标注而不是纯分类。bilstm_hidden一般取 128 或 256取 128 时每个方向的 LSTM 是 64 维拼接后正好 128。dropout通常设 0.5因为 BERT 本身已经很强再接 dropout 主要用来防 BiLSTM 层过拟合训练集里那些高频句式。2.2 源码文件清单每个 py 是什么角色这份 zip 里的文件没有按功能分目录整理但跑过一遍之后分工很清楚。我按实际使用顺序整理如下文件角色定位model.py定义 BERTBiLSTMCRF 整体网络结构data_utils.py/loader.py读原始标注数据转成 BERT 的 input_ids、attention_mask、token_type_ids 和 label_idsutils.py辅助函数比如标签映射、checkpoint 保存config_file超参数配置文件不装 json 用纯文本或 dict 形式也可以train.py训练入口主版本nerhup.py/nerhup_ori.py训练/预测入口的备份版本区别在标签处理逻辑load_pretrain_test.py加载预训练权重后跑测试集predict.py单条文本推理入口conlleval.py用 conll 格式计算 Precision、Recall、F1maps.pkl标签到 id 的映射字典训练和预测共用的关键文件LTP_NER.py基于哈工大 LTP 的对比 Baselinedownload_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. 数据与标签设计交通肇事案的事件要素怎么进 BERT3.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_len128的 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 torch1.8.0 pip install transformers4.6.0 pip install pytorch-crf0.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 21 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现象训练到第二个 epochloss 从 20 掉到 6 之后突然涨回 15验证集预测结果全是 O。原因学习率太大BERT 部分被冲坏了。BERT 底座参数已经经过大规模预训练学习率超过 1e-4 会让它的权重偏离太远后面再训练很难拉回来。另一个常见原因是warmup_ratio没设模型一开始就用满学习率。解决学习率设成 5e-5warmup_ratio0.1重新训练。损失函数层面还要确认 CRF 用的是负对数似然而不是简单的交叉熵CRF 的 loss 本身就比 Softmax 版要大看起来数值高不一定有问题要结合 F1 一起看。5.2 显存 OOMbatch 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 是 1I-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_dirbertNER_legal_pretrained, num_labelslen(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_tokensTrue) with torch.no_grad(): logits model(input_idstorch.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 和数据的标签顺序对不对再跑最小样例最后才动完整训练。先验证再训练这个习惯帮我避免了好几轮浪费算力的无效迭代希望帮到你。本文还有配套的精品资源点击获取