简介这是一份基于PyTorch实现的BERT-BiLSTM-CRF命名实体识别项目面向自然语言处理学习者和算法工程师可用于人名、地名、组织名等实体抽取。项目将预训练BERT、双向LSTM与CRF解码器有机结合覆盖数据预处理、模型训练、验证、推理与评估完整流程。压缩包共19个文件以Python脚本为主包含模型定义、训练、预测及工具模块另有Jupyter Notebook演示数据处理与预测过程并附有训练集、验证集、测试集样例、配置文件和使用文档整体仅3.95MB轻量易用。已有936人学习下载。通过研读代码可掌握BERT输出与序列标注的标签对齐方式、BiLSTM上下文编码以及CRF维特比解码的具体实现同时获得一套可直接运行的NER训练测试流程适合作为课程实战或科研基线便于在此基础上做领域迁移与效果调优。1. 这个 master(1) 模板到底在解决什么问题中文 NER 的常见选择题你可能和我一样在某个团队共享目录或同事的U盘里拿到过一个名为Bert-BiLSTM-CRF-pytorch-master (1)_python_的压缩包。名字里的 “(1)” 说明它是同一个项目被反复下载、拷贝、再修改的副本而这种频繁流传本身就说明一件事在中文命名实体识别NER场景里BERT BiLSTM CRF 是很多人验证后愿意留下的基准方案。它要解决的问题很具体给定一句中文把里面的人名、地名、机构名等实体的边界准确切出来。做法也相对统一——BERT 提供字的语义表示BiLSTM 做上下文整合CRF 负责约束标签序列的合法性。适合急着跑通实体抽取、做毕设复现或产品 POC 的人也适合想对比“裸 BERT 分类头”和“结构化标签约束”差异的从业者。2. BERT、BiLSTM、CRF 怎么组装先看懂模型结构再决定从哪里改起2.1 为什么不是 BERT Softmax而是 BERT BiLSTM CRF先说结论直接用BertForTokenClassification接一个 softmax 也能做 NER但实体抽取这类任务和普通文本分类不一样。每个 token 的标签不是独立的B-PER后面可以跟I-PER但B-PER后面直接跳I-LOC就是常识性错误。Softmax 在训练时看不到这种标签与标签之间的转移关系只能靠 BERT 自己去隐式学习数据少或者实体边界模糊时预测结果里就会出现大量不合理跳转。BERT 本身已经把每个字编码成语义向量但它对“标签之间的约束”没有显式建模。于是常见做法是在 BERT 后面接一个正向和反向的 LSTM把前向、后向信息拼接起来再通过一个线性层把维度压到标签数量。这个 BiLSTM 的参数量远小于 BERT却能在这个具体任务上对 BERT 的表示做一次有监督的提纯并且给 CRF 提供更稳定的发射得分emission score。CRF 的价值在于引入标签转移矩阵。训练时它不只看每个位置预测得对不对还会看整条路径的联合得分解码时用维特比算法找全局最优标签序列。在中文 NER 里很多错误恰恰是实体边界错误比如多切一个字、少切一个字CRF 的转移约束能直接压掉这类问题。所以这个三件套不是堆模块而是各管一段BERT 管语义BiLSTM 管上下文的再整合CRF 管输出结构。2.2 最小模型骨架一个能跑通的结构定义在 PyTorch 基础框架上把这三段拼起来并不复杂。下面的代码定义了一个最常见的BertBiLSTMCRF类forward里区分了训练和预测两条路径训练时返回 CRF 的 loss预测时返回维特比解码后的标签序列。import torch from torch import nn from transformers import BertModel class BertBiLSTMCRF(nn.Module): def __init__(self, bert_dir, num_tags, lstm_hidden256, num_layers1, dropout0.5): super().__init__() self.bert BertModel.from_pretrained(bert_dir) hidden self.bert.config.hidden_size # bert-base 通常是 768 # 双向 LSTM输出维度是 hidden_size * 2 self.bilstm nn.LSTM( input_sizehidden, hidden_sizelstm_hidden, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, ) self.dropout nn.Dropout(dropout) self.fc nn.Linear(lstm_hidden * 2, num_tags) self.crf CRF(num_tagsnum_tags) def forward(self, input_ids, attention_mask, labelsNone): # BERT 最后一层输出 bert_output self.bert(input_ids, attention_maskattention_mask)[0] # BiLSTM 再做一轮上下文整合 lstm_output, _ self.bilstm(bert_output) # 线性层映射到标签空间 logits self.fc(self.dropout(lstm_output)) if labels is not None: # 训练阶段CRF 返回负对数似然期望真实路径得分最高 return self.crf(logits, labels, maskattention_mask.bool()) # 预测阶段用维特比解码不要对 logits 直接 argmax return self.crf.decode(logits, maskattention_mask.bool())这里有几个参数值得说明。lstm_hidden256是双向 LSTM 的隐层宽度中文 NER 数据量通常不大256 已经够用再大容易过拟合且训练变慢。num_layers我一般就设 1堆两层 LSTM 对短文本收益很小反而让梯度路径更长。dropout0.5是 Bert 之后到全连接层之前的随机失活比例如果你发现验证集抖动很大可以先把它降到 0.3。CRF 类在这个骨架里没有展开它既可以自己手写也可以直接用社区里现成的 PyTorch CRF 实现。很多旧版开源项目都会把 CRF 单独写成一个文件结构大同小异核心就是下面要说的得分布和维特比解码。2.3 CRF 的得分函数与维特比解码这两段逻辑决定实体边界CRF 层之所以比 softmax 强是因为它把“序列整体得分”作为优化目标。真实路径得分由两部分组成每个位置从 LSTM 输出拿到的发射得分以及标签之间转移的转移得分。训练时让真实路径的得分在所有可能路径的总得分里占比尽量高等价于最小化两者之差。下面是一个简化但逻辑完整的 CRF 实现主要看在_forward_score里怎么对全部路径做 log-sum-exp以及decode里怎么用维特比回溯拿到全局最优。import torch from torch import nn class CRF(nn.Module): def __init__(self, num_tags): super().__init__() self.num_tags num_tags self.start_trans nn.Parameter(torch.randn(num_tags)) self.end_trans nn.Parameter(torch.randn(num_tags)) self.trans nn.Parameter(torch.randn(num_tags, num_tags)) def _logsumexp(self, x, dim): m x.max(dimdim)[0] return m (x - m.unsqueeze(dim)).logsumexp(dimdim) def forward(self, logits, labels, mask): gold_score self._score_sentence(logits, labels, mask) total_score self._forward_score(logits, mask) return total_score - gold_score def _score_sentence(self, logits, labels, mask): batch, seq_len, _ logits.shape score logits[:, 0].gather(1, labels[:, 0:1]).squeeze(1) score self.start_trans[labels[:, 0]] for t in range(1, seq_len): valid mask[:, t].float() trans self.trans[labels[:, t - 1], labels[:, t]] emit logits[:, t].gather(1, labels[:, t:t 1]).squeeze(1) score (trans emit) * valid lengths mask.sum(dim1) - 1 end_idx labels.gather(1, lengths.unsqueeze(1)).squeeze(1) return score self.end_trans[end_idx] def _forward_score(self, logits, mask): batch, seq_len, _ logits.shape score logits[:, 0] self.start_trans.unsqueeze(0) for t in range(1, seq_len): valid mask[:, t].unsqueeze(1) all_paths ( score.unsqueeze(2) self.trans.unsqueeze(0) logits[:, t].unsqueeze(1) ) score torch.where( valid.bool(), self._logsumexp(all_paths, dim1), score, ) return self._logsumexp(score self.end_trans.unsqueeze(0), dim1) def decode(self, logits, mask): batch, seq_len, _ logits.shape score logits[:, 0] self.start_trans.unsqueeze(0) back [] for t in range(1, seq_len): valid mask[:, t] all_paths ( score.unsqueeze(2) self.trans.unsqueeze(0) logits[:, t].unsqueeze(1) ) best_score, best_tag all_paths.max(dim1) back.append(best_tag) score torch.where(valid.unsqueeze(1), best_score, score) final_score score self.end_trans.unsqueeze(0) best_end final_score.argmax(dim1) # 这里按常规实现补上回溯逻辑即可 return self._viterbi_backtrack(best_end, back, mask)_forward_score里每个时刻都保留“以每个标签结尾”的累计得分torch.where的作用是让 padding 位置的得分不参与递推。decode里的back记录的是每个时刻每个候选标签的前驱最优标签最后从终止标签往前回溯。这里要注意预测阶段千万不能直接logits.argmax(dim-1)那等于把 CRF 的转移约束扔掉了实体边界又会乱回去。3. 把句子变成张量的数据链路从 BMES 标注到 BERT token 的对齐与 padding3.1 训练数据格式与读取逻辑中文 NER 数据最常见的落地格式是“字 标签”逐行排列句子之间用空行隔开标签体系用 BIO 或 BMES。BIO 里B-PER表示人名的开始I-PER表示人名内部O表示非实体BMES 则把内部再拆成M和单字实体S。两者对模型效果影响不大选一种后整个项目保持一致即可。读取文件时不要直接按行读一个丢一个要先把当前句子的字和标签收集起来遇到空行再整体保存。这一步看着简单但很多人是在这里把句子的最后一个字丢弃的。def load_ner_data(file_path): sentences, labels [], [] cur_s, cur_l [], [] with open(file_path, encodingutf-8) as f: for line in f: line line.strip() if not line: if cur_s: sentences.append(cur_s) labels.append(cur_l) cur_s, cur_l [], [] continue parts line.split() if len(parts) ! 2: continue char, tag parts cur_s.append(char) cur_l.append(tag) # 文件最后一行没有空行时也要收尾 if cur_s: sentences.append(cur_s) labels.append(cur_l) return sentences, labels这个函数返回的是两个列表每个元素分别是“一行的字符列表”和“对应的标签列表”。注意标签字符串必须严格统一B-PER和b_per不能混用否则标签字典会凭空多出很多类别训练还会因为类别不平衡而出现莫名其妙的低 F1。读进来之后我一般会顺手打印前三条样本确认没有错位再进入 tokenizer 阶段。3.2 标签与 token 的对齐这个环节最容易翻车BERT 的 tokenizer 和“一个字一个标签”的标注体系之间有一个天然冲突tokenizer 会把词表中的未登录字切分成多个子词还可能自动插入[CLS]、[SEP]。比如某个生僻字被切成两个 token原始标签只有一个如果按位置一一对应标签序列就比 input_ids 短训练时直接报错或错位。常见做法是先用tokenizer(sent, is_split_into_wordsTrue)拿到每个 token 对应的原始字下标word_ids再把原始标签扩展到每个 token 上。同一个字被切成多个 token 时这几个 token 共享同一个标签[CLS]、[SEP]和 padding 位置用-100填充让 cross entropy 或 CRF 自动忽略它们。from transformers import BertTokenizerFast def encode_with_labels(tokenizer, sentence, labels, max_len128): encoding tokenizer( sentence, is_split_into_wordsTrue, truncationTrue, max_lengthmax_len, ) word_ids encoding.word_ids() label_ids [-100] * len(encoding.input_ids) prev_wid None for i, wid in enumerate(word_ids): if wid is None: prev_wid None continue # 同一个原始字对应的多个子词 token贴上同一个标签 if wid ! prev_wid: label_ids[i] label_map[labels[wid]] prev_wid wid return ( encoding.input_ids, encoding.attention_mask, label_ids, )这里的label_map是提前构造好的{标签名: 数字编号}字典其中O和各类B/I都要有编号。用word_ids对齐比直接zip(sentence, input_ids)可靠得多因为它把 tokenizer 内部的子词切分透明化了。如果你在代码里看到有人对特殊 token 也赋了标签值那基本就是标签整体偏移的根源。3.3 padding、truncation 与 CRF Mask动态 padding 比固定长度更省显存BERT 类模型对序列长度非常敏感固定把每句话都 padding 到 128 虽然省事但显存浪费很大。更好的做法是在 DataLoader 的 collate 函数里做动态 padding一个 batch 内只补齐到当前 batch 的最长句。CRF 计算时必须传入 mask否则 padding 位置会作为真实 token 参与转移得分计算。def collate_fn(batch): input_ids [item[input_ids] for item in batch] attention_mask [item[attention_mask] for item in batch] label_ids [item[label_ids] for item in batch] max_len max(len(seq) for seq in input_ids) padded_input, padded_mask, padded_label [], [], [] for ids, mask, labels in zip(input_ids, attention_mask, label_ids): pad_len max_len - len(ids) padded_input.append(ids [0] * pad_len) padded_mask.append(mask [0] * pad_len) padded_label.append(labels [-100] * pad_len) return ( torch.tensor(padded_input, dtypetorch.long), torch.tensor(padded_mask, dtypetorch.long), torch.tensor(padded_label, dtypetorch.long), )[0]是 BERT 的[PAD]token idattention_mask里补 0label 里补-100。CRF 内部用attention_mask.bool()作为有效位置判断padding 位置既不算发射得分也不算转移得分。这一步做对之后loss 曲线会比固定 padding 时平滑很多因为不同 batch 的真实有效长度变化不会再干扰梯度。提示如果你发现训练 loss 正常下降但验证时预测结果里出现大量O先别调参回去检查word_ids对齐逻辑里prev_wid的更新位置。很多“模型不学习”的玄学最后都栽在这一行。4. 训练这条链路的三组参数分层学习率、梯度累积与早停4.1 分层学习率BERT 应该用多小的学习率直接对整个模型用一个学习率是新手最容易踩的坑。BERT 是预训练模型权重已经收敛在语义空间的某个合理位置学习率太大会把预训练信息冲掉而随机初始化的 BiLSTM 和 CRF 层需要相对大的更新步长才能快速收敛。常见做法是对参数分组BERT 层用 2e-5 到 5e-5LSTM、全连接和 CRF 用 1e-3 量级。这里还有一个细节weight decay 通常只加在非 bias、非 LayerNorm 的参数上否则 BN 和 LayerNorm 的统计会被干扰这也是 transformers Trainer 里默认的分组逻辑。from transformers import AdamW, get_linear_schedule_with_warmup no_decay [bias, LayerNorm.weight] bert_params list(model.bert.named_parameters()) other_params [ p for n, p in model.named_parameters() if not n.startswith(bert.) and p.requires_grad ] optimizer_grouped [ { params: [p for n, p in bert_params if not any(nd in n for nd in no_decay)], lr: 2e-5, weight_decay: 0.01, }, { params: [p for n, p in bert_params if any(nd in n for nd in no_decay)], lr: 2e-5, weight_decay: 0.0, }, { params: other_params, lr: 1e-3, }, ] optimizer AdamW(optimizer_grouped) scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps, )total_steps可以用len(train_loader) * epochs估算。warmup 比例 0.1 是常用值它的作用是让 BERT 层在训练前几步不要冲太猛。如果你数据量很小比如只有几千条warmup 比例建议提到 0.2否则前几个 step 的 loss 可能出现瞬间爆炸。4.2 训练循环与梯度累积显存不够时的后悔药训练循环本身并不复杂但要注意两个问题CRF 返回的 loss 是 batch 维度的需要.mean()再反向传播梯度累积时loss 要除以累积步数否则等效 batch size 变大后学习率没有相应缩放收敛会不稳定。def train_one_epoch(model, loader, optimizer, scheduler, accumulation_steps2): model.train() total_loss 0.0 for step, batch in enumerate(loader): input_ids, attention_mask, label_ids [x.to(device) for x in batch] loss model(input_ids, attention_mask, labelslabel_ids) loss loss.mean() / accumulation_steps loss.backward() if (step 1) % accumulation_steps 0: torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss loss.item() * accumulation_steps return total_loss / len(loader)梯度累积的适用场景是 GPU 显存不够需要用 4 的 batch size 模拟 8 的等效 batch size。注意这种情况下 BERT 层的真实更新频率变低了训练 epoch 数可以适当加长一点。clip_grad_norm_的阈值我习惯设在 5.0中文 NER 里 BiLSTM 反向传播容易出现梯度爆炸这一步能防止 loss 突然变成 NaN。一个小建议验证时不要开梯度with torch.no_grad()必须写上。很多人训练集 loss 很漂亮一验证就崩就是因为验证阶段还开着 dropout 和梯度导致结果剧烈抖动。4.3 参数怎么选一张可直接抄的表下面的参数来自我多次跑中文 NER 的经验值不保证对每个数据集最优但作为起点足够稳定。参数推荐值说明max_seq_len128中文 NER 句子普遍不长按数据 95% 分位截断更省显存train_batch_size8 或 16显存受限时降到 4配合梯度累积BERT lr2e-5数据量小或领域差异大时用 3e-5 试CRF / LSTM lr1e-3与 BERT 层分开设置不要统一warmup_steps10% total_steps小数据集提高至 20%max_grad_norm5.0防梯度爆炸epochs10 左右配合早停不要死跑固定轮数early_stop_patience3验证 F1 连续 3 轮不升就停保存最优 checkpoint早停的指标我建议用验证集实体级 F1而不是 loss。因为 CRF 的训练 loss 在下降到一定程度后变化很小但 F1 可能还在稳步上升反过来F1 连续几轮不动时loss 再降多半是过拟合信号。每次 epoch 结束保存一次model.state_dict()用best_f1判断是否替换当前最优权重最后加载最优权重做测试集预测。5. 训练避坑标签错位、CRF Mask 与显存不足的排查记录5.1 训练不收敛F1 一直在 30% 上下徘徊现象模型能跑验证集 loss 也在下降但实体级 F1 始终在 30% 附近预测结果里实体边界明显错位。原因中文 BERT 的 tokenizer 会把部分生僻字切成多个子词代码里如果直接按 “一个字一个标签” 去zip(sentence, label)token 总数和标签总数不一致后面的标签会被整体挤掉或移动。CRF 层拿到的发射得分和真实标签对不上学出来的转移矩阵自然也是歪的。解决用 3.2 小节里的word_ids()对齐逻辑同一个原始字的所有子词 token 共享同一个标签[CLS]、[SEP]和 padding 用-100填充。改完之后先打印一条样本的len(input_ids)和len(label_ids)两者一致再训练。5.2 加了 CRF 之后效果反而不如裸 BERT 分类头现象同一份数据BertForTokenClassification的 F1 有 78%套上 CRF 之后反而掉到 70%。原因CRF 层是随机初始化的它的转移矩阵需要足够多的训练步数才能学到合理的标签约束。如果整个模型统一用 2e-5 的学习率CRF 的收敛速度会被拖得很慢训练 epoch 又不够CRF 等于还在半路自然帮不上忙。还有一种可能是训练数据过少比如只有几百条句子CRF 的转移参数学不到可靠统计反而引入噪声。解决把 CRF 和 BiLSTM 层的学习率调到 1e-3BERT 层保持 2e-5训练 8 个 epoch 后再看效果。数据量少时先用 BIO 体系减少标签类别数或者直接用 BERT-CRF 结构不插 BiLSTM减少随机参数总量。5.3 显存不够batch_size8 也直接 OOM现象8GB 显存的卡跑bert-base-chinesebatch_size8、max_len128训练 step 刚开始就报 CUDA out of memory。原因BERT 的 self-attention 复杂度跟序列长度呈平方关系128 长度的显存占用远超直觉。DataLoader 里如果提前把所有句子都 padding 到固定 128显存浪费就更明显。解决先改成动态 padding只补 batch 内最长长度再把 batch_size 砍到 4开accumulation_steps2维持等效 batch size最后把 max_len 按训练集的 95% 分位截断而不是取最大值。三步做完显存占用通常能降到原来的三分之一以下。5.4 环境问题Python、PyTorch、CUDA 版本对不上现象代码在本地能跑换到 GPU 服务器后torch.cuda.is_available()一直返回 False或者 import torch 时报 undefined symbol。原因最常见的安装方式是用了 CPU 版 PyTorch或者 Python 版本和 PyTorch 的 wheel 不匹配导致 CUDA 扩展没有被正确加载。很多人习惯在 vscode 里直接点运行却没有确认当前解释器到底属于哪个 conda 环境。解决训练前先跑一段环境检查确认 PyTorch 版本带cu后缀再跑torch.cuda.is_available()。在 vscode 里切换解释器后用python -c import torch; print(torch.__version__)验证当前环境而不是只看右上角显示的 Python 版本。这个检查 30 秒就能完成但能省掉半天 “玄学报错” 的血泪排查时间。5.5 实验结果复现不了同数据两次训练 F1 差 10%现象同一份数据、同一个脚本第一次 F1 82%第二次变成 71%没有任何代码改动。原因模型里有太多随机源PyTorch 参数初始化、DataLoader 的 shuffle 顺序、GPU 上某些算子的非确定性。不固定随机种子结果差异大是必然的。解决训练脚本开头固定random.seed(42)、numpy.random.seed(42)、torch.manual_seed(42)DataLoader 传入generatortorch.Generator().manual_seed(42)。另外在验证阶段必须model.eval()否则 dropout 会继续随机失活推理结果每次都不一样。固定种子后再跑两次F1 波动应该控制在 1% 以内。6. 验证与二开实体级 F1 怎么算以及长文本滑窗扩展训练结束时不能只看整体准确率因为 NER 的样本里O标签占比很高即使所有实体都没切对token 级准确率也可能有 90% 以上。我一般用实体级 F1 作为上线与否的判据只有实体的起始下标和实体类型完全匹配才算预测正确。def extract_entities(label_seq): entities [] start -1 cur_type None for i, tag in enumerate(label_seq): if tag.startswith(B): start i cur_type tag[2:] elif tag.startswith(I) and cur_type is not None: # 继续当前实体 pass else: if cur_type is not None and start 0: entities.append((start, i - 1, cur_type)) start -1 cur_type None # 收尾最后一个实体 if cur_type is not None and start 0: entities.append((start, len(label_seq) - 1, cur_type)) return set(entities)统计时把预测实体集合和真实实体集合做交集先算精确率和召回率再算 F1。实体级 F1 达到 85% 以上基本可以认为这个模型在验证集上是可用的如果只有 70% 左右优先检查标签对齐和 CRF 转移矩阵而不要急着换更大的 BERT。长文本是另一个常见落地场景。BERT 的 max_len 限制摆在那里遇到一篇几千字的新闻稿不能直接截断前 128 字否则文末实体全丢。我常用的做法是滑窗预测窗口大小取 200 字步长 100 字每个窗口交叠部分被预测两次合并时保留置信度高的实体。这个方式不需要重新训练只写一个预测脚本就能把长文本的实体召回率提上来不少。另外如果要把模型放进在线服务常见思路是只把 BERT BiLSTM 导出为 ONNXlogits 出来后仍然在 Python 侧做维特比解码。这样既避开了 CRF 动态循环在 ONNX 里不友好的问题也保留了标签约束带来的稳定性。我自己换数据集时的检查顺序值得一提先跑通环境检查再打印一条训练样本验证标签对齐然后用 100 条小样本跑到过拟合最后才上全量数据。这个顺序帮我避开了至少十次 “调参调了一下午最后发现是数据有问题” 的翻车经历。希望帮到你。本文还有配套的精品资源点击获取