BERT中文NER实战:从源码到部署的避坑指南
简介:这份资源面向具备一定Python基础、希望入门或进阶自然语言处理的学习者,聚焦如何借助预训练BERT模型完成中文命名实体识别任务,解决中文文本中人名、地名、机构名等实体抽取的实践问题。压缩包共9个文件,约3.72MB,以txt语料与词表、py训练与评估脚本、pl评测工具及md说明文档为主,另含示例图片,覆盖数据、代码与评测环节。已有3346人学习下载,具备一定的参考热度。内容围绕BERT双向编码器结构、MLM与NSP预训练任务、IOB标注体系及Precision/Recall/F1评估指标展开,并涉及Hugging Face Transformers库的模型加载与微调、中文分词与特殊令牌处理、序列填充、AdamW优化器与学习率调度等训练细节,读者可据此理解从数据预处理到模型训练评估的完整链路,并参考脚本组织自己的中文NER实验。
1. 拿到这份 BERT 中文 NER 源码,先搞清楚它能替你省掉哪三件事
如果你正在做中文命名实体识别,大概率经历过这个循环:翻论文找 BERT-BiLSTM-CRF 的结构图,照着图搭模型,然后卡在数据标注格式、字级别 tokenizer 对齐、CRF 转移矩阵的 mask 处理上,一个 epoch 跑完 loss 不降,标签全是 O。这份 Python 项目源码就是冲着这个场景来的——它把预训练语言模型 BERT 接进中文 NER 的完整链路打包好了:数据预处理、模型定义、训练循环、评估指标、推理脚本,一条龙。适合两类人:一是 NLP 方向的学生或转岗开发者,想跑通一个能出 F1 的 baseline;二是工程侧要快速验证「BERT 做实体抽取到底够不够用」的团队。下面按「能干什么 → 怎么跑 → 怎么改」拆开讲。
2. 项目结构与技术选型:为什么是 BERT + BiLSTM + CRF 这套组合
2.1 目录骨架与各文件职责
拿到源码先别急着 pip install,花五分钟把目录扫一遍,后面调 bug 能省一半时间。这类中文 NER 项目的典型结构大致如下(具体文件名以你下载的包为准,但职责划分基本一致):
bert-chinese-ner/ ├── data/ │ ├── train.txt # 训练集,BIO 或 BIOES 标注 │ ├── dev.txt # 验证集 │ └── test.txt # 测试集 ├── pretrained/ │ └── bert-base-chinese/ # 预训练权重 + vocab.txt + config.json ├── config.py # 超参、路径、标签集配置 ├── data_loader.py # 数据集读取、tokenize、对齐、padding ├── model.py # BERT + BiLSTM + CRF 模型定义 ├── train.py # 训练入口 ├── evaluate.py # 评估,输出 P/R/F1 ├── predict.py # 单句/批量推理 └── utils.py # 标签映射、序列截断、指标计算config.py是整个项目的控制面板,学习率、batch size、最大序列长度、标签列表都从这里走。data_loader.py是最容易翻车的地方——中文 NER 的坑八成出在「字级别 tokenize 后标签怎么对齐」上,后面单独讲。model.py里 CRF 层的实现质量直接决定最终 F1,值得逐行读。
2.2 技术栈拆解:BERT 负责编码,BiLSTM 补上下文,CRF 管标签合法性
这套组合不是拍脑袋堆的,每一层解决一个具体问题。BERT 的[CLS]和 token 输出提供的是字级别的上下文向量,但 NER 任务里相邻实体边界往往需要更长的序列依赖,BiLSTM 在 BERT 输出之上再跑一遍双向循环,把前后文信息再融合一次。CRF 则是最后一道保险:它学一个标签转移矩阵,保证「B-ORG 后面不会直接跟 I-PER」这种非法序列不被解码出来。
常见做法是 BERT 输出维度 768,BiLSTM hidden 设 256、双向拼接后 512,接一个线性层映射到标签数,再进 CRF。也有项目直接 BERT + 线性 + CRF 跳过 LSTM,效果差距在小数据集上不明显,但 BiLSTM 对边界模糊的实体更稳一些。源码里如果带了use_lstm开关,建议两种都跑一遍对比。
提示:预训练权重目录里必须有
vocab.txt,BERT 中文模型是按字切分的,vocab 大小约 21128,缺了这个文件 tokenizer 直接报错。
2.3 标签体系:BIO 还是 BIOES,先对齐再谈效果
源码默认大概率是 BIO(B-实体类型 / I-实体类型 / O),简单够用。但 BIOES 多了 E(实体结束)和 S(单字实体),对实体边界的约束更强,CRF 解码时更不容易把两个相邻实体粘成一个。如果你手上的标注数据是 BIOES,而config.py里标签列表写的是 BIO,训练不会报错,但 F1 会莫名其妙低一截——因为标签 id 和模型输出维度对不上,CRF 转移矩阵学了个寂寞。
改之前先确认三件事:config.py的label_list顺序、数据文件里实际出现的标签、utils.py里label2id的构建逻辑,三者必须一致。我一般会在data_loader.py加载数据后加一行断言,把数据里出现过的标签集合和配置里的集合做差集,不为空就直接抛异常,比训练完看指标猜原因高效得多。
3. 跑起来:从环境安装到第一条推理结果
3.1 环境准备与依赖安装
Python 版本建议 3.8 及以上,PyTorch 和 transformers 的版本要匹配。源码如果没有锁版本,我一般这么装:
# 创建虚拟环境,避免和系统包打架 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 核心依赖,torch 按你的 CUDA 版本去官网选对应命令 pip install torch==1.13.1 pip install transformers==4.26.1 pip install seqeval==1.2.2 # 算 P/R/F1 用,比手写靠谱 pip install numpy tqdmseqeval这个包别省,NER 的评估必须按实体级别算,不能按 token 级别算 accuracy——一个句子 90% 是 O 标签,模型全预测 O 也能拿 90% 准确率,但 F1 是 0。transformers版本别太新,4.30 之后部分 API 有变动,老代码里BertConfig.from_pretrained的参数名可能对不上,报错就降版本,这是血泪经验。
3.2 数据格式与预处理链路
训练数据一般是每行「字 + 空格 + 标签」,句子之间空行分隔:
北 B-LOC 京 I-LOC 大 O 学 O 在 O 上 O 海 Odata_loader.py的核心逻辑是把每个字送进 BERT tokenizer 转成 input_ids,同时把标签转成 id 序列。这里有个关键细节:BERT 中文 tokenizer 按字切分,理论上一个字对应一个 token,但[CLS]和[SEP]会占两个位置,标签序列要在首尾各补一个特殊标记(通常补 -100,PyTorch 的 CrossEntropyLoss 会自动忽略)。如果源码里没处理这个对齐,训练 loss 会正常降但评估崩盘。
# data_loader.py 里标签对齐的核心片段(示意) def convert_example_to_features(text, labels, tokenizer, max_len, label2id): tokens = list(text) # 中文按字切 input_ids = tokenizer.convert_tokens_to_ids(tokens) # 首尾加 [CLS] / [SEP],标签位补 -100 input_ids = [tokenizer.cls_token_id] + input_ids + [tokenizer.sep_token_id] label_ids = [-100] + [label2id[l] for l in labels] + [-100] # 截断到 max_len,超长部分直接砍尾 input_ids = input_ids[:max_len] label_ids = label_ids[:max_len] # padding 到 max_len,attention_mask 标记有效位 attention_mask = [1] * len(input_ids) + [0] * (max_len - len(input_ids)) input_ids += [tokenizer.pad_token_id] * (max_len - len(input_ids)) label_ids += [-100] * (max_len - len(label_ids)) return input_ids, attention_mask, label_ids这段代码三个参数要盯住:max_len设 128 还是 256 看你的实体平均长度,太长显存吃不消;-100是 PyTorch 的 ignore_index 默认值,别改成 0,否则 padding 位会被当成 O 标签参与 loss;attention_mask必须和 input_ids 等长,少了它模型会把 padding 当真实输入。
3.3 训练与评估:看 loss 之外还要盯 F1
启动训练一般就一行:
python train.py --config config.py --device cuda:0但别跑上就不管了。前 100 步看 loss 是否在降,如果 loss 卡在 2.3 左右不动,八成是学习率太大(BERT 微调一般 2e-5 到 5e-5)或者标签对齐错了。每个 epoch 结束跑一次evaluate.py,看验证集 F1。正常情况 BERT-base 在中文 NER 公开数据集上 F1 能到 90 上下,如果你的数据领域特殊(医疗、法律),85 左右也算合理。
评估脚本里seqeval的用法要注意classification_report的mode参数,默认是 strict,实体边界错一个字就算错;如果业务上允许边界宽松,可以改mode='lenient',但对比实验时口径要统一。
4. 核心模块拆解:模型定义与 CRF 层的关键实现
4.1 BERT + BiLSTM 的特征提取链路
model.py的forward是整个项目的心脏,逐段看:
class BertBiLstmCrf(nn.Module): def __init__(self, bert_path, num_tags, lstm_hidden=256): super().__init__() self.bert = BertModel.from_pretrained(bert_path) self.lstm = nn.LSTM( input_size=768, # BERT-base 输出维度 hidden_size=lstm_hidden, num_layers=1, bidirectional=True, batch_first=True ) self.fc = 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.lstm(seq_out) # [B, L, 512] emissions = self.fc(lstm_out) # [B, L, num_tags] if labels is not None: # 训练:算负对数似然,mask 掉 padding 位 loss = -self.crf(emissions, labels, mask=attention_mask.bool()) return loss # 推理:维特比解码出最优标签序列 return self.crf.decode(emissions, mask=attention_mask.bool())参数说明:lstm_hidden=256是经验值,数据量小可以降到 128 防过拟合;num_tags必须等于config.py里标签数,多一个少一个 CRF 直接维度报错;mask传的是attention_mask.bool(),这一步漏了的话 padding 位会参与转移计算,解码结果尾部全是乱标签。CRF层建议用pytorch-crf这个库,自己手写转移矩阵容易在 batch 维度和 mask 广播上翻车。
4.2 CRF 解码与损失函数的两个易错点
第一个易错点是crf.decode返回的是 list of list,不是 tensor,后面接label2id反查时别直接当 tensor 索引。第二个是训练时labels里的 -100 必须被 mask 掉,pytorch-crf的mask参数传的是有效位为 True 的布尔张量,和attention_mask语义一致,但如果你自己把 -100 也当有效位传进去,loss 会算出 nan。
注意:如果训练几个 step 后 loss 变 nan,先查 CRF 的 mask 有没有传对,再查学习率是不是超过 1e-4。这两个原因占 nan 问题的九成。
4.3 推理脚本:从单句到批量抽取
predict.py一般提供单句推理,核心是把训练时的预处理链路原样走一遍,再反查标签:
def predict(text, model, tokenizer, id2label, max_len=128): tokens = list(text) input_ids = [tokenizer.cls_token_id] + \ tokenizer.convert_tokens_to_ids(tokens) + \ [tokenizer.sep_token_id] input_ids = input_ids[:max_len] attention_mask = [1] * len(input_ids) # padding 到 max_len pad_len = max_len - len(input_ids) input_ids += [tokenizer.pad_token_id] * pad_len attention_mask += [0] * pad_len # 转 tensor 并加 batch 维 input_ids = torch.tensor([input_ids]).to(device) attention_mask = torch.tensor([attention_mask]).to(device) with torch.no_grad(): tags = model(input_ids, attention_mask)[0] # 去掉 [CLS]/[SEP] 对应的位置,还原到原文字符 tags = tags[1:len(tokens) + 1] return list(zip(tokens, [id2label[t] for t in tags]))这里tags[1:len(tokens)+1]的切片是去掉首尾特殊位,切错了会导致标签整体错位一位,现象是「每个实体都往后偏一个字」,排查时先看这个。批量推理的话把单句拼成 batch,注意按实际长度排序减少 padding 浪费,解码后再按原顺序还原。
5. 避坑与排查:中文 NER 训练里最常见的五个翻车现场
5.1 现象:loss 正常降,但验证集 F1 一直是 0
原因基本锁定在标签对齐。BERT tokenizer 加了[CLS]/[SEP],标签序列没同步补位,导致每个字的标签和 token 错开一位,模型学到的全是错位映射。解决:在data_loader.py里打印第一条样本的input_ids长度和label_ids长度,两者必须相等;再抽查前三个位置,label_ids[0]应该是 -100。
5.2 现象:训练报 CUDA out of memory,batch size 降到 4 还炸
不是 batch 的问题,是max_len设太大。中文 NER 句子平均 50 字以内,max_len=128足够,设 512 显存直接翻四倍。另外 BiLSTM 的 hidden 设 512 也比 256 多吃不少显存。解决:先把max_len降到 128、lstm_hidden降到 128 跑通,再逐步往上加,别一上来就拉满。
5.3 现象:预测结果里实体边界总是多一个字或少一个字
CRF 的转移矩阵没学好,常见于训练数据里 BIO 标签不完整——比如有I-LOC开头但没有对应B-LOC的样本。这种脏数据会让 CRF 学到非法转移。解决:训练前跑一遍数据清洗脚本,检查每个I-X前面是否紧跟B-X或I-X,不满足的样本要么修正要么丢弃。我一般会在utils.py里写个validate_bio()函数,加载数据时强制过一遍。
5.4 现象:换了领域数据后 F1 从 90 掉到 60
预训练权重和目标任务领域不匹配。bert-base-chinese是在通用语料上训的,医疗、金融这类垂直领域术语它没见过。解决:要么用领域语料继续预训练(MLM 任务再跑几轮),要么换领域内公开的预训练模型。继续预训练的成本不低,数据量小于一万条时收益有限,先试小学习率微调(1e-5)看能不能拉回来。
5.5 现象:推理时同一句话跑两次结果不一样
模型没切eval()模式,dropout 还在生效。解决:推理前必须model.eval(),并且包在torch.no_grad()里。这个坑新手常踩,现象很玄学——单条推理结果飘忽,批量推理反而稳定,因为 batch 内统计量被平均了。
6. 二次开发:换标签体系、接业务接口与效果验证的实操路径
拿到这份源码,真正有价值的是把它改成你自己业务能用的东西。第一步换标签体系:假设你要从通用 NER 切到「商品名 / 价格 / 规格」三类实体,改config.py的label_list为["O", "B-PROD", "I-PROD", "B-PRICE", "I-PRICE", "B-SPEC", "I-SPEC"],同步改数据标注,然后确认model.py里num_tags是从len(label_list)动态取的而不是写死的——写死的话改配置不生效,这是二次开发第一个要检查的点。
第二步接业务接口。predict.py现在是命令行跑单句,实际用的时候一般包成 HTTP 服务。我习惯用 FastAPI 包一层,模型在启动时加载一次常驻显存,请求进来只做 tokenize 和前向:
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() model, tokenizer, id2label = load_model_once() # 启动时加载 class Req(BaseModel): text: str @app.post("/ner") def ner(req: Req): pairs = predict(req.text, model, tokenizer, id2label) # 把 B/I 序列合并成实体区间,业务方要的是 {实体: 类型} entities = merge_bio(pairs) return {"entities": entities}merge_bio这个函数源码里不一定有,得自己补:遍历标签序列,遇到B-X开新实体,遇到I-X追加到当前实体,遇到O或类型变化就收口。合并逻辑写错的话,接口返回的实体区间会重叠或断裂,联调时业务方一眼就能看出来。
第三步验证改造效果。别只看整体 F1,按实体类型拆开看seqeval的classification_report,哪类实体 F1 低就针对性补标注数据。我一般会留一个 200 条的人工抽检集,每次改完模型跑一遍,看抽检准确率有没有掉——整体指标涨了但抽检掉了,说明模型在过拟合训练集分布,这种时候宁可回退。
最后说个习惯:每次改config.py或data_loader.py之后,先拿 50 条样本跑一个 mini 训练(1 个 epoch),确认 loss 能降、评估不报错,再上全量。这个习惯帮我省过好几次「跑了一晚上发现标签配错」的后悔药。从那以后我每次动数据预处理代码,都强制先走一遍小样本冒烟测试。希望这份拆解能帮你把 BERT 中文 NER 这条链路顺利跑通,少踩几个我踩过的坑。
本文还有配套的精品资源,点击获取