☰
中文命名实体识别边界不准?BERT+BILSTM+CRF 从零实战
2026/9/26 12:13:22 网站建设 项目流程

简介:这份资源是面向计算机相关专业学生与项目实战学习者的中文命名实体识别完整项目,采用BERT+BILSTM+CRF经典组合实现,可作为课程设计、期末大作业或NLP入门练手参考。项目由大三学生完成并经导师指导认可,评审得分99分,代码完整可运行,对基础薄弱者同样友好。压缩包共15个文件,以9个py源码文件为核心,搭配4个txt配置与说明、1个md项目文档及gitignore,整体约406KB,结构紧凑、便于快速上手。内容涵盖数据加载、模型定义、训练与预测等模块,并附项目说明与模型文件,读者可据此理解BERT预训练表示、双向LSTM上下文建模与CRF序列标注的协作流程,掌握从数据预处理到实体识别的完整链路。目前已有90人学习,适合需要一份可复现、有评审背书的NLP大作业参考的读者。

1. 中文命名实体识别为什么总在「边界」上翻车

做中文 NLP 的同行大概率都遇到过这种场景:用 BERT 微调一个序列标注模型,实体类型识别得七七八八,但一到实体边界就开始玄学——「北京大学第三医院」被切成「北京大学」和「第三医院」两个 ORG,「2023 年 5 月」的日期只标出「2023 年」。这不是模型不够大,而是中文命名实体识别(NER)的边界判定天然依赖标签之间的转移约束,而单纯的 BERT+Softmax 逐 token 独立分类,恰恰丢掉了这层约束。

BERT+BILSTM+CRF 这套组合就是冲着这个问题来的:BERT 负责把字/词映射成带上下文语义的向量,BILSTM 进一步捕捉长距离双向依赖,CRF 层则在输出端强制标签序列的合法性(比如 I-ORG 不能直接跟在 B-PER 后面)。三者叠加,是中文 NER 里最经典、也最容易复现的工程方案。这篇文章面向想用 Python 从零跑通这套模型的人——不管你是刚配好 vscode python 环境的新手,还是想换掉线上规则系统的老手,都能照着把数据、模型、训练、推理这条链路走完,并且知道每一步的参数为什么这么设、坑在哪。

2. BERT+BILSTM+CRF 的选型逻辑与数据准备

2.1 为什么是这三层,而不是只用 BERT

先说清楚每一层解决什么问题,选型才不是拍脑袋。

BERT 的输出是每个 token 的上下文向量,但它本质是个特征提取器,做序列标注时通常直接接一个线性层 + Softmax,对每个位置独立预测标签。这种「逐点分类」的致命伤是:它不知道标签之间的转移是否合法。中文 NER 用 BIO 标注体系时,合法序列有硬约束——I-X 前面必须是 B-X 或 I-X,O 后面不能直接接 I-X。逐点分类会产出大量非法序列,边界自然乱。

BILSTM 加在 BERT 之上,作用是再叠一层双向序列建模。有人会问:BERT 本身不就是双向的吗?是的,但 BERT 的注意力是全局的,BILSTM 提供的是局部邻域内的顺序归纳偏置,对实体边界的连续性有额外增益,尤其在实体较长、跨词边界时更明显。实践中加 BILSTM 通常能带来 0.5~1.5 个点的 F1 提升,代价是训练慢一些。

CRF 是最后一道保险。它把标签序列当成一个整体来打分,学习一个转移矩阵,解码时用 Viterbi 算法找全局最优路径。这样非法转移的概率会被压到极低,边界一致性显著改善。这三层的分工可以概括成:BERT 管「这个词是什么」,BILSTM 管「前后文怎么连」,CRF 管「标签怎么排才合法」。

2.2 数据格式与标注体系:BIO 还是 BMES

中文 NER 最常用两种标注体系:

体系标签示例适用场景边界精度
BIOB-PER, I-PER, O通用,实体较短中等
BMESB-PER, M-PER, E-PER, S-PER实体较长、需精确边界较高

