ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

用BiLSTM-CRF实现中文电子病历命名实体识别:完整实践指南

2026/10/1 12:37:41 拓冰建站 浏览量
用BiLSTM-CRF实现中文电子病历命名实体识别:完整实践指南 简介面向中文医疗文本信息抽取场景这份Python项目整合了BiLSTM-CRF模型的完整训练与预测流程适合自然语言处理、电子信息等专业的课程设计或毕业设计参考。压缩包共999个文件含17个源码文件、798个TXT数据文件以及模型checkpoint、评估指标、标签文件等整体大小约84.55MB。已有127人学习浏览。项目提供电子病历语料库的训练/测试划分和多轮result_metric结果可直观观察模型在各实体类别上的表现配合项目说明能帮助理解中文分词、字向量表示、序列标注及条件随机场解码等关键环节。文件目录按数据、训练、评估组织便于学习者快速定位源码与语料适合作为复现医疗命名实体识别任务的参考资料。1. 一份能跑的BiLSTM-CRF源码能帮你解决什么拿到一份出院小结让住院医师人工抽症状、诊断、药名平均要花20分钟把同样的文本丢给基于BiLSTM-CRF的中文电子病历命名实体识别模型几秒钟就能给出结构化结果。这就是这份源码的核心价值它把「看文本→找实体→归类」这件事从人工流程变成了可复现的Python程序。刚接触NER的人可以用它理解序列标注的完整链路做医疗NLP的工程师可以直接拿它当基线模型再往上替换BERT或者加入词典特征。本文会按「选型逻辑→数据预处理→模型实现→训练调参→避坑→进阶技巧」的顺序展开把每个环节的参数和踩过的坑都讲清楚。2. 为什么是BiLSTM-CRF中文医疗NER的选型逻辑2.1 BiLSTM负责“读”CRF负责“写”两个模块的分工命名实体识别本质上是序列标注问题。对中文电子病历来说输入是一串字符输出是每个字符对应的标签比如「B-症状」「I-症状」「O」。BiLSTM-CRF这个组合里BiLSTM双向长短期记忆网络负责编码——它从左往右和从右往左各读一遍句子把每个字符的上下文信息融合成一个隐状态向量CRF条件随机场负责解码——它不只看单个字符的得分而是把整条标签序列放在一起算一个全局得分从而学到标签之间的转移约束。这两个模块的分工非常明确。BiLSTM擅长捕捉长距离依赖比如「患者咳嗽两周伴胸痛」里「咳嗽」和「胸痛」都是症状医生表述时的距离可能隔着十几个字CRF擅长约束输出结构比如「B-症状」后面必须是「I-症状」或「O」不可能直接跳到「B-药物」。单纯用BiLSTM做softmax分类很容易出现标签跳变单纯用CRF又缺乏对文本语义的深度理解。两者组合恰好各补短板。对于医疗文本这种实体边界模糊、术语密集的场景这种搭配至今仍是性价比很高的基线方案。2.2 医疗文本为什么不能直接套通用NER方案通用领域的NER工具在新闻、百科文本上表现不错但直接拿来做电子病历识别效果往往明显下滑翻车点集中在三处。第一实体类型完全不同。通用NER标注的是人名、地名、组织机构而病历里需要识别的是症状、疾病、药物、检查项目、手术操作这些类别在通用语料里几乎没有。第二医疗文本有大量缩写和混合表达比如「T 37.8℃」「WBC 12.5×10^9/L」「ivgtt」数字、英文、单位连在一起字符级别的模型很容易把边界切错。第三病历口语化严重像「无明显诱因」「偶有」「诉」这类高频口语词在标准语料里很少出现。所以做中文电子病历NER第一步不是选多先进的模型而是先建立一个贴合医疗场景的标签体系再把模型在这个体系上重新训练。我一般会先把标签体系定成症状、疾病、药物、检查、手术五类必要时拆分出「体征」「部位」等细分类。标签粒度直接影响模型上限定粗了信息不够用定细了标注成本翻倍且模型容易混淆相邻类别。2.3 环境准备用Python跑通的最小依赖清单这份源码既然带了项目说明通常默认环境是Python 3.6以上加PyTorch。动手之前先确认几个核心依赖装好避免训练到一半发现缺库。pip install torch1.9.0 pip install numpy pandas pip install scikit-learn pip install tqdm版本不需要完全锁死但建议PyTorch不要低于1.6因为后面用到的某些API在老版本上行为不一致。scikit-learn主要用来算评估指标虽然BiLSTM-CRF的评估一般自己写混淆矩阵但用classification_report看每类实体的精确率、召回率、F1值会更直观。装完后直接在项目根目录新建一个config.py把数据路径、标签列表、模型参数集中放进去后面所有脚本都从这儿读取参数省得每次在命令行里敲一堆配置。3. 从病历文本到训练样本预处理与BIO标注3.1 中文电子病历的文本特征与标注粒度电子病历和普通新闻文本最大的差异在于句式高度模式化。现病史里经常出现「患者于3天前无明显诱因出现腹痛呈持续性伴恶心、呕吐」主诉里则是「咳嗽、咳痰伴发热2天」。这种模式化一方面降低了语义复杂度另一方面也带来了标注陷阱——同样的词在不同上下文里属于不同实体「发热」在现病史里是症状在检查记录里可能就是体温测得的客观结果。标注粒度上我建议按字符级做BIO标注。B表示实体首字符I表示实体内部字符O表示非实体。不用BMES方案是因为病历实体多数是2到6个字的短实体BIO足够区分边界且实现更简单。构建标注数据时用「字」而不是「词」作为最小单位理由有两个分词工具在医疗术语上准确率不稳定「肺源性心脏病」这类复合词经常被切碎字符级标注能避免分词错误向下游传播让模型自己学边界。3.2 构建字符索引与标签编码的Python代码把原始标注数据转成模型能读的数字索引是训练前最机械也最容易出错的一步。下面这段代码是一个标准的转换流程输入是分好行的文本和对应的标签行。def build_vocab(texts, labels): word2idx {PAD: 0, UNK: 1} label2idx {O: 0} for text, label_seq in zip(texts, labels): for char in text: if char not in word2idx: word2idx[char] len(word2idx) for label in label_seq: if label not in label2idx: label2idx[label] len(label2idx) return word2idx, label2idx def encode_sequence(text, label_seq, word2idx, label2idx, max_len200): ids [word2idx.get(c, word2idx[UNK]) for c in text[:max_len]] label_ids [label2idx[l] for l in label_seq[:max_len]] # 长度不足 max_len 的部分补 0补齐标签也用 0 对齐 ids ids [0] * (max_len - len(ids)) label_ids label_ids [0] * (max_len - len(label_ids)) return ids, label_ids这段代码里有三个细节值得注意。word2idx里必须预留PAD和UNK医疗文本里数字、特殊符号层出不穷训练集不可能覆盖全部字符遇到没见过的字符统一映射到UNK而不是报错。label2idx里O的编号必须为0这样和PAD位置对齐后padding部分的标签也正好是O不会干扰损失计算。max_len200是经验值病历里现病史段落动辄几百字但绝大多数实体集中在前150字内截断比硬塞更有效后面避坑章节会细说。3.3 序列长度与批处理的取舍电子病历的文本长度分布极不均匀主诉可能只有20个字现病史可能长达500字。直接按最长序列padding会让batch里大部分计算浪费在无意义的PAD字符上。我常用的做法是设定两个长度max_len200作为硬上限超过部分直接截断batch_size32作为默认批大小配合PyTorch的pack_padded_sequence处理变长序列。处理变长序列时需要把batch内的样本按真实长度降序排序然后传给nn.utils.rnn.pack_padded_sequence。如果不做这一步BiLSTM会对PAD位置也计算隐状态既浪费算力还可能让CRF学到「PAD后面跟什么标签」这种无意义模式。排序、打包、解包这三步是一个固定的套路建议把这段逻辑封装成工具函数所有训练循环复用。4. 用PyTorch实现BiLSTM-CRF核心代码与参数设置4.1 网络结构的代码骨架先看模型主体的PyTorch实现这是整份源码里最核心的部分。注释里标了每个模块的作用方便对照结构理解。import torch import torch.nn as nn from torchcrf import CRF class BiLSTM_CRF(nn.Module): def __init__(self, vocab_size, tag_size, embedding_dim128, hidden_dim256): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.bilstm nn.LSTM(embedding_dim, hidden_dim // 2, num_layers1, bidirectionalTrue, batch_firstTrue) self.dropout nn.Dropout(0.5) self.fc nn.Linear(hidden_dim, tag_size) self.crf CRF(tag_size, batch_firstTrue) def forward(self, input_ids, mask): emb self.embedding(input_ids) lstm_out, _ self.bilstm(emb) lstm_out self.dropout(lstm_out) emissions self.fc(lstm_out) return emissions, lstm_out def loss(self, emissions, tags, mask): return -self.crf(emissions, tags, maskmask, reductionmean)padding_idx0让PAD位置的embedding始终是零向量模型不会在这些位置学到无意义的信息。LSTM的hidden_dim256表示两个方向各输出128维拼接后变成256维这样设计比单设256维再双向拼接少一半参数量。torchcrf这个库封装了Viterbi解码和负对数似然计算用起来方便但要注意mask参数必须和输入序列的PAD位置严格对应否则CRF会算错转移分数。4.2 损失函数与训练循环CRF的score计算CRF的训练和普通分类网络有一个关键区别它的损失函数计算的是整条序列的负对数似然而不是逐字符的交叉熵。这意味着预测时要一次性输出整个句子的标签序列评估时也要在句子级别比较。训练循环的代码如下。def train_epoch(model, dataloader, optimizer, device): model.train() total_loss 0 for batch in dataloader: input_ids, tag_ids, mask batch input_ids, tag_ids, mask input_ids.to(device), tag_ids.to(device), mask.to(device) emissions, _ model(input_ids, mask) loss model.loss(emissions, tag_ids, mask) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() total_loss loss.item() return total_loss / len(dataloader)这段代码里藏着两个容易翻车的细节。clip_grad_norm_设置了5.0的梯度裁剪阈值——BiLSTM反向传播时梯度很容易爆炸尤其当序列长度偏长时不裁剪的话loss会突然变成NaN训练直接中断。model.loss返回的是reductionmean的负对数似然即每个batch的平均loss观察训练曲线时数值会在一个合理区间内波动而不是随着batch大小变化。4.3 5个必调参数embedding_size、hidden_size、lr、batch_size、clip参数配置直接决定模型是能收敛还是原地踏步。我按踩坑频率排个序学习率是最关键的超参数BiLSTM-CRF最佳范围一般在0.001到0.002调大一个数量级loss直接不降调小一个数量级训练慢到怀疑人生。对比Adam和SGD我常用Adam但需要同步调小学习率比如Adam用0.001SGD就得0.01起步。embedding_size设128是性价比很高的选择医疗语料字符集规模也就几千设512不会带来明显收益反而让模型参数多出一截。hidden_size设256仍是稳妥值如果训练数据超过10万实体可以试512但小数据集上256到512的提升很有限。batch_size受显存制约32是个默认值显存够用的话提到64能让训练更稳定但要注意BN在这类序列标注模型里一般不用。梯度裁剪阈值clip设5.0如果观察到loss波动频繁可以降到3.0反之如果loss平稳但收敛慢可以升到10.0。4.4 训练日志怎么看loss下降与实体级别的指标训练时不要只盯着loss曲线。loss下降只说明模型在拟合训练集真正需要看的是每一类实体的精确率、召回率和F1值。我一般每个epoch结束跑一遍验证集用一个简单的脚本输出按类别的指标。from sklearn.metrics import classification_report import numpy as np def evaluate(model, dataloader, id2label, device): model.eval() true_labels, pred_labels [], [] with torch.no_grad(): for input_ids, tag_ids, mask in dataloader: emissions, _ model(input_ids.to(device), mask.to(device)) pred model.crf.decode(emissions, maskmask.to(device)) # 把PAD位置过滤掉只保留真实长度的标签 for i, seq_len in enumerate(mask.sum(dim1).tolist()): true_labels.extend(tag_ids[i][:seq_len].tolist()) pred_labels.extend(pred[i][:seq_len]) target_names [id2label[i] for i in sorted(set(true_labels)) if id2label[i] ! O] print(classification_report(true_labels, pred_labels, target_namestarget_names, digits4))crf.decode用Viterbi算法返回概率最大的标签序列注意它返回的已经是解码后的整数序列不需要再做argmax。过滤PAD位置这一步很关键——如果不过滤PAD位置会被模型预测成O虽然不影响实体指标但会拉低整体准确率让你对模型真实水平产生误判。评估时只看实体类别的F1O类别的F1参考意义不大因为非实体占绝大多数会把整体指标抬得很虚。5. 中文医疗NER的5个高频避坑记录5.1 标注不一致导致实体边界漂移现象模型在「咳嗽」上预测为B-症状 I-症状在「干咳」上却预测成O O同类词结果差异很大验证集F1卡在0.7上下不去。 原因检查标注样本后会发现同一个「咳嗽」在有些句子标的是症状在另一些句子标的是O。人工标注时对实体边界理解不一致模型学到的是混乱的边界规则。 解决标注规范里明确给三条硬规则一是「症状类实体只标异常表现不标正常生理现象」二是「修饰词不进入实体比如『剧烈腹痛』只标『腹痛』」三是「实体内部不允许嵌套其他实体」。标注完成后跑一遍一致性校验脚本找出所有覆盖同一段文本但标签不一致的样本人工复核。5.2 症状与疾病混标的后果现象模型频繁把「高血压」「糖尿病」识别为症状而这类词在标准标签体系里应该属于疾病诊断。 原因电子病历里「高血压」既出现在主诉「发现高血压3年」也出现在诊断列表「高血压病2级」。标注时如果不区分上下文语境模型就无法分清这个实体到底是症状还是诊断。 解决需要结合存在位置区分别类。诊断列表里的疾病名标为疾病既往史里出现的慢性病名也标为疾病而现病史里患者的主观不适标为症状。「腹部包块」这种既可能是体征也可能是症状的词统一归入症状并在规范里强制约定。5.3 OOV问题数字、英文缩写和检查指标现象模型对「WBC 12.5×10^9/L」这类包含英文和数字的文本几乎完全识别失败WBC被标成了O。 原因字符级模型遇到数字和英文时UNK占比太高。训练集里数字以「12.5」「10^9」的形式出现测试集如果出现「8.7×10^3」字符组合不同模型就很难泛化。 解决预处理阶段把数字统一替换为占位符比如把12.5替换成[NUM]把单位缩写WBC替换成[ENG]。模型学到的是「数字占位符单位」的模式而不是死记某个具体数值。替换逻辑要放到索引构建之前保证训练和测试走同一条预处理管道。5.4 长序列训练OOM现象batch_size设为32max_len设300训练到第二个epoch直接爆显存报CUDA out of memory。 原因BiLSTM的显存占用随序列长度线性增长CRF的转移矩阵计算在解码时也需要额外显存。长序列加上大batch显存很容易超限。 解决降低max_len到200把batch_size下调到16同时给DataLoader加pin_memoryTrue减少数据传输开销。实测200字上限能覆盖90%以上的病历文本对实体F1的影响通常控制在1个百分点以内但显存占用能降一半。5.5 CRF解码速度慢现象验证集跑一次要10秒训练时每epoch都要解码一遍整体训练时间比普通分类模型多出两倍。 原因crf.decode用Viterbi算法时间复杂度是O(序列长度×标签数²)标签类别越多越慢。尤其在训练初期模型还没收敛解码路径跳来跳去耗时更明显。 解决把验证集的decode放到torch.no_grad()块里减少自动求导带来的开销。训练过程中每3个epoch评估一次而不是每epoch都跑验证。如果标签类别超过15个可以考虑在训练阶段用find_best_path接口配合batch_firstTrue减少矩阵转置开销这个细节在标签多时能省下不少时间。6. 提升召回率的一个落地技巧引入词典特征6.1 词典特征怎么融入BiLSTM-CRF当模型在验证集上的F1稳定在0.8左右时想再往上提一个台阶最有效的低成本做法是引入领域词典特征。中文电子病历里实体有很强的词典闭合性比如药物名、检查项目名基本都在药典和诊疗规范里能查到。常见做法是构建一个三元组词典每项包含「实体文本、实体类型、匹配优先级」在预处理阶段对每个字符标记它是否命中词典、命中哪种实体类型把这个标记向量拼接到字符embedding后面。def build_dict_feature(text, medical_dict, max_len200): # feature: 0非词典词, 1命中症状, 2命中疾病, 3命中药物, 4命中检查 feat [0] * max_len for term, etype in medical_dict.items(): start 0 while True: idx text.find(term, start) if idx -1: break for pos in range(idx, idx len(term)): feat[pos] etype start idx 1 return feat[:max_len]这段代码在建模阶段会拿到每个字符的词典特征向量然后和embedding输出拼接BiLSTM的输入维度随之从embedding_dim变成embedding_dim 4。词典匹配用简单的字符串find实现虽然不够高效但胜在可控透明匹配不到时特征保持为0。调参时有两个注意点词典覆盖率低于60%时这个特征的作用不明显因为大部分字符的特征是0词典覆盖率高但匹配表太长时要注意优先匹配最长实体比如「急性心肌梗死」和「心肌梗死」同时命中时应取更长的那个否则边界会被短词带偏。6.2 用混淆矩阵验证效果加了词典特征之后不要只看整体F1用混淆矩阵按类别看谁受益了。药物和检查这两类实体通常提升最明显因为它们在词典里封闭性强表达方式也固定而症状实体因为表述极其多样词典覆盖率低提升往往有限。如果发现症状类的精确率反而下降说明模型开始依赖词典而忽略了上下文此时应调低特征权重或者限制词典只在O字符上生效避免覆盖模型自己学到的语义信息。最终判断词典特征值不值得引入看一眼混淆矩阵就清楚——药物类F1从0.85提到0.93说明词典修改成功了症状类从0.79掉到0.77那就要回退配置。我自己的习惯是每做一次改动就保留一个带版本号的评估结果表这样模型迭代时不靠感觉靠数据说话。希望这篇笔记能帮你把BiLSTM-CRF这个基线模型跑起来、调明白少走几趟我走过的弯路。本文还有配套的精品资源点击获取