简介:基于BERT-BILSTM-CRF的中文命名实体识别完整项目,面向自然语言处理学习者、课程设计与期末大作业场景,提供了经过导师指导并获97分评价的高分实现。项目包含可直接运行的Python源码、项目使用说明、标注数据与预训练模型,下载后无需修改即可复现实验。压缩包共18个文件,以Python脚本为主,涵盖模型构建、数据预处理、训练验证与结果评估等模块;另有docx使用手册、Markdown说明、结构示意图及项目配置,整体仅299KB,轻量便于部署。代码文件按功能划分清晰,读者可对照学习。目前已有259人学习浏览。借助该项目可系统掌握BERT与BiLSTM-CRF结合进行中文NER的完整流程,理解数据处理、模型训练、评估脚本与可视化等关键环节,也适合作为课程设计参考或进阶级练习。
1. 中文命名实体识别这件事,为什么绕不开 BERT-BILSTM-CRF
命名实体识别(NER)在中文场景下一直被三个老问题卡脖子:词边界模糊、未登录词多、领域实体长尾。你拿规则或者词典做,换个领域就废;拿纯 BILSTM 做,上下文建模弱,标签跳转的约束全靠后处理;拿纯 CRF 做,又缺乏对句子深层语义的感知。而把 BERT、BILSTM、CRF 串成一条流水线,正好把预训练语义、双向序列特征和标签约束三者各取所长——这也是这个项目标题能在中文 NER 任务里长期保持高频检索的原因。这个 zip 里不是只有一堆 .py,还带了一份能直接跑的数据集和已训练好的模型权重,对一个想从零上手中文 NER 的从业者来说,意味着环境配好、命令一敲、指标就能出来,再在验证集上折腾参数。本文我会按自己的落地习惯,把这个方案从原理到复现、再到踩坑一条路讲透。
2. 从 BERT 到 CRF:三个模块各自解决什么问题,又是怎么拼起来的
2.1 中文输入是怎么一步步变成标签序列的
一条中文句子进模型,先经过 BERT 的 tokenizer 转成 WordPiece 子词序列。中文场景下,BERT 默认的分词粒度是字或者子词,比如“南京市长江大桥”会被切成“南 京 市 长 江 大 桥”这样的 token 序列,每个 token 对应一组向量表示。和英文不同,中文的字本身就是基础单位,所以 BERT 在这条链路里承担的是“词向量 + 上下文语义”的生成器角色。
拿到 BERT 输出的每个 token 的隐藏层向量后,BILSTM 接棒。它把每个 token 的向量分别喂给正向 LSTM 和反向 LSTM,两个方向的输出在每一时刻拼接,形成同时包含前面和后面信息的新向量。这一步的意义在于:BERT 虽然编码能力强,但它的位置编码靠 Transformer 的注意力机制,而 LSTM 对序列顺序的建模方式更天然,能补上 BERT 在局部上下文上的盲区,尤其是面对复杂嵌套实体时,双向信息可以帮助判断一个词到底是地名的一部分还是人名的一部分。
最后一层是 CRF。它不改变每个 token 的发射概率(来自 BILSTM 输出),而是学习标签之间的转移矩阵。比如“B-PER 后面不能直接跟 I-PER 的下一跳 O”这种约束,在 CRF 里被建模成转移分数。CRF 的目标是在给定整条句子的发射分数时,找到一整条最优标签路径,而不是像 Softmax 那样对每个 token 独立选标签。这一条对于中文 NER 特别关键,因为中文实体标签有严格的 BIO/BIOES 结构约束,独立分类很容易产生“B-PER 后面跟 I-LOC”这种组合错误。
2.2 为什么选 BILSTM,不直接上 BERT + CRF
常见做法是先尝试 BERT-CRF 或者纯 BERT + 全连接层,但落地时你会发现几个问题。第一个是 BERT 的序列标注输出维度通常很高,直接接 CRF 或 Softmax,训练慢、调参空间小;BILSTM 相当于做了一次降维和序列特征二次提取,让 CRF 拿到的发射分数更平滑。第二个是 BERT 在大规模预训练后的词表示偏通用,在垂直领域(比如医疗、法律、金融)里,领域实体特征在微调阶段才被激活,单靠 BERT 微调不够,BILSTM 的学习速率和上游 BERT 不同,可以分开设置,让领域信息在 LSTM 层得到强化。
我一般会把 BILSTM 的隐藏层维度设在 128 到 256 之间。维度太小,序列特征提取得不够细;维度太大,CRF 层的参数量会跟着涨,训练时间直接翻倍,而且对准确率的提升边际递减。拿这个项目来说,默认配置通常是 128 维 + 两层 LSTM,如果你只有一张消费级 GPU,这个配置能在数小时到一天内跑完一个中文数据集,效果和 256 维差距不大。
2.3 项目里最关键的三个文件:model.py、train.py、predict.py 的职责划分
一个标准的 BERT-BILSTM-CRF 工程,源码部分通常分成三块,对应三个入口文件。model.py 负责把 BERT 的 huggingface 实现、BILSTM 层和 CRF 层组装成一个 PyTorch Module;train.py 负责加载数据集、构造 DataLoader、定义优化器和评估函数,同时保存最佳模型权重;predict.py 则加载已经保存的 .bin 模型文件,对一条新句子做推理,并输出 BIOES 标签和实体三元组(实体文本、实体类型、出现位置)。你拿到 zip 后,先不要急着改任何参数,先按 README 跑通 train.py 的小样例,再跑 predict.py,这样你能快速确认代码依赖是否正确、数据格式是否符合预期。
3. 跑通项目的最小路径:从解压到看到验证集 F1 的具体操作
3.1 环境准备与依赖安装
项目依赖的核心是 Python 3.7+、PyTorch 1.6+、transformers 4.x、numpy、pandas、scikit-learn。不建议直接在系统全局环境里装,因为 transformers 版本和 pytorch 版本容易出现冲突。常见的做法是创建一个虚拟环境。
python -m venv ner_env source ner_env/bin/activate # Windows 下执行 ner_env\Scripts\activate pip install torch==1.10.0 pip install transformers==4.18.0 pip install numpy pandas scikit-learn说明:这里固定 torch 和 transformers 版本是为了匹配预训练模型的权重文件。如果你用的是新版 transformers,加载老权重时可能出现键名不匹配,要额外转换。装完后跑python -c "import torch; print(torch.__version__)"确认安装成功。
3.2 数据格式与标签体系:BIO 还是 BIOES
中文 NER 项目默认的数据格式一般是一行一个字或词,空行分隔句子,每行格式为“字 + 标签”。以“小明去上海”为例:
小 B-PER 明 I-PER 去 O 上 B-LOC 海 I-LOC这里的标签体系是 BIO。如果项目使用 BIOES 体系,标签会多出 E(End)和 S(Single),比如“李 B-PER 明 E-PER”。BIOES 对边界描述更细,CRF 能利用的转移约束也更多,但数据标注成本高一些。你拿到数据后,先看它的标签集合是什么类型,再和 model.py 里的 label2id 映射对照。常用检查命令如下:
cut -d" " -f2 train.txt | sort -u这条命令会把训练集里所有标签类型打印出来,排查标签不一致的情况。如果项目数据是 JSON 格式,需要先写个小脚本转成 BIO 文本格式,再喂给训练脚本。
3.3 训练命令与参数含义
大多数开源项目会把训练参数集中在 config.py 或者用命令行参数传递。常见的训练启动方式是:
python train.py \ --train_data data/train.txt \ --dev_data data/dev.txt \ --test_data data/test.txt \ --bert_model chinese-bert-wwm-ext \ --batch_size 32 \ --lr 5e-5 \ --crf_lr 0.01 \ --epoch 5 \ --max_len 128 \ --seed 42其中--bert_model指定的是 huggingface 的预训练模型名称,项目里可能已经下载好了本地权重,直接传本地目录即可。--lr是 BERT 部分的学习率,中文微调时代常用值是 2e-5 到 5e-5;--crf_lr是 CRF 和 BILSTM 部分的学习率,通常可以比 BERT 大一个数量级,因为下游层随机初始化,需要更快收敛。--max_len控制句子最大长度,超过 128 的句子会被截断,对于中文 NER,绝大多数句子在 96 字以内,128 足够。
训练结束后,项目会在output/目录下保存model.pt和config.json,其中model.pt是完整模型权重,config.json保存标签映射和模型超参。验证集 F1 的打印日志一般长这样:
eval_loss: 0.0342 precision: 0.9512 recall: 0.9337 f1: 0.9424如果你的 F1 在 0.9 以下,不要急着调模型,先检查数据清洗是否干净、标签是否对齐、有没有全角半角混用。F1 低于 0.8 的情况,八成是数据格式问题,不是模型问题。
3.4 用 predict.py 做推理,并输出实体三元组
predict.py的典型运行方式是输入一句话,输出实体列表。
python predict.py --model_dir output --text "小明昨天去了北京故宫博物院"预期输出会类似这样:
实体: 小明, 类型: PER, 位置: [0, 2) 实体: 北京故宫博物院, 类型: LOC, 位置: [7, 14)这里的[0, 2)表示实体在原始字符串中的起止字符下标,左闭右开。注意位置索引是按字符算的,不是按 token 算的。如果你发现实体位置对不上,多半是 tokenizer 把中文字符切分后产生 offset 偏移,这一点在下面的避坑章节里单独讲。
4. 避坑指南:BERT-BILSTM-CRF 落地时最常翻车的五个环节
4.1 BERT 权重文件加载失败
现象:运行 train.py 时抛出Some weights of the model checkpoint were not used或者KeyError: bert.embeddings.word_embeddings.weight。
原因:项目里下载的 BERT 权重版本和当前 transformers 版本不兼容。早期版本权重键名是bert.embeddings.word_embeddings.weight,新版可能改成了bert.embeddings.token_type_embeddings.weight这类区别。另一个常见情况是本地权重目录缺少config.json,或者模型权重是 TensorFlow 格式的.ckpt文件,而代码只认 PyTorch 的.bin。
解决:先用 transformers 自带脚本转换权重。在项目根目录执行:
python -m transformers.convert_tf_checkpoint_to_pytorch \ --tf_checkpoint_path bert_model/model.ckpt \ --config_file bert_model/config.json \ --pytorch_dump_path bert_model/pytorch_model.bin转换后再把config.json放到同一目录。如果权重已经是 PyTorch 格式但报KeyError,就去 model.py 里打印pretrained_model.state_dict()的键名,和模型初始化时的键名做对比,找出差异。
4.2 tokenizer 和标签序列长度不一致
现象:训练时 loss 突然变成 NaN,或者 DataLoader 报length mismatch,以及 predict 阶段实体偏移。
原因:BERT tokenizer 会把一个中文字切分成子词吗?通常不会,但遇到特殊字符、全角数字、英文单词时,一个 token 可能被切分成多个子词,导致输入序列长度大于原始字符数,而标签序列仍是按字符长度对齐的。如果代码里直接取input_ids长度和 label 长度拼接,就会错位。
解决:好项目会维护一个token_to_original_char_mapping数组,或者在数据预处理阶段就限制输入中不出现英文和数字。你可以自己写一个对齐函数,思路是对每个原始字符,记录其对应 token 的起始位置,然后对标签序列做“每个 token 取所在字符的标签”的映射。我的建议是干脆在预处理阶段把所有非中文字符替换为特殊符号[UNK],避免 tokenizer 切碎它们。
4.3 样本不均衡导致模型倾向预测 O
现象:训练后 precision 很高但 recall 很低,几乎把所有实体都漏掉了。
原因:中文 NER 数据里,O 标签占比通常超过 80%,模型很容易学会“无脑输出 O”来压低整体 loss。F1 上不去,往往是因为损失函数没有对实体类别加权,或者训练轮数太少,实体类别的特征还没充分学出来。
解决:两件事。第一,在计算 loss 时给实体标签加权重,比如pos_weight = 5.0;第二,训练时用早停(early stopping)监控验证集 F1,不要只看 epoch 结束后的 loss。如果你的数据和项目数据分布差异大,还可以在训练脚本里加入class_weight参数,按标签出现频率反比设置权重,实践效果明显。
4.4 训练 OOM(显存溢出)
现象:batch_size 调大后,CUDA out of memory。
原因:BERT 的显存占用随序列长度和 batch_size 平方级增长。中文字符序列 128 长度时,一个 batch 为 32 就可能在 8G 显存卡上溢出,尤其是带 CRF 的反向传播会额外保留中间变量。
解决:先压 batch_size 到 8 或 16,同时打开 gradient accumulation,累积步数等于 4,模拟大 batch。另外可以把--max_len降到 96,多数中文句子 96 个字符已经足够。如果还要省显存,把 BILSTM 的层数从 2 降到 1,F1 只会损失 0.3 到 0.5 个百分点,但显存能降 20%。
4.5 用 GPU 训练但结果比 CPU 差,且每次运行结果不同
现象:同一份数据和参数,GPU 跑出来的 F1 波动超过 1 个百分点,CPU 反而稳定。
原因:PyTorch 的 cuDNN 在卷积和 RNN 里有 atomic 操作,非确定性计算会带来微小浮点误差。CRF 的维特比解码也可能因为并行路径产生不确定选择。这不一定是 bug,但会影响调试体验。
解决:在 train.py 开头设置:
torch.manual_seed(42) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False注意设置 deterministic 后训练速度会下降大概 10%,但换来的是可复现的结果。如果你在不同机器上训练,还要固定PYTHONHASHSEED和numpy.random.seed,否则数据打乱顺序差异也会带来波动。
5. 调参和验证的进阶技巧:把 F1 从 0.90 推到 0.94 的四个动作
当基本模型跑通,开始调优时,优先看验证集 F1 而不是训练集 loss。以下四个动作按性价比从高到低排列。
第一个动作是检查实体边界误差的分布。我习惯把预测结果和真实标签逐条对比,输出误报和漏报的实体案例。比如“原中国工商银行行长”这种长实体,模型容易预测成“中国工商银行”。如果这种边界错误占绝大多数,说明 BILSTM 层对实体边界的记忆不够,可以把 BILSTM 的隐藏层从 128 升到 192,同时把 dropout 从 0.5 降到 0.3,让边界信息更容易流过。
第二个动作是调 CRF 的学习率。很多项目把整个模型用一个 Adam 优化器,导致 CRF 层的转移矩阵学习缓慢。更稳妥的做法是给 BERT 和下游层分别设置优化器。我用下面的方式:
optimizer = torch.optim.AdamW([ {'params': bert.parameters(), 'lr': 5e-5}, {'params': bilstm.parameters(), 'lr': 1e-3}, {'params': crf.parameters(), 'lr': 5e-3}, ])这样 CRF 的转移矩阵能更快学到标签约束,收敛速度明显提升,通常能带来 0.2 到 0.5 个 F1 点的提升。
第三个动作是数据增强。中文 NER 不能像英文那样随便做回译增强,但可以做简单的“同义词替换”和“字符扰动”。替换实体文本中的常见同义字(例如“有限公司”换成“股份有限公司”),前提是不改变实体类型。这个操作对提升模型在长尾实体上的鲁棒性有奇效。你可以用 jieba 分词后,对非实体部分随机替换几个词,训练集扩大 1.5 倍,F1 稳定提升 0.5 到 1 个点。
第四个动作是集成与半监督。项目训练好之后,用它对无标签语料做预测,把置信度高于 0.95 的伪标签样本加入训练集,再训练一轮。这里的置信度可以用 CRF 分数归一化后的概率近似。注意只加实体标签置信度高的样本,否则会把噪声教给模型。这个方法放在最后,因为它依赖前三个动作把基线打牢,否则伪标签错误率太高,回传误差反而伤害模型。
最后分享一个教训:调参前一定要先复现 README 里的基准结果,再动任何超参。我见过不少人在没复现基线的情况下把 BILSTM 维度调大,结果 F1 从 0.92 掉到 0.88,还以为是 BERT 版本问题,实际上只是 dropout 过高导致过拟合。用 predict.py 在 50 条自己写的业务句子上做定性测试,比看单次 F1 更可靠。这套方案的强项是通用领域 NER,如果你要做特定垂直场景,请把领域数据补充到训练集里,并重新微调三轮以上。希望这些踩坑经验能帮你少走一段弯路,祝顺利跑出理想指标。
本文还有配套的精品资源,点击获取