BIO 简单,但单个字的实体只能标成 B-X,无法区分「单字实体」和「多字实体开头」。BMES 用 B(开始)、M(中间)、E(结束)、S(单字)四类,边界信息更完整,代价是标签数翻倍、数据量要求更高。我一般建议:实体平均长度小于 3 字用 BIO,大于 3 字或对边界要求苛刻(如医疗、法律)用 BMES。

数据文件通常是一行一个字加标签,空行分隔句子,形如:

北 B-ORG 京 I-ORG 大 I-ORG 学 I-ORG 位 O 于 O 海 O 淀 O

2.3 用 Python 把原始语料转成模型输入

假设你手里是「句子\t实体列表」的原始格式,需要转成上面的逐字标注。下面这段脚本做三件事:按实体跨度打标签、处理重叠实体、输出 BIO 格式。

# convert_to_bio.py # 将 "句子\t实体1:类型1,实体2:类型2" 转为逐字 BIO 标注 import re def char_tokenize(sentence): """中文按字切分,保留原始字符顺序""" return list(sentence) def span_to_bio(sentence, entities): """ sentence: 原始句子字符串 entities: [(start, end, label), ...] start/end 为字符索引,左闭右开 返回: [(char, tag), ...] """ chars = char_tokenize(sentence) tags = ['O'] * len(chars) # 按起始位置排序,先处理靠前的实体 for start, end, label in sorted(entities, key=lambda x: x[0]): if start < 0 or end > len(chars) or start >= end: continue # 越界实体直接丢弃,避免污染训练集 # 检查是否与已标注区域重叠 if any(tags[i] != 'O' for i in range(start, end)): continue # 重叠实体跳过,中文 NER 一般不处理嵌套 tags[start] = f'B-{label}' for i in range(start + 1, end): tags[i] = f'I-{label}' return list(zip(chars, tags)) def write_bio_file(samples, out_path): """samples: [(sentence, entities), ...]""" with open(out_path, 'w', encoding='utf-8') as f: for sent, ents in samples: for ch, tag in span_to_bio(sent, ents): f.write(f'{ch} {tag}\n') f.write('\n') # 空行分隔句子 if __name__ == '__main__': demo = [("北京大学位于海淀区", [(0, 4, 'ORG'), (6, 9, 'LOC')])] write_bio_file(demo, 'train.bio')

逻辑说明:span_to_bio先给整句打上 O,再按实体跨度覆盖成 B/I。重叠检测那一步很关键——中文语料里经常出现「北京大学」和「北京」同时被标为实体的情况,如果不跳过,后写的标签会覆盖前面的,导致标注错乱。参数上,start/end用字符索引而非字节索引,中文一个字算一个位置,别用len(sentence.encode())去算,那是字节数,会错位。

提示:转换完一定要抽样人工核对 20~30 句,尤其是含数字、英文、标点的句子,分词和索引最容易在这里翻车。

3. 模型搭建:BERT 输出怎么接 BILSTM 再接 CRF

3.1 三个模块的接口对齐

搭模型最容易出错的地方不是某一层写错,而是张量形状对不上。先把数据流理清楚:

  • BERT 输入input_ids形状[batch, seq_len],输出last_hidden_state形状[batch, seq_len, hidden],其中 hidden 对 base 模型是 768。
  • BILSTM 接收[batch, seq_len, hidden],输出[batch, seq_len, 2*lstm_hidden]。注意 BILSTM 默认batch_first=False,必须显式设batch_first=True,否则维度和 BERT 对不上,这是新手最常见的翻车点。
  • 线性层把2*lstm_hidden映射到num_tags,得到发射分数emissions,形状[batch, seq_len, num_tags]。
  • CRF 层接收emissions和tags,训练时算负对数似然,推理时用 Viterbi 解码。

3.2 完整模型代码与关键参数

