资讯详情

基于BERT源码的电子病历命名实体识别实现与踩坑指南

📅 2026/9/23 17:32:57 | 华诺云谱 👁 阅读
基于BERT源码的电子病历命名实体识别实现与踩坑指南
简介这是一套基于BERT模型的电子病历命名实体识别系统源码面向医疗信息化开发者、NLP研究与工程人员用于从电子病历文本中自动识别疾病、药物、治疗手段等关键实体支撑临床决策与医疗数据处理。资源共38个文件含21个Python源文件、6个文本说明、3个XML配置、2个Markdown文档及Jupyter Notebook交互脚本整体约395KB。代码覆盖数据预处理、模型定义、训练、预测与评估全流程并附带readme说明、license许可及Git忽略配置目录结构清晰便于直接改造与二次实验。目前已吸引320人学习参考对希望快速上手医疗NER任务的读者具有实用价值。1. 电子病历命名实体识别为什么“BERT 源码”是这个方向的起点把一份电子病历交给算法让它自动抽取出“患者右上腹疼痛3天”里的症状、“CT提示胆囊结石”里的检查结果和疾病诊断——这就是电子病历命名实体识别NER要解决的事。它不是医疗NLP里最炫的方向但绝对是所有结构化病历分析的第一步没有实体抽取后面的关系抽取、知识图谱、辅助诊断全是空中楼阁。我为什么推荐从“基于BERT的电子病历NER设计源码”入手因为BERT把中文文本的语义编码能力拉高了一个量级在医疗这种词汇专业、表述不规范的场景里预训练语义比任何人工特征都好使。而且序列标注任务有成熟的范式BERT编码文本 全连接层或CRF解码标签。本文会把这套从数据标注、模型构建到训练调参、踩坑排错的完整路径拆开讲新手能照着跑熟手能避坑。2. 电子病历 NER 的标签体系与数据准备工作先弄清识别什么2.1 电子病历里到底抽哪几类实体六类标签的设计逻辑电子病历和新闻、法律文本不一样它的实体类型高度集中而且不同科室略有差异。我一般把实体类型划成六类症状、体征、检查、检验、疾病、药物。听起来简单但到了标注环节就麻烦——比如“发热”既是症状也可以是体征“示指骨折”是疾病还是体征不同标注规范下答案不一样。所以做这个项目的第一步不是写代码而是定标注规范规范定了模型的上限就锁了。标注体系我选BIOBegin, Inside, Outside因为简单且实现稳定。每个token对应一个标签非实体为O实体首字为B-实体类型实体中间和结尾为I-实体类型。比如“患者右上腹疼痛”中“疼痛”标注为B-SYMPTOM、I-SYMPTOM。BIOES增加E结尾、S单字效果略好但代码复杂度高一点新手项目没必要一上来就上BIOES。2.2 把原始病历文本转成训练样本BIO标签转换脚本拿到标注数据通常来自医学生或标注平台后第一步是把原始文本和实体位置转换成BERT能消费的BIO标签序列。下面这段代码是我常用做法输入是纯文本加实体标注列表输出是token级别的BIO标签序列def create_bio_labels(text: str, entities: list) - list: 将文本和实体标注转为BIO标签序列按字符粒度。 entities: [{start: 3, end: 5, type: SYMPTOM}] end为开区间, 不含末尾 返回: [O, O, O, B-SYMPTOM, I-SYMPTOM, O, ...] labels [O] * len(text) # 先给每个实体打上BIO for ent in entities: start, end ent[start], ent[end] if start end: continue # 多字实体 labels[start] fB-{ent[type]} for idx in range(start 1, end): labels[idx] fI-{ent[type]} return labels # 示例用法 text 患者右上腹疼痛3天 ents [{start: 3, end: 5, type: SYMPTOM}] labels create_bio_labels(text, ents) print(list(zip(text, labels))) # [(患,O), (者,O), (右,O), (上,B-SYMPTOM), (腹,I-SYMPTOM), # (疼,I-SYMPTOM), (痛,I-SYMPTOM), (3,O), (天,O)]这段代码的逻辑看着简单实际有讲究实体标注的 end 必须是开区间因为 Python 切片本来就是这样习惯但很多标注平台导出的是闭区间这里不统一就会到处差一个字符。建议在读取标注文件后就统一成开区间不要在 create_bio_labels 里再修否则多个实体重叠时会出问题。另一个值得留意的是实体长度——单字实体照样能标但B后必须跟I否则CRF那一层会报错。2.3 用BERT的Tokenizer做对齐字符级标签如何映射到subword电子病历文本进入BERT前要做tokenize而中文BERTbert-base-chinese是按字切分的——胃变成 [unused] 或者一个 token所以字符级标签和token级标签在中文里基本是1:1对齐。但病历里常嵌英文缩写和数字比如CT、3.5cm、AFP这时英文被拆成子词标签就得跟着展开。这块是对齐代码里最容易翻车的地方我单独写清楚from transformers import BertTokenizerFast def align_labels_with_tokens(tokenizer, text: str, char_labels: list): 把字符级BIO标签对齐到BERT token级。 BERT中文按字切分但英文/数字会被切成subword标签要跟着展开或分摊。 token_ids tokenizer(text, add_special_tokensFalse)[input_ids] tokens tokenizer.convert_ids_to_tokens(token_ids) token_labels [] char_idx 0 # 当前字符在原文中的游标 for token in tokens: if token in ([UNK], [CLS], [SEP]): token_labels.append(O) continue # BERT中文token基本等于原文一个字符特殊符号如“##”开头的英文子词 if char_idx len(char_labels): token_labels.append(O) continue if token.startswith(##): # 它是前面英文词的子词部分标签跟着上一个词走 token_labels.append(token_labels[-1] if token_labels else O) elif token in ([UNK],): token_labels.append(O) # 未知字符保守处理为O else: # 普通token对应原文一个字符 token_labels.append(char_labels[char_idx]) char_idx 1 return token_ids, token_labels这里关键点BERT的decode后token不等于原文字符的情况主要是##开头的英语子词中文生僻字会变成[UNK]此时保持O而不是勉强对齐因为我宁可在训练时少一个正样本也不要在标签里掺脏数据。最后一个注意点是BertTokenizerFast和BertTokenizer的切分结果几乎一致但Fast版的速度快得多数据准备阶段建议用Fast版。3. 用BERT做序列标注的最小复现模型架构与训练管线3.1 为什么选BERT而不是BiLSTM-CRF预训练语义的价值2018年之前医疗NER的主流做法是BiLSTMCRF词向量用word2vec。这个方案的瓶颈在词覆盖——电子病历里“上腹部绞痛”“墨菲氏征阳性”这类专业表述word2vec没见过的词太多OOVout-of-vocabulary问题严重。换成BERT之后预训练阶段见过的中文语料足够大生僻专业词的上下文表示远比静态词向量可靠。另一个被低估的点是BERT的双向编码。BiLSTM虽然是双向但本质是拼接两个方向的隐状态对长距离依赖的建模能力和Transformer相比还是差一截。电子病历里“患者3年前因胆囊结石行胆囊切除术术后恢复可近1周再发右上腹疼痛”——这个“再发”要能追溯到“胆囊结石”这个老毛病就得有很强的上下文建模能力。3.2 整体架构设计BERT 全连接 CRF 还是 Softmax我见过不少教程直接把BERT输出的[CLS]向量拿去分类这是文本分类的做法不能用于序列标注。序列标注的标准架构是BERT输出每个token的768维向量再接一个全连接层把维度映射到标签数比如B-SYMPTOM、I-SYMPTOM、O等13类然后接Softmax或CRF解码。差别在于Softmax对每个token独立预测可能预测出“I-SYMPTOM”开头却没有“B-SYMPTOM”这种非法序列而CRF在解码时用转移矩阵约束标签间的合法性。实战中我一般先跑通“BERT 全连接 Softmax”拿到baseline再加CRF层。建议不要一开始就上CRF理由有两条。一是Softmax版本的loss收敛更容易观察以及诊断错误时更好定位是BERT没学到语义还是CRF转移约束搞错了。二是CRF的loss对学习率敏感初上手容易踩坑第5章会专门讲。所以下面的最小复现用Softmax但我在3.4节也给出CRF的完整代码。3.3 训练管线代码数据加载与标签重映射import torch from torch.utils.data import Dataset from transformers import BertTokenizerFast class EMR_NER_Dataset(Dataset): def __init__(self, texts, label_seqs, tokenizer, max_len256, label2idNone): self.texts texts self.label_seqs label_seqs # 字符级BIO标签 self.tokenizer tokenizer self.max_len max_len self.label2id label2id # {O: 0, B-SYMPTOM: 1, ...} def __len__(self): return len(self.texts) def __getitem__(self, idx): text self.texts[idx] char_labels self.label_seqs[idx] token_ids, token_labels align_labels_with_tokens( self.tokenizer, text, char_labels ) # 截断 加特殊token token_ids token_ids[:self.max_len - 2] token_labels token_labels[:self.max_len - 2] token_ids [self.tokenizer.cls_token_id] token_ids [self.tokenizer.sep_token_id] token_labels [O] token_labels [O] # CLS和SEP都标O attention_mask [1] * len(token_ids) # 转id label_ids [self.label2id.get(lbl, 0) for lbl in token_labels] return { input_ids: torch.tensor(token_ids, dtypetorch.long), attention_mask: torch.tensor(attention_mask, dtypetorch.long), labels: torch.tensor(label_ids, dtypetorch.long), } # 使用示例 label_list [O, B-SYMPTOM, I-SYMPTOM, B-SIGN, I-SIGN, B-DISEASE, I-DISEASE, B-TEST, I-TEST, B-TREATMENT, I-TREATMENT, B-MEDICINE, I-MEDICINE] label2id {lbl: i for i, lbl in enumerate(label_list)}这段代码里max_len256是调出来的经验值电子病历的主诉和现病史段落一般不超过200字但体格检查部分可能到400字。第5章会讲长文本截断导致实体切半的问题那里有一个滑动窗口方案。然后标签里面出现了13个类别其中每个实体类型都有B和I两个标签这个设计在CRF里是必需的Softmax里也会让模型更容易区分实体边界。3.4 训练循环从loss计算到CRF解码的完整代码训练循环里有两个细节很多人第一次会做错。一是BERT的输出取last_hidden_state而不是pooler_output。pooler_output是[CLS]那个token过了一层tanh的结果它只适合做句级分类。二是在计算loss时要设置ignore_index-100把padding位置的label忽略掉否则模型会努力学习“预测pad为O”这种无意义的事情。import torch.nn as nn from transformers import BertModel from torchcrf import CRF class BertNER(nn.Module): def __init__(self, bert_pretrainedbert-base-chinese, num_labels13, use_crfTrue): super().__init__() self.bert BertModel.from_pretrained(bert_pretrained) self.dropout nn.Dropout(0.1) self.classifier nn.Linear(self.bert.config.hidden_size, num_labels) self.use_crf use_crf if use_crf: self.crf CRF(num_labels, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) seq_output outputs.last_hidden_state # [batch, seq_len, 768] logits self.classifier(self.dropout(seq_output)) # [batch, seq_len, num_labels] if labels is not None: if self.use_crf: # CRF要求mask是bool类型 mask attention_mask.bool() loss -self.crf(logits, labels, maskmask, reductionmean) else: loss_fct nn.CrossEntropyLoss(ignore_index-100) loss loss_fct(logits.view(-1, logits.shape[-1]), labels.view(-1)) return loss, logits else: if self.use_crf: pred self.crf.decode(logits, maskattention_mask.bool()) return pred # list of lists return torch.argmax(logits, dim-1)CRF那部分用到了torchcrf库它在PyTorch 2.x下需要装个小补丁否则decode时会报TypeError。具体在第5章避坑里讲。训练时优化器我用AdamWBERT部分的学习率2e-5分类层和CRF层的参数如果共用同一个学习率会出现loss在某个step突然变成nan的情况所以一般把分类层和CRF层单独设成1e-4。4. 训练参数怎么调从学习率到类别不均衡的处理4.1 核心参数的推荐范围与调节优先级BERT微调有一个很反直觉的地方学习率大了必炸小了收敛慢到怀疑人生。我一般用2e-5到5e-5之间分类层和CRF层用1e-4到3e-4。优化器选AdamW权重衰减0.01。warmup比例0.1意思是前10%的step学习率从0线性升到设定值用来避开预训练权重和任务头之间巨大的梯度差异。batch size在BERT里特别吃显存我是这么判断电子病历文本的max_len是256NVIDIA RTX 309024GB能跑batch size32V10016GB只能到16如果有两块卡就梯度累积。别硬塞到OOMPyTorch的显存碎片问题在BERT上被放大得很厉害一次性申请超出显存直接报错还难恢复。training_args { learning_rate: 2e-5, # BERT主干 head_learning_rate: 1e-4, # 分类层 / CRF weight_decay: 0.01, batch_size: 16, max_epochs: 5, warmup_ratio: 0.1, max_grad_norm: 1.0, scheduler: linear, }参数调节顺序我给一个实用策略先固定epoch5、batch size16把learning_rate从5e-5往下降每次减半跑2个epoch观察loss曲线再撞一次batch size最后看warmup。我见过有人在batch size8和32之间切换F1掉1.5个点这个指标属于正常波动不用焦虑。4.2 类别不均衡被“O”淹没的正样本一个典型的电子病历数据集里O标签占比可能高达85%~90%。这个比例下模型很狡猾——全部预测O就能拿到85%的token级准确率。所以评估时我不看accuracy只看precision、recall和F1并且必须用span级别的F1实体级别的F1不能看token级别。span级F1的计算方法是用seqeval库它要求预测和标注都是实体级别的格式[B-SYMPTOM, I-SYMPTOM] 这种列表被它融合成 [SYMPTOM] 的span然后比较起始位置。针对不均衡我有两个常用手段。第一是在Loss里给实体类非O更高的权重CrossEntropyLoss的weight参数设为[1.0, 5.0, 5.0, ...]O标签保持1.0。第二种是数据层面的做法将长文本切段后做去重让正样本的比例控制在15%到30%之间这个方法对F1的影响比调loss权重更明显。4.3 验证集划分与早停技巧电子病历数据有很强的病人级别聚类效应——同一个病人的多段病历高度相似如果按句随机划分验证集会泄露大量同源信息导致F1虚高3~5个点。正确做法是按病人ID分桶切分训练集、验证集、测试集分别落在不同病人上。源码包里我一般会专门留一个split_by_patient的脚本这是内部验收时被问最多的事情因为很多团队是从公开数据集跑通模型再上自己的病历分错组对效果评估是致命的。早停我设置在验证集span级F1连续3个epoch不上升时触发。注意早停的patience参数不能设成1BERT微调时F1曲线是波动上升的epoch 2稍降、epoch 3又回来了是常态。5. 电子病历 NER 的五条踩坑记录现象、原因与解决5.1 token对齐错位[UNK]把标签序列打乱现象训练loss能降但验证时所有实体都预测错位一个字符或者某些标签完全预测不出来。原因病历里的生僻字、特殊符号如±、②在bert-base-chinese词表里不存在被tokenize成[UNK]。我2.3节的align_labels_with_tokens里虽然对[UNK]做了处理但如果你在数据预处理时不检查[UNK]的出现位置char_idx游标会多发一位导致后续所有token的标签错位。解决在构造数据集时统计[UNK]的占比超过0.5%就单独打印这些样本的原文和token结果。如果有大量[UNK]不要硬用bert-base-chinese可以换更大的中文医疗预训练模型它们词表更大。另一个思路是先把特殊符号替换成[unused1]再fine-tune时把[unused1]当作一个普通token让BERT自己学它的语义。5.2 全角/半角混用导致实体边界被割裂现象同一个实体“右上腹”在训练集里是全角在测试集里变成半角模型有时识别为两个实体。原因电子病历录入时输入法状态不稳定全角半角混用极其常见。“3.5cm”的“.”可能是全角“”。BERT的tokenizer对全角和半角字符是区别对待的同一含义的字符被映射到完全不同的embedding模型学不到它们的等价性。解决数据预处理时统一做全角转半角Python的str.maketrans把全角ASCII对应范围映射回半角即可。注意全角空格\u3000要单独处理成半角空格。做完之后检查一遍实体边界确认没有因角转换产生新的切分问题。5.3 CRF层的loss突然变成nan或者长时间不降现象BERT层用2e-5正常收敛但加了CRF后第一个epoch loss就在0.5附近震荡甚至某个step直接nan。原因还是学习率的问题但不动脑的人会把锅甩给CRF“不稳定”。实际情况是CRF层的转移矩阵参数初始化范围比BERT输出大同样的学习率下CRF层的参数更新幅度远高于BERT层导致转移矩阵出现极端值解码时所有路径的得分都变成-1e8级别的数exp之后就是nan。解决把CRF参数从主优化器里摘出来单独设学习率官方一点的说法是参数分组parameter groups。我一般这样处理optimizer AdamW([{params: bert.parameters(), lr: 2e-5}, {params: classifier.parameters(), lr: 1e-4}, {params: crf.parameters(), lr: 1e-3}])。如果还nan把max_grad_norm从1.0降到0.5。5.4 长文本截断切掉实体尾巴现象一条记录明明有“胃镜检查提示慢性萎缩性胃炎”模型只抽出了“胃镜”后半截全丢了。模型不是笨是max_len256直接从第256个token处硬截断了。原因电子病历的“现病史”和“既往史”边界恰好落在256个token附近把实体切断了。由于我采用的是简单截断策略被切断的地方变成半个实体模型的CRF或softmax无法判断这个残缺片段的类型。解决长文本按句子切分成多个窗口每个窗口独立进模型推理时再合并结果。窗口之间设置20~30个token的重叠overlap。这个方案在源码实现里就是多一层循环但非常好用。如果不想写窗口逻辑拆句器按句号、分号切分加简单的规则合并也可以。5.5 torchcrf在PyTorch 2.x下的兼容性报错现象from torchcrf import CRF能过但loss -self.crf(logits, labels, maskmask)报TypeError: ellipsis object is not subscriptable。原因torchcrf内部使用了mask[...]的写法PyTorch 2.x对ellipsis的运算规则做了更严格的检查旧库没跟上。这是源码复现时最容易让新手卡住的兼容性问题Google搜出来的中文论坛答案甚至会让降到PyTorch 1.x不必那么折腾。解决两个选择。一是pip install pytorch-crf它维护更新接口几乎一样二是干脆不用第三方CRF库自己手写CRF的前向后向算法代码量百来行但把对数域的exp、log、mask细节处理好还是有点门槛的。新手建议用pytorch-crf省时省心。6. 从源码跑通到能交付用slide window推理和span-F1验收模型训练完最后一步是把验证集里表现最好的checkpoint拿出来做推理验证。推理有一个小而关键的技巧——长文本窗口重叠合并。我一般在max_len之外再设一个stride参数取max_len的一半。窗口滑过去之后每个token可能被预测多次取多次预测中最常出现的标签作为最终结果。这样处理的代价是推理耗时翻倍但span-F1能回升1.5%左右对电子病历这种对精度要求苛刻的场景来说非常值得。如果硬要省这个时间可以只在“有实体候选”的窗口间做合并没候选的直接取第一个窗口结果。def predict_long_text(model, tokenizer, text, max_len256, stride128): model.eval() tokens tokenizer.tokenize(text) total_len len(tokens) if total_len max_len - 2: # 正常推理 enc tokenizer(text, max_lengthmax_len, truncationTrue, return_tensorspt) with torch.no_grad(): logits model(**enc)[1] # 或 model.compute_logits(enc) pred torch.argmax(logits, dim-1)[0].cpu().numpy() return pred # 多窗口预测 投票 all_preds [] for start in range(0, total_len, stride): end min(start max_len - 2, total_len) window_tokens tokens[start:end] window_text tokenizer.convert_tokens_to_string(window_tokens) enc tokenizer(window_text, max_lengthmax_len, truncationTrue, return_tensorspt) with torch.no_grad(): logits model(**enc)[1] pred torch.argmax(logits, dim-1)[0].cpu().numpy() # 第一个token是CLS去掉 all_preds.append((start, pred[1:end-start1])) # 合并重叠区域简化取每个token第一次出现的预测 final [O] * total_len for start, pred in all_preds: for i, p in enumerate(pred): if start i total_len: if final[start i] O: final[start i] p return final验证时一定要用seqeval库算span级的P/R/F1不要盯着token accuracy。seqeval要求输入是二维list每个样本是list of strings我用classification_report的先例比较少通常直接调seqeval.metrics.performance_measure自己看数字。这里有个容易忽略的点seqeval会把B-SYMPTOM和I-SYMPTOM自动拼成一个实体但如果一个实体的内部标签切成了B-SYMPTOM、O、I-SYMPTOM它会算两个实体这正是我们要暴露给模型的问题。最后一个经验也是我吃过大亏的地方不要只用公开数据集验证。公开的医疗NER数据集如IMCS、CMeEE清洗得比较干净科室分布均衡格式统一模型在这些集上的F1在85%以上很正常但一上真实病历就会掉到70%甚至更低。主要原因是真实文本里有大量口头表达、错别字、不规范缩写标注规范的公开数据样本无法覆盖。我现在的习惯是先拿公开数据跑通代码再拿一个月真实病历脱敏后做二次标注和微调最后交付的模型一定是两个阶段都完成的版本。这条路没有捷径电子病历NER做好了是治病救人的基础设施值得在这件事上花时间。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。