中文命名实体识别实战:BERT-BiLSTM-CRF从原理到调优

发布时间:2026/10/2 2:37:54
中文命名实体识别实战:BERT-BiLSTM-CRF从原理到调优 简介本资源面向中文命名实体识别NER方向的初学者与毕业设计、课程设计开发者提供一套基于PyTorch实现的BERT-BiLSTM-CRF完整项目。项目将预训练BERT、双向LSTM与条件随机场CRF串联覆盖数据加载、模型构建、训练、评估与预测全流程代码含详细注释新手也能看懂部署后即可运行使用。压缩包共22个文件约2.4MB其中8个py文件承载模型定义、训练、预测与评估等核心逻辑5个txt与1个json提供训练、验证、测试数据集及标签配置5个xml与iml、gitignore等为工程配置另有README说明文档辅助上手。目前已有663人学习下载适合作为高分项目参考。读者可获得可复现的完整源码、数据集与项目说明理解BERT-BiLSTM-CRF的建模思路与调参要点并在此基础上快速迁移到自己的NER任务中。1. 中文命名实体识别为什么还在用 BERT-BiLSTM-CRF从一次简历抽取翻车说起去年帮一个做招聘 SaaS 的朋友处理简历解析规则加词典的方案在「公司名」上准确率还行一到「项目经历里的技术栈」就崩了——熟悉 Spring Cloud Alibaba 里的 Spring Cloud Alibaba 被切成三段而 Alibaba 又被误标成公司名。这种边界模糊、依赖上下文的实体正是中文命名实体识别NER最典型的战场。BERT-BiLSTM-CRF 这套组合从 2018 年前后火到现在不是因为它新而是因为它在中文 NER 上足够稳BERT 负责把字变成带上下文的向量BiLSTM 负责捕捉序列前后依赖CRF 负责保证标签序列合法比如 I-ORG 不能出现在 B-PER 后面。本文面向想用 Python 从零跑通这套模型的从业者给出可复现的源码结构、数据集处理、参数设置和踩坑记录。如果你正在做简历解析、医疗实体抽取、法律文书要素识别这套方案值得投入两三天跑通 baseline。2. BERT-BiLSTM-CRF 三层到底各干什么选型理由与数据流转2.1 为什么不是 BERT Softmax 就完事很多人第一反应是BERT 后面接个全连接分类不就行了在英文 NER 上确实能跑但中文 NER 有几个特殊性。第一中文没有天然空格分词实体边界往往跨多个字Softmax 对每个字独立分类容易产生 B-ORG I-ORG I-ORG O I-ORG 这种断裂标签。第二实体标签之间存在强约束比如 I-PER 前面必须是 B-PER 或 I-PERSoftmax 无法建模这种转移关系。CRF 层通过转移矩阵学习标签间的合法路径在解码时用 Viterbi 算法找全局最优序列这是它比 Softmax 高 1-3 个 F1 的核心原因。那 BiLSTM 呢BERT 输出的每个字向量已经包含上下文但它是通过注意力机制全局加权得到的对序列的局部顺序敏感度不如循环网络。BiLSTM 在 BERT 输出之上再做一次前向和后向的序列建模相当于给每个字补充了「它左边几个字是什么状态、右边几个字是什么状态」的信息。在数据量中等几千到几万条标注的场景下BiLSTM 能稳定带来 0.5-1.5 个点的提升。如果数据量很大十万级以上BiLSTM 的增益会变小但也不会掉点所以它成了一个「加了不亏」的默认选项。2.2 数据从原始文本到标签序列的完整流转一条训练样本的流转路径是这样的原始句子 张三在阿里巴巴工作 → 按字切分 [张,三,在,阿,里,巴,巴,工,作] → 查 BERT 词表转成 token id → BERT 编码输出 768 维向量序列 → BiLSTM 输出隐藏状态序列 → 全连接层映射到标签数量维度 → CRF 层计算最优标签路径 → 得到 [B-PER,I-PER,O,B-ORG,I-ORG,I-ORG,I-ORG,O,O]。这里有个关键细节中文 BERT 的词表是按字为主的但也会包含一些常用词。如果你的标注是按词标注的比如 阿里巴巴 整体标 B-ORG而 BERT 把它切成了多个 token就需要做标签对齐。常见做法是只在第一个 subword 上保留标签其余 subword 标为 -100PyTorch 的 ignore_index或者用 B-/I- 的扩展策略。我一般用前者简单且不容易出错。2.3 标签体系设计BIO 还是 BIOESBIO 是最常用的B-XXX 表示实体开始I-XXX 表示实体内部O 表示非实体。BIOES 多了 E-XXX实体结束和 S-XXX单字实体。理论上 BIOES 能提供更明确的边界信息但在 BERT-BiLSTM-CRF 里CRF 的转移矩阵已经能学到边界约束BIOES 的增益很小反而增加了标注成本和标签数量。我的建议是如果标注团队已经按 BIO 标了别改如果从零开始BIO 足够。标签数量 2 × 实体类别数 1比如人名、地名、机构名三类就是 7 个标签。3. 用 Python 跑通 BERT-BiLSTM-CRF从环境到训练的最小闭环3.1 环境准备与依赖安装先确认 Python 版本建议 3.8 到 3.10太新的版本某些包轮子还没跟上。用 conda 或 venv 建独立环境别在系统 Python 里装。conda create -n ner python3.9 conda activate ner pip install torch1.13.1cu117 -f https://download.pytorch.org/whl/torch_stable.html pip install transformers4.28.0 pip install seqeval1.2.2 pip install numpy pandas tqdmtorch 版本根据你的 CUDA 版本选没有 GPU 就用 CPU 版。transformers 用 4.28 是因为它的 API 稳定再新的版本有些接口改名了。seqeval 用来算实体级别的 F1比按 token 算准确率靠谱得多。3.2 数据集格式与预处理脚本假设你的数据是每行一个「字 标签」对句子之间用空行分隔这是 CoNLL 格式的变体。下面这个脚本把原始数据转成 BERT 需要的 input_ids、attention_mask 和 label_ids。import torch from torch.utils.data import Dataset from transformers import BertTokenizer class NERDataset(Dataset): def __init__(self, file_path, tokenizer, label2id, max_len128): self.tokenizer tokenizer self.label2id label2id self.max_len max_len self.samples self._load_data(file_path) def _load_data(self, file_path): samples [] with open(file_path, r, encodingutf-8) as f: words, labels [], [] for line in f: line line.strip() if not line: if words: samples.append((words, labels)) words, labels [], [] continue parts line.split() if len(parts) 2: words.append(parts[0]) labels.append(parts[1]) if words: samples.append((words, labels)) return samples def __len__(self): return len(self.samples) def __getitem__(self, idx): words, labels self.samples[idx] # 按字切分后直接编码中文 BERT 基本一字一 token encoding self.tokenizer( words, is_split_into_wordsTrue, max_lengthself.max_len, paddingmax_length, truncationTrue, return_tensorspt ) input_ids encoding[input_ids].squeeze(0) attention_mask encoding[attention_mask].squeeze(0) # 标签对齐只在第一个 subword 保留标签其余设 -100 label_ids [] word_ids encoding.word_ids() previous_word_idx None for word_idx in word_ids: if word_idx is None: label_ids.append(-100) elif word_idx ! previous_word_idx: label_ids.append(self.label2id[labels[word_idx]]) else: label_ids.append(-100) previous_word_idx word_idx return { input_ids: input_ids, attention_mask: attention_mask, labels: torch.tensor(label_ids, dtypetorch.long) }这段代码的核心在word_ids()的处理。BERT tokenizer 在is_split_into_wordsTrue时会返回每个 token 对应原始词的索引中文场景下大部分是一对一但遇到英文单词或数字时可能一对多。previous_word_idx的判断保证只有第一个 subword 拿到真实标签其余标 -100这样损失函数会自动忽略它们。max_len128对大多数中文句子够用长文本可以调到 256但显存占用会翻倍。3.3 模型定义BERT BiLSTM CRF 的拼接模型部分用 transformers 的 BertModel 做底座上面接 BiLSTM 和 CRF。CRF 层可以用 torchcrf 这个库也可以自己写我一般用pytorch-crf。pip install pytorch-crf0.7.2import torch.nn as nn from transformers import BertModel from torchcrf import CRF class BertBiLstmCrf(nn.Module): def __init__(self, bert_path, num_labels, lstm_hidden256, lstm_layers1, dropout0.3): super().__init__() self.bert BertModel.from_pretrained(bert_path) self.dropout nn.Dropout(dropout) self.lstm nn.LSTM( input_size768, hidden_sizelstm_hidden, num_layerslstm_layers, bidirectionalTrue, batch_firstTrue ) self.classifier nn.Linear(lstm_hidden * 2, num_labels) self.crf CRF(num_labels, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) sequence_output outputs.last_hidden_state # [B, L, 768] sequence_output self.dropout(sequence_output) lstm_output, _ self.lstm(sequence_output) # [B, L, 512] emissions self.classifier(lstm_output) # [B, L, num_labels] # 把 attention_mask 转成 boolCRF 需要 mask attention_mask.bool() if labels is not None: # 训练时计算负对数似然损失 loss -self.crf(emissions, labels, maskmask, reductionmean) return loss else: # 推理时 Viterbi 解码 preds self.crf.decode(emissions, maskmask) return preds几个参数说明lstm_hidden256是单向隐藏维度双向拼接后是 512这个值在中文 NER 上比较通用调到 128 会欠拟合调到 512 提升不明显且显存吃紧。dropout0.3是 BERT 输出后的丢弃率小数据集可以调到 0.4-0.5 防过拟合。lstm_layers1通常够用两层 BiLSTM 在小数据上容易过拟合。CRF 的batch_firstTrue要和 LSTM 保持一致否则维度对不上。3.4 训练循环与关键超参训练脚本的核心是优化器分组BERT 层用小的学习率2e-5BiLSTM 和分类层用大的1e-3这是因为 BERT 已经预训练好了大学习率会破坏它的参数。from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup def build_optimizer(model, bert_lr2e-5, other_lr1e-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_decay0.01) return optimizer # 训练循环骨架 for epoch in range(10): model.train() for batch in train_loader: optimizer.zero_grad() loss model( input_idsbatch[input_ids].to(device), attention_maskbatch[attention_mask].to(device), labelsbatch[labels].to(device) ) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step()clip_grad_norm_的max_norm1.0是防梯度爆炸的后悔药BiLSTM 层偶尔会出大梯度。weight_decay0.01对 BERT 微调是常规操作。warmup 比例一般设总步数的 10%让学习率从 0 线性升到设定值再衰减。4. 训练完 F1 只有 0.6排查清单与五个血泪坑4.1 标签对齐错了模型在学噪声现象训练 loss 能降到很低但验证集 F1 一直在 0.3 以下预测结果里大量标签错位。原因word_ids()对齐时把标签赋给了错误的 subword或者 padding 位置的 -100 没设对。解决写个小脚本取一条样本把 input_ids 解码回文字和原始标签逐字打印对比确认每个字的标签位置正确。特别注意[CLS]和[SEP]对应的位置必须是 -100。4.2 学习率太大BERT 参数被冲垮现象第一个 epoch loss 就震荡得厉害验证集 F1 先升后猛降。原因BERT 层用了 1e-3 这种大学习率预训练权重被破坏。解决BERT 层学习率控制在 2e-5 到 5e-5其他层 1e-3 到 1e-2。如果还是不稳把 BERT 层冻结前几个 epoch 再解冻。4.3 实体类别不平衡O 标签占了 90%现象模型把所有字都预测成 O准确率看着有 0.9但 F1 是 0。原因O 标签样本太多损失被 O 主导。解决在 CRF 的 loss 里给非 O 标签加权或者用 focal loss 的思路。简单做法是在计算 loss 时对非 O 位置的 loss 乘一个系数比如 5.0。另外评估时一定要用 seqeval 算实体级 F1别被 token 准确率骗了。4.4 最大长度截断长实体被腰斩现象短句子 F1 正常长句子超过 max_len的实体识别率骤降。原因truncationTrue直接把超出部分砍掉如果实体跨在截断边界上就废了。解决要么把 max_len 调大到 256 或 512要么做滑动窗口切分切的时候保证实体不被切开。我一般先统计训练集句子长度分布取 95 分位数作为 max_len。4.5 推理时忘了加 attention_mask现象单条推理结果正常batch 推理时短句子的预测乱掉。原因padding 位置的 token 参与了 CRF 解码Viterbi 走了非法路径。解决推理时mask attention_mask.bool()必须传给crf.decode确保 padding 位置被屏蔽。这个坑很隐蔽因为单条测试时没有 padding不会暴露。5. 把 F1 从 0.85 推到 0.92三个我常用的进阶技巧5.1 用对抗训练做数据增强FGMFast Gradient Method是在 embedding 层加扰动让模型对微小变化鲁棒。实现很简单在训练循环里加几行class FGM: def __init__(self, model, epsilon1.0): self.model model self.epsilon epsilon self.backup {} def attack(self): for name, param in self.model.named_parameters(): if 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()之后、optimizer.step()之前先fgm.attack()再算一次 loss 并 backward然后fgm.restore()最后 step。这样相当于做了两次前向训练时间增加约 50%但在小数据上 F1 能涨 1-2 个点。epsilon1.0是扰动幅度太大反而掉点0.5 到 1.0 之间试。5.2 学习率调度用余弦退火 热重启线性衰减在后期学习率太小模型容易卡在局部最优。余弦退火让学习率周期性回升有机会跳出。transformers 里有现成的from transformers import get_cosine_schedule_with_warmup scheduler get_cosine_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps, num_cycles0.5 )num_cycles0.5表示半个余弦周期学习率从峰值降到 0。如果想用热重启设num_cycles2或 3学习率会降下去再升上来。我在数据量 5000 条以下时用热重启以上用单周期。5.3 模型融合不同随机种子的 checkpoint 取平均单模型 F1 到 0.90 以后提升空间就很小了。这时候性价比最高的做法是训 3-5 个不同随机种子的模型推理时对 emission 取平均再送 CRF 解码。注意不是对最终标签投票而是对 CRF 之前的 emission 矩阵平均这样 CRF 的转移约束仍然生效。def ensemble_predict(models, input_ids, attention_mask): emissions_list [] for model in models: model.eval() with torch.no_grad(): outputs model.bert(input_idsinput_ids, attention_maskattention_mask) seq_output model.dropout(outputs.last_hidden_state) lstm_out, _ model.lstm(seq_output) emissions model.classifier(lstm_out) emissions_list.append(emissions) avg_emissions torch.mean(torch.stack(emissions_list), dim0) mask attention_mask.bool() preds models[0].crf.decode(avg_emissions, maskmask) return preds三个模型融合通常能涨 1-1.5 个点五个模型涨 1.5-2 个点但再往上边际收益就很小了。融合的代价是推理时间线性增加线上服务要权衡。最后说个习惯我每次跑完实验会把 config、训练日志、验证集 F1、测试集 F1 存到一个experiments/目录下文件名带时间戳。这样过两周回头看能清楚知道哪个参数组合有效、哪个是玄学。命名实体识别这活儿细节决定成败而细节往往藏在那些你以为不重要、结果翻车的参数里。希望帮到你。本文还有配套的精品资源点击获取