# model.py import torch import torch.nn as nn from transformers import BertModel from torchcrf import CRF # pip install pytorch-crf class BertBiLstmCrf(nn.Module): def __init__(self, bert_path, num_tags, lstm_hidden=256, dropout=0.3): super().__init__() self.bert = BertModel.from_pretrained(bert_path) hidden = self.bert.config.hidden_size # base 为 768 self.bilstm = nn.LSTM( input_size=hidden, hidden_size=lstm_hidden, num_layers=1, batch_first=True, # 必须为 True,与 BERT 输出对齐 bidirectional=True ) self.dropout = nn.Dropout(dropout) # 双向输出拼接后维度为 2*lstm_hidden self.classifier = nn.Linear(lstm_hidden * 2, num_tags) self.crf = CRF(num_tags, batch_first=True) def forward(self, input_ids, attention_mask, tags=None): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) seq_out = outputs.last_hidden_state # [B, L, 768] seq_out, _ = self.bilstm(seq_out) # [B, L, 2*H] seq_out = self.dropout(seq_out) emissions = self.classifier(seq_out) # [B, L, num_tags] # CRF 需要 mask 屏蔽 padding 位置 mask = attention_mask.bool() if tags is not None: # 训练:返回负对数似然,取负号做 loss loss = -self.crf(emissions, tags, mask=mask, reduction='mean') return loss else: # 推理:Viterbi 解码,返回每条序列的标签列表 return self.crf.decode(emissions, mask=mask)

逻辑说明:batch_first=True在 LSTM 和 CRF 上都要设,CRF 的batch_first参数决定它按[B, L, T]还是[L, B, T]解释输入,和 LSTM 不一致会直接报维度错误。mask用attention_mask转 bool,作用是让 CRF 在算转移分数和解码时忽略 padding 位置——如果不传 mask,padding 的标签会参与转移计算,模型会学到一堆无意义的转移,验证集 F1 会莫名偏低。

参数说明:lstm_hidden=256是经验值,双向拼接后 512,再映射到标签数;显存紧张可以降到 128。dropout=0.3在 BERT 微调场景下比较稳,太高会欠拟合,太低容易过拟合小数据集。num_layers=1足够,堆到 2 层收益很小还容易梯度问题。

3.3 标签对齐与 loss 计算的两个细节

第一个细节:tags里 padding 位置要填一个合法标签索引(通常填 0 对应的 O),不能填 -100。因为 CRF 的 loss 计算依赖完整标签序列,-100 会让torchcrf报索引越界。正确做法是在 collate 阶段把 padding 的标签设成 O 的 id,同时靠 mask 屏蔽掉。

第二个细节:reduction='mean'是对 batch 内所有有效 token 求平均,如果你的 batch 里句子长度差异极大,建议改成'sum'再手动除以有效 token 数,避免短句被长句稀释。这个改动对 F1 的影响在小数据集上能到 1 个点以上。

4. 训练、评估与推理的完整链路

4.1 数据集类与动态 padding

中文句子长度差异大,固定 padding 到 128 会浪费大量算力。用collate_fn做 batch 内动态 padding:

# dataset.py import torch from torch.utils.data import Dataset class NerDataset(Dataset): def __init__(self, samples, tokenizer, tag2id, max_len=128): self.samples = samples # [(chars, tags), ...] self.tokenizer = tokenizer self.tag2id = tag2id self.max_len = max_len def __len__(self): return len(self.samples) def __getitem__(self, idx): chars, tags = self.samples[idx] # 中文按字输入,is_split_into_words=True 让 BERT 按字处理 enc = self.tokenizer( chars, is_split_into_words=True, truncation=True, max_length=self.max_len, padding=False, return_tensors=None ) label_ids = [self.tag2id[t] for t in tags][:self.max_len] return { 'input_ids': enc['input_ids'], 'attention_mask': enc['attention_mask'], 'labels': label_ids } def collate_fn(batch): max_len = max(len(x['input_ids']) for x in batch) input_ids, attn, labels = [], [], [] for x in batch: pad = max_len - len(x['input_ids']) input_ids.append(x['input_ids'] + [0] * pad) attn.append(x['attention_mask'] + [0] * pad) # padding 标签填 O 的 id(假设 O 的 id 为 0),靠 mask 屏蔽 labels.append(x['labels'] + [0] * pad) return { 'input_ids': torch.tensor(input_ids, dtype=torch.long), 'attention_mask': torch.tensor(attn, dtype=torch.long), 'labels': torch.tensor(labels, dtype=torch.long) }

