☰
BERT-BiLSTM-CRF实战:中文命名实体识别从能跑到能用
2026/9/30 1:26:01 网站建设 项目流程

简介:这是一套面向计算机相关专业毕业设计学生与进阶开发者的命名实体识别实战项目,基于PyTorch框架融合BERT预训练模型、双向长短期记忆网络与条件随机场,解决文本序列标注中实体边界识别与标签依赖建模的问题,难度定位中等,适合希望深入理解神经网络与统计模型结合方案的学习者。资源包共34个文件,约4.69MB,以py源码、txt数据、xml配置、ipynb笔记及zbak备份为主,涵盖数据预处理、模型构建、训练优化与评估测试全流程,并附带标准数据集与BERT中文预训练配置。项目采用模块化架构,BERT层负责深度特征提取,BiLSTM捕获上下文语义依赖,CRF实现标签序列全局优化,代码经本地编译测试与多轮调试,运行稳定。目前已有68人学习,适合作为毕业设计参考或技能提升的实战案例。

1. BERT-BiLSTM-CRF 这套组合拳,到底解决了谁的痛

命名实体识别做久了会发现一个尴尬的现实:单纯用 BERT 做序列标注,跑出来的结果看着还行,但一到边界就露馅。比如「北京市朝阳区市场监管局」这种嵌套地名加机构名的组合,BERT 的 token 分类头经常把「朝阳区」和「市场监管局」切成两个不相关的片段,或者干脆把「区」字漏掉。更麻烦的是,标签之间明明有强约束——B -PER 后面不可能直接跟 I-ORG——但独立分类器根本不管这套,它只对每个位置单独打分,输出一串合法但语义荒谬的标签序列是常有的事。

BERT-BiLSTM-CRF 就是冲着这两个问题来的。BERT 负责把字/词映射成带上下文信息的向量,BiLSTM 在这之上再抓一层前后向的序列依赖,CRF 最后兜底,用转移矩阵保证输出的标签序列全局最优。三层各司其职,不是简单堆叠。这套结构在中文 NER 任务上,尤其是人名、地名、机构名三类实体混合的场景,F1 通常比裸 BERT 高 3 到 8 个百分点,代价是训练慢一些、显存吃得多一点。

适合谁看:已经跑通过 PyTorch 基础训练循环、想把手里的 NER 项目从「能跑」推到「能用」的工程师;或者正在选型,纠结要不要上 CRF 层的团队。下面从环境搭建一路写到推理部署,代码和参数都给到能直接抄的程度。

2. 环境搭建与数据准备:从零把 BERT-BiLSTM-CRF 跑起来

2.1 PyTorch 环境搭建:版本对应与 GPU 适配

环境这块翻车最多。PyTorch 和 CUDA 的版本对应关系不是随便配的,装错了要么 import 报错,要么训练时 loss 直接变 nan。我一般按这个流程走:

# 创建独立环境,避免和系统 Python 打架 conda create -n ner_bilstm_crf python=3.9 -y conda activate ner_bilstm_crf # 先装 PyTorch,注意 CUDA 版本要和驱动匹配 # 驱动 525+ 可以用 cu118,驱动 470+ 用 cu113 pip install torch==2.0.1+cu118 torchvision==0.15.2+cu118 --index-url https://download.pytorch.org/whl/cu118 # 再装 transformers 和序列标注相关依赖 pip install transformers==4.30.2 seqeval==1.2.2 numpy==1.24.3 tqdm==4.65.0

装完先验证 GPU 是否可用:

import torch print(torch.__version__) # 期望输出 2.0.1+cu118 print(torch.cuda.is_available()) # True 才算成功 print(torch.cuda.get_device_name(0))

如果cuda.is_available()返回 False,九成是 PyTorch 版本和驱动不匹配。别急着重装驱动,先用nvidia-smi看驱动支持的 CUDA 上限,再回 PyTorch 官网查对应版本。WSL 环境下还要确认/usr/lib/wsl/lib在 LD_LIBRARY_PATH 里,否则 CUDA 库找不到。

提示:Python 3.9 和 transformers 4.30.x 是经过大量项目验证的稳定组合。追新版本容易遇到 tokenizer 接口变动,没必要给自己找麻烦。

2.2 数据格式:BIO 标注与标签体系设计

NER 数据最常见的是 BIO 格式,每行「字 + 空格 + 标签」,句子之间空行分隔。标签体系按任务定,中文通用场景一般是:

