
简介针对医疗文本的命名实体识别NER需求这份BiLSTM-CRF实现方案覆盖从数据预处理到模型训练、可视化的完整流程适合NLP研究者、医疗信息处理开发者及入门学习者快速搭建实验环境。包内共24个文件以Python脚本、txt说明文档、xlsx数据集和json词表为主png图展示训练效果与标签分布压缩包仅2.23MB结构清晰便于按步骤使用。目前已有262人学习下载。资源包含yidu-s4k医疗语料及CCKS2019任务说明附带可训练模型文件与pyc缓存便于复现结果可视化模块支持训练效果展示说明文档则给出参数设置与解读指导从数据切分、词向量化到CRF约束优化均有对应代码。1. 认识命名实体识别为什么选 BiLSTM-CRF 而不是更花哨的模型很多做文本信息的工程师都遇到过这样的需求从新闻正文里抽人名、地名、机构名从在线问诊文本里抽症状名、药名从工单记录里抽产品型号和故障类型。这类任务在自然语言处理NLP里叫命名实体识别几乎所有信息抽取系统的第一环都是它。要说哪个模型组合最经典就是 BiLSTM-CRF——一个双向 LSTM 编码器负责读上下文一个条件随机场负责约束标签顺序。这个组合不花哨但它训练成本低、推理快在标注数据只有几千条的中小规模场景下效果往往比直接堆 BERT 更稳。这篇笔记要拆的就是一套可直接复现的 BiLSTM-CRF 命名实体识别实现从数据标注、字词特征到训练与预测全链路讲透适合正在做文本抽取、信息结构化的从业者照着落地。2. BiLSTM-CRF 的内部结构双向编码为什么能读懂上下文CRF 为什么能管住标签顺序2.1 BiLSTM 层正向反向拼接让每个字都带上完整上下文只用一个字本身预测它的实体标签很多时候是猜不准的。比如李娜出现在网球新闻里是运动员出现在歌手列表里是音乐人单独看字没有任何信息。普通 RNN 能看前面的字但常规 RNN 在长序列上存在梯度衰减问题。LSTM 用遗忘门、输入门、输出门三个门控结构控制信息保留遗忘门决定上一时刻的细胞状态保留多少输入门决定当前输入写入多少输出门决定当前隐状态输出多少。三个门组合起来让 LSTM 能在一句话里记住很早以前的人名信息同时忽略无关噪声词这是序列标注任务选择 LSTM 而不是普通 RNN 的根本原因。具体到命名实体识别文本可能很长Transformer 类模型能捕捉更长的上下文但 BiLSTM 真正的优势在于显存占用小、训练快、部署容易。正向 LSTM 按顺序从前往后读句子反向 LSTM 从后往前读每个位置的隐状态是正向向量和反向向量拼起来的结果。注意这里用的是拼接而不是相加因为相加会让正反向信息互相冲抵拼接则完整保留两个方向的独立信息。下面的代码是 BiLSTM 编码器的核心段注释标明了每一步的作用import torch import torch.nn as nn class BiLSTMEncoder(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_tags, pad_idx0): super().__init__() # 词id - 稠密向量padding_idx让pad向量保持为0 self.embedding nn.Embedding(vocab_size, embed_dim, padding_idxpad_idx) # bidirectionalTrue表示输出双向隐状态拼接 self.lstm nn.LSTM( input_sizeembed_dim, hidden_sizehidden_dim, num_layers1, batch_firstTrue, bidirectionalTrue ) # 从隐状态映射到标签得分输出维度为标签类别数 self.hidden2tag nn.Linear(hidden_dim * 2, num_tags) def forward(self, tokens): emb self.embedding(tokens) # (batch, seq, embed_dim) outputs, _ self.lstm(emb) # (batch, seq, hidden_dim*2) logits self.hidden2tag(outputs) # (batch, seq, num_tags) return logitshidden_dim * 2就是双向拼接后的维度num_tags在 BIO 标注下至少是实体类型数乘以 2 再加 1。batch_firstTrue让输入张量统一为(batch, seq, feature)这样调试打印形状时不容易搞混维度。如果你是在 MATLAB 里搭原型验证思路LSTM 的参数定义方式基本能照搬但生产环境我更推荐 PyTorch梯度计算效率和生态要完善得多。2.2 CRF 层把相邻标签的约束写成可学习的转移矩阵BiLSTM 输出的是每个字属于各标签的得分。拿北京来说北对应 B-LOC 的得分可能最高。如果直接用 softmax 在每个位置取最大得分就会出现一个常见错误上一个词是 B-PER紧接着下一个词是 I-ORG这种标签顺序明显违反 BIO 规则。因为模型只看每个位置独立的得分没有能力约束相邻标签的合法性。CRF 就是来解决这个问题的。它维护一个形状为(num_tags, num_tags)的转移矩阵transitions[i][j]表示从标签 i 转移到标签 j 的得分。一条完整标签序列的得分等于每个位置的发射得分加上相邻标签的转移得分再加句子粒度的起始和结束惩罚。训练时最大化正确标签序列的对数似然预测时用 Viterbi 算法在全部合法路径里找得分最高的那一条。写成代码是下面的核心逻辑def sequence_score(feats, tags, mask): feats: (batch, seq, num_tags) 发射得分 tags: (batch, seq) 标签id mask: (batch, seq) 0/11表示有效位置 batch_size, seq_len, num_tags feats.shape score torch.zeros(batch_size, devicefeats.device) for i in range(seq_len): valid mask[:, i] if i 0: score start_transitions[tags[:, 0]] else: prev_tags tags[:, i - 1] curr_tags tags[:, i] score transitions[prev_tags, curr_tags] * valid score feats[torch.arange(batch_size), i, tags[:, i]] * valid return score end_transitions[tags[:, -1]] * mask[:, -1]这段是前向得分计算的示意不是完整 CRF 类重点在于展示转移分数怎么累加进序列总得分的。实际实现里通常把transitions做成可学习参数放在 CRF 模块内部并实现viterbi_decode方法。很多初学者在这里漏掉不同位置的 mask导致 padding 位置的标签也参与转移计算这个问题在避坑章节会详细展开。CRF 相比 HMM 的最大区别也在这里HMM 的转移矩阵是统计出来的CRF 的转移矩阵是训练中学出来的并且能直接接受 BiLSTM 输出的发射分数作为输入。2.3 对比 BERTSoftmax什么场景下 BiLSTM-CRF 仍然值得选现在做 NERBERT 已经是常见基线了BiLSTM-CRF 为什么还有存在空间看下面这个对比表角度BiLSTM-CRFBERTSoftmax训练数据需求几千条标注就能收敛数据少时容易过拟合GPU 显存12G 足够至少 6G 以上单条推理延迟毫秒级十毫秒级起标签顺序约束CRF 显式建模依赖模型隐式学习可解释性转移矩阵可直接打印查看黑匣子难排查在标注数据非常少、或部署设备只有 CPU 的场景我一般会先用 BiLSTM-CRF 打底跑通流程后再根据效果决定要不要上 BERT。如果数据量到了几万条BERTCRF 通常更强但不在本文范围。数据量中等、目标实体类型固定时BiLSTM-CRF 训练一轮只要几分钟效果已经够用这才是它至今仍在生产环境被大量使用的原因。这一章花了较大篇幅讲原理是因为后面所有代码都建立在这两个组件的理解上。只复制粘贴代码而不明白转移矩阵和 mask 怎么配合改数据时很容易踩暗坑。3. 数据准备与特征表示标签体系、字词特征和数据标注3.1 标签体系BIO 还是 BIOES标注是 NER 的起点标注格式直接决定模型学什么。最常用的是 BIO 体系B 表示实体起始I 表示实体内部O 表示非实体。比如小红在北京人名类型是 PER地名类型是 LOC标注结果如下小 B-PER 红 I-PER 在 O 北 B-LOC 京 I-LOCBIOES 在 BIO 基础上把每个实体最后一个字标为 E单字实体标为 S。这样做的好处是模型能明确学到实体的边界和长度。单字实体多的语料用 BIOES 会明显减少实体粘连错误。实际项目里我一般无脑选 BIOES因为成本几乎一样解码结果更稳定。如果标注工具导出的原始格式是 BIO可以用一段规则无损转换成 BIOES。3.2 字级特征还是词级特征中文 NER 有个经典问题用字还是用词。用词依赖分词工具分词错误会被带入实体识别结果比如武汉市长江大桥会被切分成奇怪的样子。用字没有分词前置环节且字向量能覆盖未登录词OOV缺点是长词需要模型自行拼出边界。实践中字级特征是主流选择词边界信息可以作为一个额外特征列带进去让模型既看字、也看词边界。这个对比在代码层面的体现就是 embedding 层怎么建。字级方案下vocab 就几千个常用字加 UNK 标记embedding 矩阵很小训练快。词级方案下词表到十几万embedding 矩阵巨大训练慢。从工程角度看字级模型的开销小得多这也是我推荐字级起步的原因。分词结果作为特征时哪怕不完整也不会产生灾难性影响模型会在训练里自动学习分词边界和实体边界之间的对应关系。3.3 数据标注工具与格式转换常用标注工具有 Prodigy、Label Studio、brat。Prodigy 交互式标注效率高Label Studio 支持多人协作brat 免费但界面老。不管用哪个工具导出格式都可以整理成统一的 JSONL 行格式。每行一个对象包含两个字段text是原始句子label是由(start, end, entity_type)三元组组成的列表。{text: 王小明的病情已经好转, label: [[0, 3, PER], [5, 7, SYM]]}这个格式可以用下面的脚本转换成模型训练用的 BIOES 序列化标签def build_label_matrix(text, entities): entities: [(start, end, entity_type), ...] - 返回每个字的标签 labels [O] * len(text) for start, end, etype in entities: if end - start 1: labels[start] fS-{etype} # 单字实体 else: labels[start] fB-{etype} labels[end - 1] fE-{etype} for i in range(start 1, end - 1): labels[i] fI-{etype} return labels转换时注意end是开区间还是闭区间这是所有标注工具格式里最容易出错的一个细节。我遇到过两次标注师给的是(start, end]换到开区间后整体偏移了一个字的情况排查很久才定位到。转换完可以把结果打印十行人工检查一遍确认单字实体和连续实体都正常。3.4 标签不平衡与实体类别混淆数据集标注完成后O 类标签通常占九成以上真实实体占比很小。这个比例失衡在训练里会让模型倾向于把所有位置预测为 O。常见做法包括给损失函数加类别权重、对 O 标签的梯度做衰减、用带约束的解码限制实体长度上限。类别权重具体怎么加在训练章节的损失代码里会写出来。另一个同样常见的问题是实体类型定义重叠。把数量时间也定义为实体类型会和日期等类型互相竞争同一个词被多个类别争抢。先和业务方确认实体类型的判定标准再开始标注永远比事后重新标注划算这个返工成本是整个 NER 项目里最高的。4. 复现可落地的 BiLSTM-CRF数据加载、模型组装、训练与预测参数4.1 构建数据集与词表数据集第一步把原始文本转成 id 序列同时把标签转成 id 序列。下面这段代码完成数据加载与 tokenizefrom torch.utils.data import Dataset class NERDataset(Dataset): def __init__(self, texts, labels, char2id, tag2id): self.texts texts self.labels labels self.char2id char2id self.tag2id tag2id def __len__(self): return len(self.texts) def __getitem__(self, idx): text self.texts[idx] # 原始字符串 label self.labels[idx] # 逐字标签列表 ids [self.char2id.get(c, self.char2id[UNK]) for c in text] tag_ids [self.tag2id[t] for t in label] return ids, tag_ids这个类返回每个样本的字 id 序列和标签 id 序列。char2id.get(c, char2id[UNK])保证训练时没见过的字不会因为查不到 id 而崩溃而是落到专门的 UNK 槽位模型在 UNK 向量上学会对未见过字符做近似处理。tag2id里把O的 id 放在 0 位置这样 padding 位置用 0 填充CRF 里也能利用这个约定快速判断 padding 标签。接着是 batch 化的 collate 函数def collate_fn(batch): ids_list, tag_list zip(*batch) max_len max(len(x) for x in ids_list) batch_ids, batch_tags, batch_mask [], [], [] for ids, tags in zip(ids_list, tag_list): length len(ids) pad_len max_len - length batch_ids.append(ids [0] * pad_len) batch_tags.append(tags [0] * pad_len) # 0 PAD/O batch_mask.append([1] * length [0] * pad_len) return (torch.tensor(batch_ids), torch.tensor(batch_tags), torch.tensor(batch_mask))batch_mask的关键作用在 CRF 损失里体现padding 的转移计算全靠它排除。batch 内最大长度可以加一个max_len128的硬上限避免超长句撑爆 batch。长句截断时最好按句子边界切不能硬切否则一个实体被拦腰截断后标签会变成非法序列。4.2 模型组装BiLSTM 编码器与 CRF 解码器现在把编码器和 CRF 组合成完整模型。完整的 BiLSTM 代码可以直接复制使用import torch import torch.nn as nn import torchcrf class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, tag_size, embed_dim100, hidden_dim128): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_dim, batch_firstTrue, bidirectionalTrue) self.hidden2tag nn.Linear(hidden_dim * 2, tag_size) self.crf torchcrf.CRF(tag_size, batch_firstTrue) def forward(self, ids, mask): emb self.embedding(ids) outputs, _ self.lstm(emb) feats self.hidden2tag(outputs) return self.crf.decode(feats, maskmask) def loss(self, ids, tags, mask): emb self.embedding(ids) outputs, _ self.lstm(emb) feats self.hidden2tag(outputs) return -self.crf(feats, tags.long(), maskmask)embed_dim100是字向量维度中文语料里 100 维是常见起步值追求效果可换成 200 维。hidden_dim128是双向 LSTM 隐层维度显存紧张可降到 64但实体类型多于 10 类时建议保持 128 以上。随机初始化即可CRF 的转移矩阵会在训练中自行调节。torchcrf库封装了前向分数计算、log-sum-exp 归一化和 Viterbi 解码比自己手写要稳定得多。4.3 训练循环损失计算、梯度裁剪与早停训练循环需要梯度裁剪和早停。下面这段可以抄进自己的项目from torch.optim import Adam model BiLSTMCRF(vocab_sizelen(char2id), tag_sizelen(tag2id)) optimizer Adam(model.parameters(), lr0.001) best_f1 0.0 no_improve 0 for epoch in range(50): model.train() for batch in train_loader: pad_ids, pad_tags, mask batch loss model.loss(pad_ids, pad_tags, mask) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 梯度裁剪 optimizer.step() f1 evaluate(model, dev_loader) if f1 best_f1: best_f1 f1 torch.save(model.state_dict(), best_model.pt) no_improve 0 else: no_improve 1 if no_improve 3: breaklr0.001对应 Adam 是稳妥的选择发现 loss 震荡就降到 0.0005。clip_grad_norm_(..., 1.0)这行非常重要BiLSTM 在长句子上极易出现梯度范数暴涨不加这行 loss 会突然变成 nan。早停条件是验证集 F1 连续 3 个 epoch 不提升就停最多跑 50 轮正常情况 1020 轮就能收敛。验证集 F1 的计算逻辑放到下一节讲。4.4 预测解码与 F1 评估预测时调用model.forward返回 CRF 解码好的标签序列换算成实体列表即可输出结果def extract_entities(text, pred_tags): entities [] i 0 while i len(text): tag pred_tags[i] if tag.startswith(B-) or tag.startswith(S-): etype tag[2:] start i i 1 while i len(text) and ( pred_tags[i].startswith(I-) or pred_tags[i].startswith(E-) ): i 1 end i # 实体区间为 [start, end) entities.append((start, end, etype)) else: i 1 return entities遍历逻辑把 B 和 S 开头都作为实体起始I 和 E 作为实体继续遇到 E 会自然终止这样 BIOES 和 BIO 两种标注都能兼容。实体合并完text[start:end]取出实体文本与人工标注比对算 F1。常用评估库是seqeval直接传入pred_labels和gold_labels就能返回精确率、召回率和 F1。4.5 超参数速查与效果预期参数值说明embed_dim100字向量维度数据多可加大hidden_dim128LSTM 隐层维度num_layers1超过 1 层收益小显存翻倍lr0.001Adam 初始学习率clip_norm1.0梯度裁剪阈值batch_size32根据显存调整max_seq_len128长句截断阈值这些参数在常见中文 NER 数据集上训练一轮大约几十秒到几分钟F1 基线通常在 0.850.90具体取决于语料难度。如果跑出来和这个范围差很远先别怀疑模型去检查数据标注质量和词表覆盖。5. 避坑手册五条让 NER 模型翻车的高频问题5.1 训练 loss 不降反升学习率与梯度爆炸现象前几个 epoch 的 loss 一直徘徊在几十附近偶尔直接变成 nan训练曲线没有任何下降趋势。原因学习率相对当前数据偏大加上 LSTM 在长序列上的梯度范数快速累积参数更新步长过大导致 loss 震荡。另一个隐蔽原因是开启了自动混合精度CRF 的 log-sum-exp 在 fp16 下非常容易溢出。解决先把学习率降到 0.0005确认clip_grad_norm_开启。如果用了 AMP先关闭跑通再考虑加速。同同时打印每个 batch 的梯度范数看到超过 5 就说明裁剪阈值需要调低。5.2 解码结果全是 O类别比例失衡与冷启动现象训练正常收敛验证集准确率很高但预测时整个字符串全被标为 O一个实体都识别不出来。原因最常见是训练数据里实体类别标注太少模型学到了全部输出 O 也能拿不错准确率的懒惰解。另一个常见原因是tag2id映射写错某个实体标签 id 和 O 的 id 对调CRF 解码时认为 O 是最优路径。解决计算各类别占比给损失函数加类别权重比如把 O 类的权重设为 0.1实体类权重保持 1.0。再打印一个 batch 的真实标签分布和tag2id对照确认 id 没有写反。如果数据量实在太小用训练好的省份词向量初始化可以缓解冷启动。5.3 padding 导致标签错位mask 没被正确传给 CRF现象验证集 F1 比训练集低一大截且错误集中在句子的开头和结尾几个 token 上。原因collate 函数生成的 mask 在传入 CRF 时没有对齐。padding 位置填充的不是 0 标签而是真实标签 idCRF 把 padding 位置的转移也算进了路径得分相当于强行给非法路径加了分。解决检查 collate 函数里 padding 标签填的是tag2id[O]还是专门为 padding 准备的 id。确认 mask 语义是 1 有效 0 无效并在 loss 里打印mask.sum()看看一个 batch 内有多少有效 token。如果 mask 和标签不匹配第一时间就能发现。5.4 出现 PER 后接 ORG 的非法标签序列预测时用了 argmax现象预测出的实体边界看起来很合理但标签顺序明显违反 BIO 规则比如 B-PER 后面直接跟 I-ORG。原因预测时直接对 BiLSTM 输出的 feats 取 argmax忽略了 CRF 的转移约束。BiLSTM 只负责给每个位置打分标签之间的合法性完全由 CRF 的转移矩阵决定。解决预测必须走 CRF 的viterbi_decode不能对 feats 做 argmax。torchcrf的decode方法返回标签 id 序列注意传入 mask。有些版本不传 mask 会一直解码到固定长度要在调用时显式传maskmask。5.5 测试集 F1 波动大随机种子与数据切分现象同一份数据两次训练得到的 F1 差 2 个百分点以上换一次数据集划分结果又变。原因没有固定随机种子训练验证切分不稳定。O 标签占比大不同划分里实体的分布差异会被放大尤其当同一文档的前后句被分到训练和测试时模型相当于见过答案。解决固定 Python、NumPy、PyTorch 三个随机种子用train_test_split时设置stratify按实体数量分层切分按句子级而不是文档级。我一般还会把随机种子作为超参数存进实验记录方便复现。这个环节不顺的话后面所有对比实验都是不可信的。6. 进阶用法把 BiLSTM-CRF 用到新闻处理与在线问诊场景6.1 用预训练嵌入做热启动随机初始化的字向量要学出语义关系通常需要大量标注数据。手头语料量不足时一个非常实用的做法是加载中文预训练字向量比如腾讯 AI Lab 开源的字向量文件。加载时把char2id里的每个字去词表查向量查到就填充到 embedding 矩阵查不到保留随机值。pretrained load_vectors(tencent_embeddings.txt) embedding_weight torch.randn(len(char2id), embed_dim) hit 0 for char, idx in char2id.items(): if char in pretrained: embedding_weight[idx] torch.tensor(pretrained[char]) hit 1 print(f命中率: {hit / len(char2id):.2f}) model.embedding.weight.data.copy_(embedding_weight)加载后模型第一轮 loss 会明显更低收敛速度也更快。新闻处理场景里语料领域集中时还可以用 word2vec 在领域语料上增量训练字向量效果优于通用向量。6.2 把 BERT 当老师蒸馏到 BiLSTM-CRF如果手头有 GPU 能训练 BERT它的知识可以转移到 BiLSTM-CRF 上。做法是用 BERT 对训练集生成每个 token 的标签概率分布作为 soft label让 BiLSTM-CRF 学这个分布而不仅仅是硬标签。损失函数变成正确标签的交叉熵加上和 soft label 的 KL 散度权重各占一半左右。这样部署环境不需要跑 BERT推理速度回来了效果比直接用 BiLSTM-CRF 高几个点。在线问诊这类数据比较敏感的医疗场景蒸馏后的模型逻辑更可控、推断更快也方便做规则层审计我实际落地时都是用这个组合。6.3 置信度过滤与规则兜底最后一个常用技巧是结合 CRF 的转移得分做置信度过滤。Viterbi 解码得到的路径得分除以序列长度可以作为一条粗糙的置信度估计。新闻处理场景里通常把低于 0.5 阈值的实体过滤掉宁缺毋滥再补一条正则规则限制实体长度例如中文人名上限 4 个字把明显不合法的候选直接丢弃。即使 NER 模型某段时间效果不佳下游流程也不会收到脏数据。在我做过的在线问诊文本抽取项目里这套组合拳是最终落地方案先用蒸馏的 BiLSTM-CRF 做主模型再用规则兜底过滤明显错误的药品名和症状名。从那以后我每次做实体抽取项目都强制走一遍写数据转换脚本、检查标签分布、固定随机种子、对比蒸馏模型这个流程至少能少掉一半的返工调试时间。希望帮到你。本文还有配套的精品资源点击获取