逻辑说明:is_split_into_words=True告诉 tokenizer 输入已经是切好的字序列,不要再做子词切分。中文 BERT 的 vocab 基本按字覆盖,但遇到生僻字或英文数字仍可能被拆成多个 subword,这时labels长度会和input_ids对不上。稳妥做法是用word_ids()做对齐,把同一 word 的多个 subword 只在第一个位置保留标签,其余设成 -100 并在 loss 里忽略。上面代码为简洁假设了字级对齐,实际生产务必加这层校验。

4.2 训练循环与学习率设置

# train.py import torch from torch.optim import AdamW from transformers import BertTokenizer, get_linear_schedule_with_warmup def train(model, loader, epochs=10, lr=2e-5, device='cuda'): model.to(device) # BERT 用较小学习率,LSTM 和分类头可以用大一点 bert_params = list(model.bert.named_parameters()) other_params = [p for n, p in model.named_parameters() if not n.startswith('bert')] optimizer = AdamW([ {'params': [p for _, p in bert_params], 'lr': lr}, {'params': other_params, 'lr': lr * 10} ], weight_decay=0.01) total_steps = len(loader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(total_steps * 0.1), num_training_steps=total_steps ) model.train() for epoch in range(epochs): total_loss = 0 for batch in loader: batch = {k: v.to(device) for k, v in batch.items()} loss = model(batch['input_ids'], batch['attention_mask'], batch['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(loader):.4f}')

逻辑说明:BERT 和随机初始化的 LSTM/分类头对学习率的敏感度不同,前者用 2e-5,后者用 2e-4(即 lr*10),这是微调的标准做法。clip_grad_norm_设 1.0 防止 CRF 层梯度爆炸,中文 NER 训练里梯度爆炸不算罕见,尤其 batch 小的时候。warmup 比例 0.1 是经验值,能避免训练初期 BERT 被大梯度带偏。

4.3 用 seqeval 算实体级 F1

token 级准确率会骗人——把所有字都预测成 O,准确率也能到 90% 以上。必须用实体级指标:

# evaluate.py from seqeval.metrics import classification_report, f1_score def evaluate(model, loader, id2tag, device='cuda'): model.eval() preds, trues = [], [] with torch.no_grad(): for batch in loader: batch = {k: v.to(device) for k, v in batch.items()} pred_tags = model(batch['input_ids'], batch['attention_mask']) for p, t, m in zip(pred_tags, batch['labels'].tolist(), batch['attention_mask'].tolist()): length = sum(m) preds.append([id2tag[i] for i in p[:length]]) trues.append([id2tag[i] for i in t[:length]]) print(classification_report(trues, preds)) return f1_score(trues, preds)

逻辑说明:seqeval按实体整体匹配计算 P/R/F1,B-ORG 和 I-ORG 必须连续且类型一致才算命中。length = sum(m)用 mask 截掉 padding,否则 padding 的 O 会被算进指标,虚高。注意pred_tags来自crf.decode,返回的是 list of list,长度已经是有效长度,但为保险还是按 mask 截一次。

5. 避坑与排查:中文 NER 训练里最常见的 5 个翻车现场

5.1 现象:loss 不降,一直卡在 8 左右

原因:标签 id 和模型输出维度不匹配,或者 padding 标签填了 -100 导致 CRF 计算异常。中文 NER 标签数通常是 2*实体类型数+1(BIO),如果num_tags设错,CRF 转移矩阵维度对不上,loss 会卡在一个固定值。