标签含义示例
B-PER人名开始张
I-PER人名中间/结尾三
B-LOC地名开始北
I-LOC地名中间/结尾京
B-ORG机构名开始清
I-ORG机构名中间/结尾华
O非实体的

标签列表要固定顺序,存成labels.txt,训练和推理共用。顺序一旦变了,模型加载后预测全乱。我一般把标签表写成:

LABELS = ["O", "B-PER", "I-PER", "B-LOC", "I-LOC", "B-ORG", "I-ORG"] label2id = {label: i for i, label in enumerate(LABELS)} id2label = {i: label for label, i in label2id.items()}

数据量方面,中文 NER 每个实体类型至少准备 500 到 1000 条标注样本,否则 CRF 的转移矩阵学不准。数据太少时,BiLSTM 层可以冻结,只微调 BERT 和 CRF。

2.3 用 BERT tokenizer 做子词对齐

BERT 用的是 WordPiece 分词,一个中文词可能被切成多个子词,但 NER 标签是按字/词给的。对齐逻辑是:第一个子词继承原标签,后续子词标为 -100(PyTorch 的 ignore_index),不参与 loss 计算。

from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") def align_labels_with_tokens(labels, word_ids): new_labels = [] previous_word_id = None for word_id in word_ids: if word_id is None: new_labels.append(-100) # 特殊 token 不计算 loss elif word_id != previous_word_id: new_labels.append(labels[word_id]) # 子词第一个继承标签 else: new_labels.append(-100) # 同一词的后续子词忽略 previous_word_id = word_id return new_labels

word_ids()返回每个 token 对应的原始词索引,特殊 token([CLS]、[SEP])返回 None。这个对齐逻辑必须和后面的 CRF 层配合好,否则 loss 会算错,模型学出来的边界全是偏的。

注意:bert-base-chinese的 vocab 是字级别的,中文场景下基本不会出现一个词切多个子词的情况,但对齐逻辑仍然要写对,因为标点、英文、数字混合时会有多子词。

3. 模型结构拆解:BERT、BiLSTM、CRF 各自干什么活

3.1 BERT 输出怎么接 BiLSTM:维度与 batch 处理

BERT 的输出是[batch_size, seq_len, hidden_size],bert-base-chinese的 hidden_size 是 768。BiLSTM 要求输入是[seq_len, batch_size, input_size],所以中间要做一次 transpose。BiLSTM 的隐藏层维度一般设成 256 或 512,双向拼接后输出维度是 hidden_size * 2。

