知识图谱增强的中文电子病历医学命名实体识别实践
简介基于BERT和知识图谱的中文电子病历医学命名实体识别项目面向自然语言处理与知识图谱方向的学生、研究者及开发者适用于医学文本信息抽取、电子病历结构化等场景。资源围绕CCKS2019医学命名实体识别任务展开代码以Python为主压缩包共98个文件、约6.07MB包含57个py脚本如基于BERT的NER训练、预测、BILSTM_CRF基线、16个tsv标注数据、7个sh训练与预测脚本、5个spo三元组文件、模型配置与说明文档完整覆盖数据预处理、模型训练、推理预测等环节。已有179人学习浏览适合作为知识图谱大作业或NLP课程设计的参考实现。压缩包提供基于BERT与知识图谱增强的NER模型实现、KG嵌入结合方案、可运行脚本及数据集样例读者可据此复现CCKS2019基线实验、对比不同模型效果并针对电子病历命名实体识别进行二次开发与改进。1. 中文电子病历NER的痛点为什么纯BERT不够用电子病历EMR里的医学命名实体识别面对的文本和新闻、百科完全不是一个物种。写进病历的句子往往高度省略患者胃部不适既往胃溃病史这类半结构化表达中胃溃指的是胃溃疡既往史后面紧跟一串疾病名。更麻烦的是嵌套和歧义左肺上叶肺癌术后里左肺上叶是部位肺癌是疾病而肺癌术后又是一个完整的事件描述。用纯BERT做序列标注F1通常在82%88%区间上不去瓶颈不在模型容量而在实体边界和类别歧义——这正是知识图谱能补的位置。这篇文章按基线搭建→知识图谱融合→训练调参→验证优化的顺序给出一条可复现的中文电子病历医学命名实体识别落地路径适合NLP工程师、医疗信息化从业者和入门研究者。2. 模型选型与原理BERT-CRF基线到知识图谱增强2.1 序列标注视角下的中文医学NER中文医学命名实体识别本质是一个字级别的序列标注问题。句子被切成单个汉字每个字被赋予一个标签标签体系通常采用BIO或BIOES。对电子病历场景我一般用BIOES而不是BIO因为EEnd标签能让模型更明确地学习实体边界这对胃溃这类截断词尤其有效。# BIOES标签体系示例疾病、症状、药物、检查四类实体 label2id { O: 0, B-Disease: 1, I-Disease: 2, E-Disease: 3, S-Disease: 4, B-Symptom: 5, I-Symptom: 6, E-Symptom: 7, S-Symptom: 8, B-Drug: 9, I-Drug: 10, E-Drug: 11, S-Drug: 12, B-Exam: 13, I-Exam: 14, E-Exam: 15, S-Exam: 16, }这组映射把每个实体类别拆成4种位置标签加1个O标签一共17类。S用于单个字构成的实体比如癌单独出现时会被标为S-Disease而不是B-Disease加E-Disease。选定标签体系后输出层的维度就固定为17后续知识图谱特征拼接也需要对齐到每个字。与英文tokenization不同中文BERT使用字级别vocab这意味着不需要分词。分词在医学文本里是灾难源头——胃溃疡出血可能被切成胃/溃疡/出血或胃溃/疡出/血任何分词错误都会直接传给下游标注。字级别输入绕开了这个问题。2.2 BERT编码与CRF解码的职责边界BERT负责给每个字一个上下文相关的向量表示。以患者胃溃病史为例胃和溃在BERT眼中会分别看到左右两侧的上下文但BERT对每个字独立输出概率它不知道胃后面必须跟溃或溃瘍才能构成完整实体。这个约束由CRF层补充。import torch import torch.nn as nn from transformers import BertModel from torchcrf import CRF class BertCRF(nn.Module): def __init__(self, pretrained_namebert-base-chinese, num_labels17): super().__init__() self.bert BertModel.from_pretrained(pretrained_name) self.dropout nn.Dropout(0.1) self.classifier nn.Linear(self.bert.config.hidden_size, 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) logits self.classifier(self.dropout(outputs.last_hidden_state)) if labels is not None: # CRF需要mask来忽略padding位置 loss -self.crf(logits, labels, maskattention_mask.bool(), reductionmean) return loss, logits # 解码时用CRF的维特比算法得到全局最优标签序列 return self.crf.decode(logits, maskattention_mask.bool()), logitstorchcrf的decode方法内部执行维特比算法复杂度为O(n·L²)n是序列长度L是标签数。这里attention_mask.bool()是关键——CRF的转移矩阵会扫描mask为False的位置如果不传maskpadding处的标签也会参与转移计算导致训练和推理不一致。损失取负对数似然reductionmean按batch平均比求和更稳定。知识图谱特征在这个架构里可以有三种注入位置见下一节。2.3 知识图谱增强的三种注入方式设计知识图谱增强时要区分实体链接和NER特征增强——前者是识别后再关联到图谱节点后者是在识别过程中就利用图谱知识。实践中常用三种方式对比如下方式具体做法优点风险词典特征拼接用KG实体名做最长匹配匹配到的字加0/1特征实现简单、可解释匹配噪声、泛化差实体边界预标注KG匹配结果直接作为额外标签序列对短实体提升明显错误匹配会误导模型知识embedding融合实体向量与BERT向量拼接或相加语义信息丰富训练成本高、需要图谱规模大我一般先做第一种——把词典匹配结果作为每个字的一个额外特征输入。具体做法是构建实体词典然后用Trie树或AC自动机扫描输入文本命中实体的字标记为1同时记录实体类型编号。这个特征拼接在BERT输出之后再进分类器。class KGAwareNER(nn.Module): def __init__(self, bert_model, num_labels, kg_feature_dim8): super().__init__() self.bert bert_model # kg_feature_dim是每个字的KG特征向量维度是否实体首字、是否实体尾字、实体类别one-hot等 self.kg_proj nn.Linear(kg_feature_dim, self.bert.config.hidden_size) self.classifier nn.Linear(self.bert.config.hidden_size * 2, num_labels) self.crf CRF(num_labels, batch_firstTrue) def forward(self, input_ids, attention_mask, kg_features, labelsNone): bert_out self.bert(input_idsinput_ids, attention_maskattention_mask).last_hidden_state kg_vec self.kg_proj(kg_features) # 拼接BERT输出和KG特征投影使模型同时看到上下文和先验知识 fused torch.cat([bert_out, kg_vec], dim-1) logits self.classifier(fused) if labels is not None: loss -self.crf(logits, labels, maskattention_mask.bool(), reductionmean) return loss, logits return self.crf.decode(logits, maskattention_mask.bool()), logitskg_proj把稀疏的词典匹配特征映射到BERT的hidden size再拼接。注意这里不能简单concat原始0/1向量因为稀疏特征会让模型过度依赖这个强信号而弱化BERT的上下文表示。先投影再拼接相当于给模型一个加权提示。特征设计上不要只放是否命中实体还要放命中的实体类型one-hot否则模型只知道这里有实体却不知道它是疾病还是药物反而增加歧义。2.4 选型决策先跑通基线还是直接上KG有不少人跳过基线直接上知识图谱结果F1反而下降。原因是KG特征在训练初期主导了梯度更新BERT部分的上下文表示被压制。常见做法是先跑通纯BERT-CRF基线记录每个实体类别的F1再逐步加KG特征对比增量。如果某个类别比如检查项在基线上F1很低通常是标注样本少或实体变体多这时KG词典特征会有明显帮助。如果疾病类F1已经很高再加词典特征收益有限可能只有0.5个点的提升。这个对比顺序能帮你判断是模型问题还是数据问题。3. 数据工程电子病历标注与知识图谱构建的最小可行方案3.1 电子病历标注的领域特性与实体类型定义电子病历的标注规范和通用领域NER不同。通用领域标注的是人名、地名、机构名电子病历则要针对床旁文书设计品类。我一般参考CCKS和CHIP评测任务把实体分为四类疾病、症状、药物、检查。每类都要给一个明确的范围说明否则标注员之间的标记一致性kappa值会很低。实体类型定义正例反例疾病明确的疾病诊断名称含缩写胃溃疡、COPD、2型糖尿病胃部不适属症状症状患者主观感受或客观体征腹痛、发热、杵状指胃镜示属检查药物具体药名或药物类别阿司匹林、硝苯地平药物过敏史非具体药检查检查项目名称胃镜、血常规、CT平扫检查结果泛指定义之外还要处理嵌套标注策略。比如慢性阻塞性肺疾病急性加重期中慢性阻塞性肺疾病是疾病诊断急性加重期是病程描述。多数评测任务只标注最长实体不标嵌套。若团队条件允许可以同时标注嵌套实体用多标签方案训练但推理复杂度会上升建议第一版只标最长实体。标注完成后要做一致性校验。我通常随机抽10%的句子让两个标注员独立标注计算实体级别的F1把一个标注员的输出当预测另一个当标准答案。F1低于90%的实体类型说明标注规范有歧义先回炉改规范不要急于训练。3.2 从医学词典到知识图谱三元组知识图谱的构建不需要从零做大规模。对NER增强而言最核心的是实体名称、实体类型、以及实体间关系药物-治疗-疾病。三元组格式为(头实体, 关系, 尾实体)例如(阿司匹林, 治疗, 冠心病)。# 最小知识图谱构建脚本从结构化表格生成三元组 import pandas as pd import json df pd.read_csv(drug_indication.csv) # 列: drug_name, indication, category triples [] for _, row in df.iterrows(): head row[drug_name].strip() rel 治疗 tail row[indication].strip() triples.append({head: head, relation: rel, tail: tail, type: row[category]}) # 导出为JSON供NER特征提取和后续bad case分析使用 with open(kg_triples.json, w, encodingutf-8) as f: json.dump(triples, f, ensure_asciiFalse, indent2) print(f生成三元组 {len(triples)} 条)这段脚本把药品-适应证表的每一行转成一个治疗关系三元组。category字段不能丢后面生成实体类型one-hot特征时要用。实际项目中三元组规模可能到百万级此时不推荐全部加载到内存做遍历匹配而是用前缀树或AC自动机索引实体名把匹配时间复杂度从O(N·L)降为O(L)N是词典大小L是句长。需要注意的是知识图谱和实体词典是两回事。词典只记录名称到类型的映射图谱记录实体间的关系。NER阶段主要用词典但在第5章的bad case分析中图谱关系能用来做实体消歧——如果句子中出现阿司匹林和出血图谱里(阿司匹林, 副作用, 出血)这条关系可以帮助模型把出血从症状类别中区分出来。3.3 实体别名表构建临床缩写归一化电子病历里大量实体以别名、缩写甚至错别字形式出现。胃溃疡可能写作胃溃、GU、消化性溃疡。而GU在病历里也可能指泌尿生殖系统genitourinary完全靠NER模型区分会消耗大量样本。别名表是知识图谱的辅助数据结构把同一个实体的所有写法映射到标准名。alias_to_std { 胃溃: 胃溃疡, GU: 胃溃疡, # 注意需要上下文判断仅当出现在消化内科场景 消化性溃疡: 胃溃疡, } def normalize_entity_mention(mention, context): 先查别名表再查询图谱返回标准实体名和类型 if mention in alias_to_std: std_name alias_to_std[mention] # 从图谱中反查标准名的类型 return std_name, kg_entity_type.get(std_name, Unknown) return mention, kg_entity_type.get(mention, Unknown)别名表的维护是一个持续过程。建议在训练集和验证集上先跑一遍基线模型把预测错误的实体mention收集起来人工审核后加入别名表。注意GU这种歧义缩写不要直接放进全局别名表建议加上科室或上下文条件——GU在消化内科病历里才映射为胃溃疡。构建图谱和别名表时有一个常见误用直接拿公开医学知识图谱做词典匹配。公开图谱覆盖的是标准化术语与电子病历里的口语表达脱节直接匹配会漏掉大量变体。我一般从训练语料里做一次实体频率统计把出现频率高但图谱中不存在的mention收集出来人工确认后补进别名表再进词典特征。4. 训练与调参中文医学NER的实践参数与坑4.1 关键参数设置学习率、序列长度与优化器分组训练中文医学NER模型baseline有两种选择bert-base-chinese或领域预训练模型。电子病历文本与通用中文语料差异大领域预训练模型通常能带来24个点的直接提升。如果团队GPU资源有限直接用bert-base-chinese加KG特征也可以接受。学习率设置上BERT部分和新增的KG特征层要分开设。BERT的预训练参数已经足够收敛学习率过大会破坏语义表示过小则KG特征层学不动。我常用的配置是from transformers import AdamW, get_linear_schedule_with_warmup optimizer AdamW([ {params: model.bert.parameters(), lr: 2e-5}, {params: model.kg_proj.parameters(), lr: 1e-4}, {params: model.classifier.parameters(), lr: 1e-4}, {params: model.crf.parameters(), lr: 1e-4}, ], weight_decay0.01) total_steps len(train_dataloader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps )BERT部分用2e-5新增层用1e-4相差5倍。weight_decay0.01是BERT微调的标准配置warmup取总步数的10%避免训练初期loss震荡。这里把KG特征层和分类器归为一组CRF的参数单独放——CRF的转移矩阵更新节奏应该比特征抽取更保守因为它学习的标签转移约束需要稳定的特征输入。max_len的设置要看GPU显存和病历句长。中文电子病历的一句话通常在50200字之间但包含既往史列表时可能超过512。max_len128时过长句子会被截断导致尾部实体丢失max_len256则显著增加显存占用。我一般先用max_len128跑通流程后续用滑动窗口处理超长文本窗口重叠32个字保证实体不被截断。4.2 类别不均衡与O类占比处理医学NER的O标签占比通常在60%75%之间。O代表非实体位置比如患者于2023年3月12日入院这句话里几乎没有实体。如果直接训练模型会倾向于把所有字都判为O因为这样准确率也能超过60%。CRF的转移矩阵能部分缓解这个问题——它学到了O后面不能直接跟I这类约束但无法从根上解决问题。缓解手段有三个方向一是按实体类别做损失加权二是用focal loss替代cross entropy三是过采样含实体的句子。BERT-CRF结构在CRF层做加权比较麻烦因为CRF的loss是基于整个序列的转移概率计算的。因此我推荐第三种——从训练集里按实体密度排序包含至少一个实体的样本全部保留纯O样本降采样到总样本的30%以内。import random def filter_pure_o_samples(dataset, max_o_ratio0.3): 过滤纯O样本控制O类占比避免模型偏向预测为O kept, o_count [], 0 for sample in dataset: if sum(1 for label in sample[labels] if label ! 0) 0: if random.random() max_o_ratio: kept.append(sample) else: kept.append(sample) return keptmax_o_ratio0.3表示纯O样本最多保留30%。随机筛选会丢失部分句子结构信息但训练速度提升明显因为纯O句子的梯度贡献很小。另一种替代方案是focal loss把难分类样本权重抬高但CRF的全局解码会中和focal loss的效果收益不如直接下采样明显。4.3 CRF层的实际收益与转移矩阵调试在BERT-CRF和BERT加softmax的选择上医学NER场景几乎总是选CRF。实测中CRF带来的提升在13个F1点之间且主要作用在长实体上。例如非ST段抬高型心肌梗死有10个字softmax可能把前几个字符判成实体尾部中间某个字符判为OCRF则通过转移矩阵约束B之后必须跟I或E不能跳回O。# 训练完成后打印CRF转移矩阵观察标签间的转移概率是否合理 import torch # model.crf.transitions 形状为 [num_labels, num_labels] trans model.crf.transitions.data.cpu().numpy() # 查看从B-Disease(1)到I-Disease(2)的转移概率 print(B-Disease - I-Disease:, trans[1][2]) print(B-Disease - O:, trans[1][0])调试时打印CRF转移矩阵非常有价值。训练良好的模型B-Disease - I-Disease的转移概率通常稳定在0.8以上而B-Disease - O的概率趋近于0。如果模型学到异常转移概率比如B到I的概率很低说明训练数据里标注不一致需要回到第3章做标注校验。CRF的代价是推理速度。维特比解码在17类标签、128字长的序列上单条耗时约几毫秒。若线上QPS要求上千这可能是瓶颈。替代方案是去掉CRF改用softmax加规则后处理——用一个简单状态机在解码结果上强制B-I-E约束。这个方案F1下降0.51个点但吞吐量能提升2倍以上。4.4 评估指标严格匹配与宽松匹配的取舍NER评估有两种口径。严格strict要求边界和类型全部正确宽松relaxed只要求边界有重叠且类型正确。学术评测常用strict但临床应用更关注宽松——医生手动修正一个胃溃的边界比完全找不到实体要容易得多。建议两个指标都算看增量时分开看。from seqeval.metrics import classification_report def evaluate_ner(model, dataloader, modestrict): strict模式实体完全匹配才对relaxed模式边界重叠且类型相同即可 true_entities, pred_entities [], [] for batch in dataloader: with torch.no_grad(): predictions, _ model(**batch) true_entities.extend(batch[label_sequences]) pred_entities.extend(predictions) if mode strict: # seqeval直接支持BIOES标签的严格评估 report classification_report(true_entities, pred_entities, output_dictTrue) return report else: # 宽松评估需要自己实现抽取实体span后判断边界重叠与类型一致 ...严格模式下用seqeval库即可它直接支持BIOES标签。宽松模式的自定义实现核心是分别从true和pred中抽取实体span然后判断两个span是否有字符重叠且类型一致。无论哪种模式最终都要看每个类别的F1差异——如果药物类F1显著低于疾病类通常是药物名变体多或训练样本少KG词典和别名表应该优先补充对应类别。5. 图谱融合的最后一步实体链接验证与推理加速技巧5.1 用知识图谱关系做bad case归因当模型F1停在88%附近时先不要急着调参用知识图谱把错误分类的样本做归因。方法很简单把预测错误且在图谱中能命中的实体输出关联三元组人工观察模式。def query_kg_for_failure(mention, kg_graph): 输入错误mention返回图谱中所有关联三元组 results [] for item in kg_graph: if item[head] mention or item[tail] mention: results.append(item) return results这个函数配合错误样本表格使用能把几十条bad case快速归成几类模式。常见两种错误模式mention在图谱中存在但类型判错说明KG类型特征没被充分学习去检查对应特征维度的权重或增加该类型的训练样本mention以别名形式存在但未被NER识别优先走别名表——把未命中的mention加入映射再重训或直接叠加词典特征。5.2 推理阶段的知识图谱匹配性能优化线上推理时词典匹配是整个链路里最容易被低估的性能瓶颈。如果图谱有10万实体名逐条用in dict扫描一条句子要耗时数十毫秒比BERT推理还慢。常见优化是用pyahocorasick构建AC自动机把匹配复杂度降到O(L)L是句子字符数。import ahocorasick def build_ac_from_kg(kg_entity_list): 用AC自动机索引所有图谱实体名返回匹配器 automaton ahocorasick.Automaton() for idx, entity in enumerate(kg_entity_list): automaton.add_word(entity, (idx, entity)) automaton.make_automaton() return automaton # 使用示例扫描句子中所有命中的实体 def match_entities(automaton, text): hits [] for end_idx, (idx, entity) in automaton.iter(text): start_idx end_idx - len(entity) 1 hits.append((start_idx, end_idx, entity)) return hitsmake_automaton()是构建失败指针的阶段必须在所有实体名添加完后调用一次。构建完成后扫描一个128字的句子只需微秒级时间。注意不要在每次请求时重建automaton应在进程启动时构建一次并常驻内存。匹配时还有一个命中冲突消解问题同一个位置可能匹配到胃溃和胃溃疡两个实体名这时取最长优先同时返回类型信息给KG特征生成器。如果一个位置匹配到多个相同长度的实体名则按类型优先级决定通常疾病优先于检查。5.3 验证KG增强各维度是否真实有效知识图谱增强是否有效不应该只看整个测试集上的F1而要看在业务最关心的实体子类上的表现。比如病历质控场景最关注漏报不良事件那就构造一个测试子集只包含图谱中链接了药物-副作用关系的句子对比有KG特征和无KG特征的模型在该子集上的召回率。具体做法是在测试集上运行两个版本的模型输出到两个文件再用Python脚本做维度对比按实体类型分组统计recall、按图谱关系覆盖与否分组统计recall。如果KG增强在药物-副作用子集上的召回率提升明显而在无图谱关系覆盖的子集上没有退化说明增强真实有效而不是靠整体指标掩盖局部退化。这种子集评测方式比单看整体F1更能指导面向临床场景的迭代方向。本文还有配套的精品资源点击获取