解决:打印tag2id确认标签总数,检查CRF(num_tags)的入参。padding 标签统一填 O 的 id,靠 mask 屏蔽,不要用 -100。

5.2 现象:验证集 F1 比训练集低 20 个点

原因:数据泄漏或标注不一致。常见的是同一实体在不同句子里标了不同类型,或者训练集和验证集有重复句子。中文语料从网页爬下来时,模板句重复率很高。

解决:训练前用句子哈希去重,跨集合检查重复。标注一致性用脚本扫一遍:统计每个实体字符串对应的标签类型,出现多类型的挑出来人工复核。

5.3 现象:推理时实体边界总是少一个字

原因:BILSTM 的batch_first没设成 True,或者 CRF 的batch_first和 LSTM 不一致,导致序列顺序错乱,模型学到的是错位的转移模式。

解决:两处都显式设batch_first=True,并在 forward 里打印一次emissions.shape,确认是[B, L, num_tags]。顺序错了不会报错,只会静默地让指标变差,这是最阴的坑。

5.4 现象:显存溢出,batch 只能开到 4

原因:BERT base 加 BILSTM 后参数量上去了,加上 CRF 的转移矩阵和 Viterbi 解码的中间张量,显存吃紧。seq_len 设 128 时,batch 16 在 8G 卡上基本跑不动。

解决:开梯度累积模拟大 batch,accum_steps=4配合 batch 4 等效 batch 16;开混合精度torch.cuda.amp,显存能省 30% 左右;seq_len 按数据实际长度分布设,别盲目 128,统计一下 95 分位数。

5.5 现象:换用自己领域的数据后 F1 暴跌

原因:BERT 预训练语料和领域语料分布差异大,直接微调小数据容易灾难性遗忘。医疗、法律、金融领域的实体形态和通用语料差别明显。

解决:先用领域语料做继续预训练(MLM 任务),再微调 NER;或者冻结 BERT 前几层,只训后几层加 BILSTM+CRF。数据量小于 2000 句时,冻结底层往往比全量微调更稳。

6. 把模型压到能上线的三个进阶技巧

跑通训练只是第一步,真正要落地还得解决推理速度和部署问题。分享三个我实际用过的技巧。

技巧一:BERT 层用 ONNX 导出加速。PyTorch 原生推理在 CPU 上很慢,把 BERT 部分导出成 ONNX,用 onnxruntime 跑,CPU 推理能快 2~3 倍。BILSTM 和 CRF 部分保留 PyTorch,因为 CRF 的 Viterbi 解码用 numpy 手写反而更快。导出时注意固定input_ids和attention_mask的动态轴,否则 batch 变化会报错。

技巧二:CRF 解码用 batch Viterbi。torchcrf的 decode 是逐条循环的,batch 大了很慢。可以自己实现向量化的 Viterbi,把转移矩阵和发射分数用广播做,batch 32 时解码耗时能降一半。核心是把[B, L, T]的发射分数和[T, T]的转移矩阵做外积,逐步取 max 并记录回溯指针。

技巧三:小模型蒸馏。如果线上 QPS 要求高,用 BERT-base 的模型蒸馏到 4 层 BERT 或直接用 BiLSTM+CRF(不接 BERT),F1 通常掉 3~5 个点,但推理速度快 5 倍以上。蒸馏时用大模型的软标签(发射分数)做监督,比硬标签效果好。

验证方法上,我习惯在测试集上按实体类型分别看 F1,而不是只看总体。中文 NER 里 PER 通常最高,ORG 和 LOC 因为边界模糊往往低 5~10 个点。如果某个类型特别低,先查标注质量,再考虑加类型特征或调整该类型的 loss 权重。

最后说个血泪教训:别在没做数据去重和标注一致性检查的情况下就调模型结构。我早期有次 F1 死活上不去,换了三种模型都没用,最后发现是训练集里 15% 的句子和验证集重复,模型在背答案。数据干净比模型花哨重要得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询