import torch.nn as nn from transformers import BertModel class BertBiLSTMCRF(nn.Module): def __init__(self, bert_path, num_labels, lstm_hidden=256, lstm_layers=1, dropout=0.3): super().__init__() self.bert = BertModel.from_pretrained(bert_path) self.dropout = nn.Dropout(dropout) self.bilstm = nn.LSTM( input_size=768, hidden_size=lstm_hidden, num_layers=lstm_layers, bidirectional=True, batch_first=True, # 输入输出都是 [batch, seq, feature] dropout=dropout if lstm_layers > 1 else 0 ) 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.dropout(sequence_output) lstm_output, _ = self.bilstm(sequence_output) # [B, L, 512] 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())

batch_first=True省掉了手动 transpose,少一个出错点。dropout 加在 BERT 输出之后、BiLSTM 之前,防止过拟合。LSTM 层数超过 1 时,层间也要加 dropout。

3.2 CRF 层:转移矩阵与维特比解码

CRF 的核心是一个[num_labels, num_labels]的转移矩阵,transitions[i][j]表示从标签 i 转移到标签 j 的分数。训练时用前向算法算所有路径的 log-sum-exp,推理时用维特比算法找最优路径。

class CRF(nn.Module): def __init__(self, num_tags, batch_first=False): super().__init__() self.num_tags = num_tags self.batch_first = batch_first # 转移矩阵:transitions[i][j] = P(标签j | 标签i) self.transitions = nn.Parameter(torch.randn(num_tags, num_tags)) # 起始和结束转移 self.start_transitions = nn.Parameter(torch.randn(num_tags)) self.end_transitions = nn.Parameter(torch.randn(num_tags)) def forward(self, emissions, tags, mask): # 计算负对数似然,训练时用 return -self._compute_log_likelihood(emissions, tags, mask) def decode(self, emissions, mask): # 维特比解码,推理时用 return self._viterbi_decode(emissions, mask)

转移矩阵的初始化用randn就行,训练过程中会自动学到「B-PER 后面不能跟 I-ORG」这类约束。如果数据量小,可以手动把非法转移的初始值设成 -10000,加速收敛。

提示:CRF 层建议用pytorch-crf库,自己手写容易在 mask 处理上翻车。上面代码是简化版,生产环境直接用torchcrf.CRF。

3.3 损失函数与优化器参数怎么设

损失函数就是 CRF 的负对数似然,不需要额外加交叉熵。优化器用 AdamW,学习率分两组:BERT 层用 2e-5,BiLSTM 和 CRF 层用 1e-3。这是因为 BERT 已经预训练好了,微调时学习率要小;BiLSTM 和 CRF 是随机初始化的,需要大一点的学习率才能快速收敛。

from torch.optim import AdamW bert_params = list(model.bert.named_parameters()) other_params = list(model.bilstm.named_parameters()) + \ list(model.classifier.named_parameters()) + \ list(model.crf.named_parameters()) optimizer = AdamW([ {"params": [p for n, p in bert_params], "lr": 2e-5}, {"params": [p for n, p in other_params], "lr": 1e-3} ], weight_decay=0.01) scheduler = torch.optim.lr_scheduler.LinearLR( optimizer, start_factor=1.0, end_factor=0.1, total_iters=1000 )

batch_size 在 16GB 显存上设 16 到 32,序列长度截断到 128 或 256。训练轮数一般 10 到 20 轮就够了,太多会过拟合。每轮结束在验证集上算 F1,保存最好的那个 checkpoint。

4. 训练、评估与推理:把模型从能跑推到能用

4.1 训练循环与梯度裁剪

训练循环本身不复杂,关键是梯度裁剪和 mask 处理。BERT 微调时梯度容易爆炸,clip_grad_norm_设 1.0 是常规操作。

from seqeval.metrics import f1_score, classification_report def train_epoch(model, dataloader, optimizer, scheduler, device): model.train() total_loss = 0 for batch in dataloader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["labels"].to(device) optimizer.zero_grad() 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() total_loss += loss.item() return total_loss / len(dataloader)

验证时把model.eval()打开,用torch.no_grad()包住,解码出来的标签序列用 seqeval 算 F1。seqeval 的好处是按实体级别算,不是按 token 级别,更符合 NER 的实际评价标准。

4.2 用 seqeval 算实体级 F1

def evaluate(model, dataloader, id2label, device): model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for batch in dataloader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["labels"] preds = model(input_ids, attention_mask) for pred, label in zip(preds, labels): pred_tags = [id2label[p] for p, l in zip(pred, label) if l != -100] true_tags = [id2label[l.item()] for l in label if l != -100] all_preds.append(pred_tags) all_labels.append(true_tags) return f1_score(all_labels, all_preds), classification_report(all_labels, all_preds)

classification_report会输出每个实体类型的 precision、recall、F1,方便定位是哪个类型拖后腿。常见情况是 ORG 类 F1 明显低于 PER 和 LOC,因为机构名边界模糊、嵌套多。

4.3 推理部署:PyTorch 转 ONNX 与 batch 推理

训练完的模型要上线,直接跑 PyTorch 也行,但推理速度慢。转 ONNX 后用 onnxruntime 能快 2 到 3 倍,尤其是 batch 推理场景。

import torch.onnx model.eval() dummy_input = torch.randint(0, 1000, (1, 128)).to(device) dummy_mask = torch.ones(1, 128).to(device) torch.onnx.export( model, (dummy_input, dummy_mask), "ner_bert_bilstm_crf.onnx", input_names=["input_ids", "attention_mask"], output_names=["emissions"], dynamic_axes={ "input_ids": {0: "batch", 1: "seq"}, "attention_mask": {0: "batch", 1: "seq"}, "emissions": {0: "batch", 1: "seq"} }, opset_version=13 )

注意 CRF 的维特比解码在 ONNX 里不好实现,所以导出时只导出到 emissions 层,解码逻辑放在 Python 端用 numpy 实现。这样既保留了 ONNX 的推理速度,又不用在 ONNX 里硬写循环。

注意:opset_version 建议用 13 或更高,低版本对 dynamic_axes 支持不好,batch 维度会固定死。

5. 避坑与排查:那些让我重跑过训练的血泪教训

5.1 loss 不降反升,或者直接变 nan

现象:训练头几轮 loss 从 10 降到 5,然后突然跳到 nan,之后再也降不下来。

原因:九成是学习率设大了,或者梯度爆炸没裁。BERT 层用 1e-3 的学习率必炸,必须用 2e-5 级别。另外 CRF 的转移矩阵初始化值太大也会导致前向算法溢出。

解决:确认 BERT 层学习率在 2e-5 到 5e-5 之间,加clip_grad_norm_(model.parameters(), 1.0),CRF 转移矩阵初始化用torch.randn(num_tags, num_tags) * 0.1。

5.2 验证集 F1 很高,测试集一塌糊涂

现象:验证集 F1 到 0.95,换一批数据掉到 0.6。

原因:数据泄露。要么验证集和训练集有重叠样本,要么标注规范不一致。中文 NER 里常见的是训练集标了「北京市」为 LOC,测试集标成「北京」为 LOC,边界不一致导致 F1 暴跌。

解决:训练前用脚本检查训练集和验证集的句子级重叠,重叠的直接删。标注规范写成文档,所有标注人员对齐边界规则。

5.3 实体边界总是差一个字

现象:预测出「北京」但标注是「北京市」,或者预测「张三」但标注是「张」。

原因:BERT tokenizer 的子词对齐没做对,或者 CRF 的转移矩阵没学到 B/I 约束。

解决:检查align_labels_with_tokens函数,确保第一个子词继承标签、后续子词标 -100。另外确认标签体系里 B- 和 I- 是成对的,不能只有 B- 没有 I-。

5.4 GPU 显存不够,batch_size 降到 1 还 OOM

现象:16GB 显存,batch_size 设 8 就 OOM。

原因:序列长度设太长了。BERT 的注意力是 O(n^2),seq_len 从 128 提到 512,显存占用翻 16 倍。

解决:统计训练数据里 95% 的句子长度,按那个值截断。中文 NER 句子一般不超过 128 个字,设 128 足够。如果确实有长文本,用滑动窗口切分,别硬撑。

5.5 推理时 batch 内句子长度不一致,结果错位

现象:单条推理正常,batch 推理时第二条开始标签全错。

原因:padding 没做对,或者 attention_mask 没传给 CRF 的 decode 函数。

解决:用 tokenizer 的padding=True自动补齐,decode 时把 attention_mask 转成 bool 传给 CRF。CRF 的 decode 必须带 mask,否则会把 padding 位置的标签也算进最优路径。

6. 进阶技巧:让 BERT-BiLSTM-CRF 再涨两个点

模型跑通之后,想再往上提 F1,有几个方向值得试。第一个是对抗训练,在 BERT 的 embedding 层加 FGM(Fast Gradient Method)扰动,相当于给模型加正则,小数据集上通常能涨 1 到 2 个点。实现方式是在每个 batch 反向传播后,对 embedding 参数加一个小的扰动,再算一次 loss 累加回去。

class FGM: def __init__(self, model, epsilon=1.0): self.model = model self.epsilon = epsilon self.backup = {} def attack(self): for name, param in self.model.named_parameters(): if "word_embeddings" in name and param.requires_grad: self.backup[name] = param.data.clone() norm = torch.norm(param.grad) if norm != 0: r_at = self.epsilon * param.grad / norm param.data.add_(r_at) def restore(self): for name, param in self.model.named_parameters(): if name in self.backup: param.data = self.backup[name] self.backup = {}

用法是在loss.backward()之后调fgm.attack(),再算一次 loss 并backward(),最后fgm.restore()再optimizer.step()。epsilon 设 0.5 到 1.0 之间,太大反而掉点。

第二个方向是标签平滑。CRF 的负对数似然对错误标签的惩罚太硬,标签平滑把 one-hot 标签变成软标签,能缓解过拟合。不过 CRF 的标签平滑实现起来麻烦,更简单的替代方案是在 BiLSTM 输出后加一个温度系数,软化 emissions 的分布。

第三个方向是模型融合。训三个不同随机种子的 BERT-BiLSTM-CRF,推理时把 emissions 平均后再做维特比解码。代价是推理慢三倍,但 F1 通常能涨 1 到 1.5 个点。如果线上对延迟不敏感,这个方案性价比很高。

最后一个习惯:每次改完超参或结构,固定一个随机种子跑三遍,取平均 F1。单次结果波动可能有 0.5 个点,只看一次容易误判。我一般把种子设成 42、123、2024 各跑一遍,心里才有底。希望帮到你。

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

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

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

立即咨询