简介:本资源面向计算机、人工智能、数据科学等专业学生及企业开发者,提供一套基于BERT+BILSTM+CRF的中文命名实体识别完整项目源码,适合作为毕业设计、课程设计或大作业的实战参考,也可用于初期项目立项演示。压缩包共58个文件,约13.75MB,包含16个Python源码文件、19个编译缓存文件、9个文本数据、5张效果图、4份Markdown说明文档及XML配置等,覆盖模型定义、数据预处理、训练脚本与说明文档等模块。项目整合了BERT预训练语言模型、双向LSTM与CRF层,并附带人民日报、MSRA等中文NER数据集及预处理代码,目录中可见BERT_BILSTM_CRF、BILSTM_CRF、IDCNN_CRF等多种模型实现,便于对比学习。目前已有1209人学习下载,读者可据此掌握从数据清洗、词表构建到模型训练与评估的完整流程,积累序列标注任务的排错与调参经验。
1. 中文 NER 为什么总在「人名边界」上翻车:从 BERT+BILSTM+CRF 这套组合说起
做中文命名实体识别(NER)的工程师,大概率都经历过这样的场景:模型在测试集上 F1 刷到 0.95,一上真实业务文本,人名被切成了两半,「张伟明」识别成「张伟」,「欧阳娜娜」只认出「娜娜」。这不是模型不行,而是中文 NER 的边界判定本身就是个硬骨头——没有空格分词,实体嵌套、简称、别名混在一起,单靠字向量分类根本压不住标签之间的依赖关系。
BERT+BILSTM+CRF 这套组合,就是目前工业界落地中文 NER 最稳的基线方案之一。BERT 负责把每个字编码成带上下文的向量,BILSTM 捕捉字符级序列的前后依赖,CRF 层则在输出端约束标签转移的合法性——比如「B-PER」后面不该直接跟「B-LOC」。三者叠加,既解决了「字的多义性」,又解决了「标签乱序」的问题。这套方案适合谁?适合手上有几千到几万条标注数据、想快速搭一个可用的中文实体抽取服务、又不想从零训语言模型的团队。Python 生态下,HuggingFace Transformers 加 torchcrf 就能跑通全流程,不需要自己写底层算子。
接下来我会按「数据怎么准备 → 模型怎么搭 → 训练怎么调 → 坑在哪 → 怎么验证」的顺序,把这条链路拆开讲清楚。你照着做,能复现一个可用的中文 NER 模型;你踩过的坑,我大概率也踩过。
2. 数据准备与标签体系:BIO 标注的四个实操细节
2.1 中文 NER 的数据从哪来、怎么清洗
中文 NER 的数据来源无非几类:公开数据集(MSRA、OntoNotes、Weibo NER)、业务日志抽取、人工标注平台产出。公开数据集适合验证模型结构,业务数据才是真正要落地的。我一般会先把原始文本做一轮清洗:去掉 HTML 标签、统一全半角、处理连续空白符、过滤超长文本(超过 512 字的先截断或分段)。这一步不做,后面 BERT 的 tokenizer 会给你一堆意外。
清洗完的文本要转成「字 + 标签」的序列格式。中文 NER 通常按字切分,不按词。原因很简单:分词器本身会引入误差,而 BERT 的 tokenizer 对中文是按字或子词切的,按字标注能和 tokenizer 对齐,减少后续对齐的麻烦。
import re def clean_text(text): # 去掉 HTML 标签 text = re.sub(r'<[^>]+>', '', text) # 全角转半角 text = ''.join([chr(ord(ch) - 0xFEE0) if 0xFF01 <= ord(ch) <= 0xFF5E else ch for ch in text]) # 合并连续空白 text = re.sub(r'\s+', ' ', text).strip() return text # 示例 raw = "<p>张三于2023年加入北京字节跳动。</p>" print(clean_text(raw)) # 输出: 张三于2023年加入北京字节跳动。这段代码做了三件事:剥离 HTML、全角转半角、压缩空白。参数上没什么可调的,但顺序有讲究——先剥标签再转半角,否则标签里的全角字符会被误转。清洗后的文本按字拆开,每个字对应一个标签,就得到了训练所需的序列。
2.2 BIO 标签体系怎么定:别在 B 和 I 上省事
BIO 是最常用的标注体系:B-XXX 表示实体开头,I-XXX 表示实体内部,O 表示非实体。中文 NER 常见的实体类型包括 PER(人名)、LOC(地点)、ORG(机构)、MISC(其他专名)。有些团队为了省事,只用 B 和 O,不用 I,结果就是「张三」和「张三四」在标签上没区别,模型学不到边界。
我一般会坚持用完整的 BIO,并且在数据预处理阶段做一次标签合法性校验:B 后面必须跟同类型的 I 或 O,不能出现 B-PER 后面直接跟 I-LOC 这种非法转移。这个校验在训练前做,能提前发现标注错误,比训练完再回头查省事得多。
def validate_bio(labels): # 检查 BIO 序列合法性 for i in range(1, len(labels)): prev, curr = labels[i-1], labels[i] if curr.startswith('I-'): entity_type = curr[2:] if not (prev == f'B-{entity_type}' or prev == f'I-{entity_type}'): return False, f"位置 {i}: {prev} -> {curr} 非法转移" return True, "合法" labels = ['B-PER', 'I-PER', 'O', 'B-LOC', 'I-LOC'] print(validate_bio(labels)) # (True, '合法')这个校验函数逻辑很直白:遍历标签序列,遇到 I-XXX 就检查前一个标签是不是同类型的 B 或 I。如果不是,说明标注有问题。实际项目中,我见过不少标注团队把「北京市朝阳区」标成 B-LOC I-LOC B-LOC I-LOC,这种嵌套实体在 BIO 体系下需要拆成两个独立实体,或者改用 BILOU 体系。选哪种取决于你的业务需求,但一定要在标注规范里写清楚。
2.3 标签映射表与数据格式转换
标签要转成数字 ID 才能喂给模型。我一般会维护一个 label2id 字典,把 O 设为 0,其余按顺序编号。注意:CRF 层对标签顺序敏感,虽然理论上顺序不影响最终效果,但固定顺序能让实验可复现。
labels = ['O', 'B-PER', 'I-PER', 'B-LOC', 'I-LOC', 'B-ORG', 'I-ORG'] label2id = {label: idx for idx, label in enumerate(labels)} id2label = {idx: label for label, idx in label2id.items()} print(label2id) # {'O': 0, 'B-PER': 1, 'I-PER': 2, 'B-LOC': 3, 'I-LOC': 4, 'B-ORG': 5, 'I-ORG': 6}数据格式上,我习惯把每条样本存成{"text": "张三在北京", "labels": ["B-PER", "I-PER", "O", "B-LOC", "I-LOC"]}的 JSON 行格式。这种格式易读易改,加载时用jsonlines库逐行读,内存友好。如果数据量特别大(百万级以上),可以转成 TFRecord 或 Arrow 格式,但中小规模项目没必要。
2.4 训练集/验证集/测试集的划分陷阱
划分数据集时,最常见的坑是按样本随机切分,导致同一个实体的不同出现被分到训练集和测试集,验证指标虚高。正确做法是按实体或按文档切分:同一个文档里的所有句子要么全在训练集,要么全在测试集。如果做不到按文档切,至少保证同一个实体的不同表述不跨集。
我一般用 8:1:1 的比例,但会先按文档 ID 分组,再在组级别做切分。这样验证集上的 F1 更接近真实上线表现。另外,验证集和测试集的标签分布要尽量一致,如果测试集里 ORG 特别多而验证集里几乎没有,调参时就会跑偏。
3. BERT+BILSTM+CRF 模型搭建:从 tokenizer 到 CRF 层的完整代码
3.1 BERT 输出怎么接 BILSTM:维度对齐与 dropout 位置
BERT 的输出是[batch_size, seq_len, hidden_size],BILSTM 要求输入是[batch_size, seq_len, input_size],所以 hidden_size 必须等于 BILSTM 的 input_size。BERT-base 中文模型的 hidden_size 是 768,BILSTM 的 hidden_dim 我一般设 128 或 256,双向拼接后输出维度是2 * hidden_dim。
dropout 放哪?我的经验是放两处:BERT 输出后放一层(0.1~0.3),BILSTM 输出后、CRF 之前再放一层(0.1~0.3)。第一层防止 BERT 过拟合,第二层防止 BILSTM 过拟合。如果数据量小于 5000 条,dropout 可以调到 0.3~0.5;数据量上万,0.1~0.2 就够了。
import torch import torch.nn as nn from transformers import BertModel class BertBiLSTMCRF(nn.Module): def __init__(self, bert_path, num_labels, lstm_hidden=128, dropout=0.2): super().__init__() self.bert = BertModel.from_pretrained(bert_path) self.dropout1 = nn.Dropout(dropout) self.bilstm = nn.LSTM( input_size=768, hidden_size=lstm_hidden, num_layers=1, bidirectional=True, batch_first=True ) self.dropout2 = nn.Dropout(dropout) self.classifier = nn.Linear(lstm_hidden * 2, num_labels) self.crf = CRF(num_labels, batch_first=True) def forward(self, input_ids, attention_mask, labels=None): outputs = self.bert(input_ids, attention_mask=attention_mask) sequence_output = outputs.last_hidden_state # [B, L, 768] sequence_output = self.dropout1(sequence_output) lstm_output, _ = self.bilstm(sequence_output) # [B, L, 2*hidden] lstm_output = self.dropout2(lstm_output) emissions = self.classifier(lstm_output) # [B, L, num_labels] if labels is not None: loss = -self.crf(emissions, labels, mask=attention_mask.bool()) return loss else: return self.crf.decode(emissions, mask=attention_mask.bool())这段代码里,CRF来自torchcrf库,需要pip install torchcrf。注意mask参数:CRF 计算损失时要传入 attention_mask,把 padding 位置排除掉,否则 padding 的标签会干扰转移矩阵的学习。batch_first=True在 LSTM 和 CRF 里都要设,保持一致。
3.2 CRF 层的转移矩阵:为什么它比 softmax 强
不用 CRF,直接对 BILSTM 输出做 softmax 分类,每个位置独立预测标签。问题在于:标签之间有依赖关系,比如「B-PER」后面跟「I-PER」是合理的,跟「I-LOC」就不合理。softmax 不管这些,它只看当前字的发射分数。CRF 则引入一个转移矩阵[num_labels, num_labels],学习标签之间的转移概率,解码时用 Viterbi 算法找全局最优路径。
转移矩阵是 CRF 的核心参数,初始化时通常设成对角占优(自己转移到自己概率高),训练中自动调整。torchcrf库已经封装好了这些,你只需要传 emissions 和 labels 就行。但要注意:CRF 的损失函数是负对数似然,数值上可能不稳定,训练时如果 loss 突然变成 nan,先检查学习率是不是太大,或者 emissions 里有没有 inf。
3.3 完整训练循环:优化器、学习率与梯度裁剪
BERT 微调的学习率要小,一般 2e-5 到 5e-5。BILSTM 和 CRF 是随机初始化的,学习率可以大一点,1e-3 到 1e-2。我一般用分层学习率:BERT 部分 2e-5,其余部分 1e-3。优化器用 AdamW,权重衰减设 0.01。
from transformers import AdamW, get_linear_schedule_with_warmup def train(model, train_loader, val_loader, epochs=5, bert_lr=2e-5, other_lr=1e-3): # 分层学习率 bert_params = list(model.bert.named_parameters()) other_params = [(n, p) for n, p in model.named_parameters() if not n.startswith('bert')] optimizer = AdamW([ {'params': [p for _, p in bert_params], 'lr': bert_lr}, {'params': [p for _, p in other_params], 'lr': other_lr} ], weight_decay=0.01) total_steps = len(train_loader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps ) for epoch in range(epochs): model.train() total_loss = 0 for batch in train_loader: input_ids = batch['input_ids'].cuda() attention_mask = batch['attention_mask'].cuda() labels = batch['labels'].cuda() loss = model(input_ids, attention_mask, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss += loss.item() print(f"Epoch {epoch+1}, Loss: {total_loss / len(train_loader):.4f}")梯度裁剪max_norm=1.0是必须的,BILSTM 和 CRF 的梯度容易爆炸,不裁剪的话 loss 可能几轮后就飞了。warmup 设总步数的 10%,让学习率从 0 线性升到设定值,再线性衰减。这套配置在几千条数据上跑 5 到 10 轮,基本能收敛。
3.4 批次构造与动态 padding
中文句子长度差异大,短句 10 个字,长句 200 个字。如果按最大长度 padding,短句浪费大量计算。我一般用动态 padding:每个 batch 内按最长句子 padding,不同 batch 长度不同。HuggingFace 的DataCollatorForTokenClassification已经支持这种模式,直接拿来用就行。
from transformers import DataCollatorForTokenClassification collator = DataCollatorForTokenClassification( tokenizer=tokenizer, padding=True, pad_to_multiple_of=8 # 对齐到 8 的倍数,GPU 友好 )pad_to_multiple_of=8是个小技巧,让序列长度对齐到 8 的倍数,GPU 计算时更高效。标签的 padding 值要设成 -100 或忽略索引,CRF 计算损失时会自动跳过。如果自己写 collator,记得把 padding 位置的标签设成 0(对应 O),但 mask 里要标成 0,让 CRF 忽略。
4. 训练调参与排错:中文 NER 的五个血泪坑
4.1 坑一:BERT tokenizer 把字切碎了,标签对不上
现象:用bert-base-chinese的 tokenizer 处理文本,发现有些字被切成了子词,比如「尴尬」变成「尴」和「尬」两个 token,但你的标签是按字标的,长度对不上。
原因:BERT 中文 tokenizer 虽然大部分情况按字切,但遇到生僻字或特殊符号时,会退化成按 subword 切。如果你的标注是按字来的,tokenizer 输出长度可能和标签长度不一致。
解决:用tokenizer(text, add_special_tokens=False)先看切分结果,如果长度和标签不一致,要么改用按 token 标注,要么在 tokenizer 里加do_basic_tokenize=False强制按字切。我一般会在数据预处理阶段加一层校验:tokenizer 输出长度必须等于标签长度,不等就报错,提前发现。
4.2 坑二:CRF 的 mask 没传对,padding 位置污染转移矩阵
现象:训练 loss 正常下降,但验证集 F1 很低,解码结果里 padding 位置出现奇怪标签。
原因:CRF 计算损失时,如果没传 mask,padding 位置的标签(通常是 0,对应 O)也会参与转移矩阵的学习,导致模型在真实边界上判断不准。
解决:crf(emissions, labels, mask=attention_mask.bool()),mask 里 1 表示真实 token,0 表示 padding。torchcrf的decode方法也要传 mask,否则解码时会输出 padding 位置的标签。这个坑我踩过两次,每次都是 loss 看着正常但指标上不去,查半天才发现是 mask 的问题。
4.3 坑三:学习率太大,BERT 微调直接崩
现象:第一轮 loss 就变成 nan,或者 loss 先降后升,剧烈震荡。
原因:BERT 预训练权重已经很好,微调时学习率太大会破坏原有参数。有人直接用 1e-3 训 BERT,不崩才怪。
解决:BERT 部分学习率控制在 2e-5 到 5e-5,BILSTM 和 CRF 部分可以到 1e-3。用分层学习率,别一刀切。如果还是崩,先冻结 BERT 训几轮 BILSTM+CRF,再解冻 BERT 一起微调。另外,梯度裁剪max_norm=1.0一定要加。
4.4 坑四:标签不平衡,O 标签占了 90% 以上
现象:模型把所有字都预测成 O,准确率看着很高(90%+),但 F1 接近 0。
原因:中文 NER 数据里,非实体字(O)通常占 85%~95%,实体字很少。模型学到「全预测 O」就能拿到高准确率,但完全没用。
解决:损失函数里给实体标签加权,或者用 focal loss。torchcrf不直接支持类别权重,但可以在 emissions 上做文章:对 O 标签的发射分数乘一个小于 1 的系数,降低其优势。更简单的做法是过采样含实体的句子,让训练集里实体比例提高到 20%~30%。我一般用后者,实现简单,效果稳定。
4.5 坑五:验证集指标虚高,上线就翻车
现象:验证集 F1 0.95,上线后人工抽检发现准确率不到 0.7。
原因:验证集和测试集同分布,但和真实业务数据分布不一致。或者验证集里实体类型单一,模型没见过复杂场景。
解决:验证集一定要从真实业务数据里抽,别只用公开数据集。另外,做一轮「对抗验证」:训一个分类器区分训练集和验证集,如果分类器准确率远高于 0.5,说明两个分布差异大,需要调整数据。上线前再用一批完全没见过的业务数据做盲测,指标打七折才是真实水平。
5. 模型验证与推理:怎么确认你的 NER 真的能用
5.1 用 seqeval 算 F1:别自己写评估脚本
序列标注的评估不能用普通分类的 accuracy,要用实体级别的 precision/recall/F1。seqeval库是标准工具,直接调就行。
from seqeval.metrics import classification_report, f1_score def evaluate(model, val_loader, id2label): model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for batch in val_loader: input_ids = batch['input_ids'].cuda() attention_mask = batch['attention_mask'].cuda() labels = batch['labels'] preds = model(input_ids, attention_mask) # 把 padding 位置去掉 for pred, label, mask in zip(preds, labels, attention_mask): length = mask.sum().item() pred_labels = [id2label[p] for p in pred[:length]] true_labels = [id2label[l.item()] for l in label[:length]] all_preds.append(pred_labels) all_labels.append(true_labels) print(classification_report(all_labels, all_preds)) return f1_score(all_labels, all_preds)seqeval的classification_report会输出每个实体类型的 P/R/F1,以及整体 micro/macro 平均。注意:传入的标签必须是字符串形式(如B-PER),不能是数字 ID。padding 位置一定要去掉,否则 O 标签会拉高准确率但拉低 F1。
5.2 推理阶段的 batch 处理与后处理
上线推理时,请求是一条条来的,但为了吞吐量,一般会攒一个 batch 再跑。我一般设 batch_size=32,超时 50ms 就强制触发。推理完的输出是标签序列,需要后处理成实体列表:遇到 B-XXX 开始记录,遇到 I-XXX 继续,遇到 O 或不同类型标签就结束当前实体。
def decode_entities(text, labels): entities = [] start = None current_type = None for i, label in enumerate(labels): if label.startswith('B-'): if start is not None: entities.append((start, i, current_type, text[start:i])) start = i current_type = label[2:] elif label.startswith('I-') and start is not None and label[2:] == current_type: continue else: if start is not None: entities.append((start, i, current_type, text[start:i])) start = None current_type = None if start is not None: entities.append((start, len(text), current_type, text[start:])) return entities text = "张三在北京工作" labels = ['B-PER', 'I-PER', 'O', 'B-LOC', 'I-LOC', 'O', 'O'] print(decode_entities(text, labels)) # [(0, 2, 'PER', '张三'), (3, 5, 'LOC', '北京')]这段后处理逻辑处理了实体在句尾结束的情况,以及 B 后面直接跟 O 的情况。实际项目中还要处理嵌套实体和重叠实体,那需要更复杂的解码策略,但 BIO 体系下这套够用了。
5.3 错误分析与迭代方向
验证集 F1 到 0.9 以上后,提升空间主要在错误分析。我一般会把预测错误的样本按类型分类:边界错误(实体多字或少字)、类型错误(PER 识别成 ORG)、漏识别(实体完全没出来)。边界错误通常是 BILSTM 层不够强或 CRF 转移矩阵没学好,可以加 BILSTM 层数或调 CRF 学习率。类型错误往往是训练数据里该类型的样本太少,需要补充标注。漏识别可能是文本太短或实体太罕见,考虑加词典特征或做数据增强。
错误分析不用做太频繁,每轮训练后抽 50~100 条错误样本看看就行。关键是别只看总体 F1,要看每个实体类型的 F1,短板往往在某个特定类型上。
6. 把模型塞进生产环境:量化、导出与一个提速技巧
训练完的模型要上线,绕不开推理速度问题。BERT-base 在 CPU 上单条推理 50~100ms,GPU 上 10~20ms,如果 QPS 要求高,这个速度不够看。我一般会做两件事:动态量化和 ONNX 导出。
动态量化把 BERT 的线性层权重从 float32 降到 int8,模型体积缩小 4 倍,CPU 推理速度提升 2~3 倍,F1 掉不到 0.5 个点。代码很简单:
import torch.quantization model.eval() quantized_model = torch.quantization.quantize_dynamic( model, {torch.nn.Linear}, dtype=torch.qint8 ) torch.save(quantized_model.state_dict(), 'ner_quantized.pt')注意:量化只对nn.Linear层做,LSTM 和 CRF 不动。量化后的模型在 CPU 上跑,GPU 上反而可能变慢,因为 int8 算子 GPU 支持不如 CPU 成熟。
ONNX 导出适合跨平台部署,尤其是用 TensorRT 或 OpenVINO 做加速的场景。导出时要把 CRF 层单独处理,因为 CRF 的 Viterbi 解码不是标准 ONNX 算子。我的做法是:BERT+BILSTM+Linear 导出成 ONNX,CRF 解码用 Python 或 C++ 单独实现。这样推理速度能再提升 30%~50%。
torch.onnx.export( model, (dummy_input_ids, dummy_attention_mask), "ner_bert_bilstm.onnx", input_names=['input_ids', 'attention_mask'], output_names=['emissions'], dynamic_axes={ 'input_ids': {0: 'batch', 1: 'seq_len'}, 'attention_mask': {0: 'batch', 1: 'seq_len'}, 'emissions': {0: 'batch', 1: 'seq_len'} }, opset_version=13 )导出时dynamic_axes必须设,否则 batch 和序列长度被固定,上线后换个长度就报错。opset_version=13对 Transformer 类模型支持较好,低于 11 可能缺算子。
最后一个提速技巧:把 BILSTM 的 hidden_dim 从 256 降到 128,F1 通常只掉 0.2~0.5 个点,但推理速度提升 20% 左右。如果业务对延迟敏感,这个 trade-off 很划算。我现在的习惯是:先训一个 hidden_dim=256 的模型看上限,再训一个 128 的看实际部署效果,两者对比后再决定上线哪个。
这套方案我从头到尾跑过不下十次,每次都有新坑,但整体链路是稳的。数据质量决定上限,模型结构决定下限,调参决定你能不能摸到上限。希望帮到你。
本文还有配套的精品资源,点击获取