简介面向医学知识图谱构建的实体识别项目资源整合BERT、BiLSTM与CRF三种主流模型针对疾病、症状、药物等医学实体的抽取提供完整实现方案适合有一定深度学习基础的研究者或开发者用于复现实验、理解序列标注任务。压缩包共1162个文件体积约25.18MB其中txt与ann构成医学语料和实体标注数据py与ipynb为模型训练、数据处理及可视化脚本json保存配置或输出结果pdf与md提供方法说明和笔记sh与whl便于环境安装与依赖管理整体目录划分清晰便于按模块查阅与复用。已有680人学习浏览。资源覆盖了从数据准备、模型构建、实体识别到知识图谱构建的完整链路使用者可直接借助标注语料和训练脚本复现医学NER实验通过对比预测结果与ann标注快速调优同时该架构也便于迁移到关系抽取和医学知识库应用对深入理解预训练模型微调、双向序列建模以及条件随机场在标签约束中的作用有很大帮助。1. 医学实体识别为什么BERT、BiLSTM、CRF要拼在一起用处理电子病历的时候命名实体识别NER往往是整套系统的第一道关卡。症状、检查项、疾病诊断、药物名称——这些实体抽不准后续的关系抽取、知识图谱构建就全是空中楼阁。单纯拿BERT裸跑NER输出层每个token的标签是独立预测的很容易出现“B-疾病后面跟I-药物”这类明显违反医学标注约定的序列。把BERT、BiLSTM、CRF按“预训练语义抽取 序列上下文编码 全局约束解码”的分工串起来是目前医学文本NER上少有的、兼顾准确率和落地成本的做法。这套组合的定位很明确BERT负责把词变成携带上下文的向量BiLSTM在BERT输出之上再做一层序列建模CRF在最后用转移矩阵约束标签顺序。它适合处理病历、检查报告、科研文献这类需要细粒度实体边界的文本。对于想建医学知识图谱的团队来说先跑通这套模型等于把整个图谱的地基夯实了。本文就围绕这个技术栈从模型分工、可复现代码、图谱落地到参数调优和避坑完整过一遍。2. 三个模型的分工BERT打底、BiLSTM接力、CRF收口2.1 BERT在医学文本里到底提取了什么BERT给实体识别带来的核心价值是解决了传统词向量无法处理的一词多义问题。“发热”在心内科病历和感染科病历里语境完全不同基于静态词向量的模型遇到这种情况基本束手无策。BERT通过Transformer的注意力机制让每个token的表示都动态融合了整句话的信息因此“发热”在不同上下文里会得到不同的向量。医学文本中还存在大量的缩写词和罕见词静态词向量往往没有合理的表示BERT的子词分词机制能把一个生僻词拆成多个子词至少保证模型见过这些片段。这里有个重要的实践判断很多人以为医学实体识别必须用专门的BioBERT或ClinicalBERT但现实是如果你只有自练的中文BERT权重直接拿来用效果往往也够用。中文医学BERT预训练模型确实在命名实体识别任务上表现更好但公开检查点多为学术版本参数量小且部署时依赖较多。我一般先拿通用中文BERT跑一版基线确认问题规模再决定要不要换医学预训练模型。如果标注数据本身就存在噪声换医学BERT带来的提升可能还不如把数据清洗做扎实。2.2 BiLSTM在BERT和CRF之间的角色压缩噪声、重组语义把BERT的输出直接喂给CRF也完全可以工作那为什么还要在中间加一层BiLSTM这层设计并非冗余它带来两个实际收益。第一个收益是降低微调成本。BERT后接全连接层做标签预测时需要对预训练权重做较大幅度的微调才能适应新任务而BiLSTM的加入相当于在预训练表示和任务输出之间加了一个缓冲让BERT只需要负责抽取通用特征用更小的学习率就能稳步收敛。第二个收益是缓和对齐误差。BERT在中文上按字切分但很多医学实体是跨字的组合如“冠状动脉粥样硬化”BiLSTM能在序列层面重新整合这些信息捕捉相邻token间的长距离依赖。不过需要说明BiLSTM层并不能提供类似CRF的全局标签约束。它只是提升了特征表达并不会阻止模型预测出“I-疾病开头”的非法序列。所以BiLSTM之后必须再接CRF这个顺序不能颠倒。训练时BiLSTM的初始隐层维度和层数设定对效果影响不明显一般隐层维度取128或256就足够层数取1取2层需要显著增加训练时间而精度收益通常不超过1个百分点。2.3 CRF的转移矩阵在约束什么CRF层才是整个模型最有工程价值的组件。它不改变BERT和BiLSTM产出的发射分数emission score而是在标签序列层面学习一个转移分数transition score。矩阵中的每一项例如从“B-Symptom”转移到“I-Symptom”的分数都是从训练数据中统计学出来的约束。如果数据里从未出现过“I-Disease直接跟在B-Symptom后面”CRF学到的转移分就会很低解码时就不会输出这种非法组合。这套机制对医学实体识别尤其关键。医学标注方案常常包含嵌套实体、重叠实体比如“慢性阻塞性肺疾病”整体是疾病实体而其中“肺”又可能是解剖部位实体。在没有CRF的模型里这类交叉标注经常导致解码结果混乱。CRF用最优路径解码Viterbi解码保证输出序列满足全局约束虽然不是所有非法的局部组合都能被覆盖但它已经把最常见的标注规范错误挡在了门外。实际训练时CRF的损失会叠加在BERT和BiLSTM的梯度上这要求loss scale不要设置得过小否则CRF学到的转移矩阵会不够准确。3. 建立可复现的医学NER训练管线从病历清洗到模型推理3.1 医学文本的标注数据从哪里来、怎么清洗成BIO格式动手之前先把数据基础打好。医学NER的标注数据有几个常见来源公开镜像数据集如CCKS竞赛任务医院内部的科研病历库以及从医学教材中人工标注的语料。自建数据要注意病历文本中包含大量隐私信息导出时要对人名、住院号等字段做脱敏处理。这块没有捷径但可以通过主动学习策略降低标注成本——先用少量标注数据训练一版模型用它对未标注数据进行预测把置信度低或者模型“意见不统一”的样本挑出来优先人工标注。清洗是将病历文本转换成标准BIOBegin, Inside, Outside格式的过程。假设你要识别症状、检查、疾病、药物四类实体标签集就是O, B-Symptom, I-Symptom, B-Test, I-Test, B-Disease, I-Disease, B-Drug, I-Drug。下面是一个实际可用的清洗脚本它做的事情是按句切分、过滤噪声并生成标签列表import re def format_medical_text(raw_text: str) - str: # 去掉病历头部的患者基本信息区 cleaned re.sub(r[^]*?(姓名|年龄|住院号)[^]*?, , raw_text) # 将数字和英文单位之间加上空格保留必要符号 cleaned re.sub(r([0-9])([a-zA-Z]), r\1 \2, cleaned) # 除了句号和分号外其余标点统一替换为逗号方便分句 cleaned re.sub(r[;!], 。, cleaned) # 去掉连续空格 cleaned re.sub(r\s, , cleaned).strip() return cleaned # -- 标注数据结构 -- # sentences: list[str] # annotations: list[dict]每个元素形如 # {start: 0, end: 3, entity: 发热, type: Symptom} def convert_to_bio(sentences, annotations, text): labels [O] * len(text) for ann in annotations: start, end, ent_type ann[start], ann[end], ann[type] if end len(text): continue labels[start] fB-{ent_type} for i in range(start 1, end): labels[i] fI-{ent_type} return labels这段代码的核心逻辑是先用正则剥掉病历头部信息避免模型把患者隐私字段识别成医学实体再对数字和英文之间做空格处理防止BERT分词时把“50mg”拆成“50”和“mg”导致边界断裂最后按句切分并生成与原始文本等长的标签列表。需要注意text必须是按字符切分的列表中文场景下直接用字符串索引是安全的英文子词会被拆开所以如果你的文本中英文比例较高这个对齐方式要改成按BERT子词偏移量对齐。3.2 用HuggingFace加载BERT并把BiLSTM和CRF接在尾部模型结构上我采用HuggingFace的BertModel作为编码器拿到last_hidden_state后送入BiLSTM再接全连接层输出各标签的发射分数最后通过CRF计算损失和解码路径。在BERT上方加BiLSTM的一个细节是要关闭BERT返回的pooler_output只保留序列输出因为pooler_output是专为句子分类设计的直接用于NER会在每个token上引入冗余信息。CRF的实现不需要自己写pytorch-crf库的CRF类提供了完整的转移矩阵学习和Viterbi解码接口。import torch import torch.nn as nn from transformers import BertModel, BertTokenizer from torchcrf import CRF class BertBiLSTMCRF(nn.Module): def __init__(self, bert_path, num_tags, hidden_dim256, dropout0.1): super().__init__() self.bert BertModel.from_pretrained(bert_path) self.bilstm nn.LSTM( input_sizeself.bert.config.hidden_size, hidden_sizehidden_dim, num_layers1, bidirectionalTrue, batch_firstTrue, ) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_dim * 2, num_tags) self.crf CRF(num_tags) def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) sequence_output outputs.last_hidden_state lstm_out, _ self.bilstm(sequence_output) lstm_out self.dropout(lstm_out) emissions self.fc(lstm_out) if labels is not None: log_likelihood self.crf(emissions, labels, maskattention_mask.bool()) loss -log_likelihood return loss else: decoded self.crf.decode(emissions, maskattention_mask.bool()) return decoded代码中attention_mask.bool()这个细节容易被忽略。CRF的mask必须能区分真实token和padding token否则模型会把padding位置也纳入序列约束导致训练时转移矩阵被污染。还有一点BERT的last_hidden_state维度是[batch_size, seq_len, hidden_size]batch_firstTrue让BiLSTM直接对接不需要做permute操作。在推理阶段crf.decode返回的是每个样本的标签索引列表长度不固定做评估时要按attention_mask去掉padding部分再计算F1值。3.3 训练参数设定与学习率分层策略医学NER训练与普通文本分类最大的不同在于预训练模型的微调学习率要远小于随机初始化的新层。如果把BERT和BiLSTMCRF用同一个学习率1e-4去训练BERT的预训练权重会在几百步内被破坏模型整体表现会非常不稳定。我通常会做参数分组BERT参数的学习率设为5e-5而BiLSTM、全连接层、CRF层用2e-3。这套做法几乎适用于所有“预训练下游结构”的模型。from transformers import AdamW def build_optimizer(model, bert_lr5e-5, task_lr2e-3): bert_params [] task_params [] for name, param in model.named_parameters(): if name.startswith(bert.): bert_params.append(param) else: task_params.append(param) optimizer AdamW([ {params: bert_params, lr: bert_lr}, {params: task_params, lr: task_lr}, ]) return optimizerbatch size的选择也很关键。显存允许的情况下建议用32以上因为CRF转移矩阵本质上是全局统计量batch太小会导致转移概率估计的方差偏大。最大序列长度建议设为128或256医学病历中很多实体跨长句但超过256之后注意力运算时间成倍增长而F1提升接近零。如果必须处理超长文本更推荐先用规则按段落切分再分别预测而不是无限扩大序列长度。训练轮数上BERT微调3~5轮就能收敛过多轮次会导致过拟合——在医学场景中过拟合的模型往往表现为对训练病历中的特定措辞过度敏感而不是学会泛化的实体边界。3.4 推理阶段的viterbi解码和标签到文本的回帖过程训练完成后推理比很多人想象中更复杂。模型输出的是标签索引序列要还原成实体核心是处理边界合并。CRF的viterbi解码结果只是保证了标签序列的合法性但它不会自动告诉你“B-Symptom, I-Symptom, I-Symptom”结束的位置在哪里。要写一个单独的还原函数遍历标签序列遇到B-开头的标签就开启新实体遇到I-就持续追加遇到O或者新类别B-就闭合当前实体。def decode_entities(token_list, id2label): entities [] current_entity None for idx, token_id in enumerate(token_list): label id2label[token_id] if label.startswith(B-): if current_entity: entities.append(current_entity) entity_type label[2:] current_entity {start: idx, end: idx, type: entity_type, text: } elif label.startswith(I-): if current_entity and label[2:] current_entity[type]: current_entity[end] idx else: # 不合法的I开头直接丢弃 if current_entity: entities.append(current_entity) current_entity None else: if current_entity: entities.append(current_entity) current_entity None if current_entity: entities.append(current_entity) return entities回帖到原文这一步也很有讲究BERT使用wordpiece分词后一个原文中的字可能不在token列表里被拆成子词所以不能直接用模型输入的下标回帖。正确做法是把原始字符序列与子词序列建立偏移映射表第几个token对应原文第几个字符开始对应第几个字符结束。这样从解码得到的实体起止token下标就能准确还原到病历原文的字符区间。如果跳过映射实体边界会整体偏离几个字符在医学场景中“高血压病”被截取成“高血压”意味着缺失一个重要的语义单元。4. 从实体识别到医学知识图谱三元组设计与图谱写入4.1 实体识别之后的关系抽取我们还需要什么实体识别本身只能给出“有哪些实体、边界在哪”的答案知识图谱则要求给出“实体之间的关系”。医学文本中存在几类最高频关系疾病—症状如“糖尿病”伴随“多饮”、疾病—药物“高血压”使用“硝苯地平”、检查—疾病“胸部CT”诊断“肺炎”。实体识别模型输出的实体序列可以作为输入找句子中同时出现的实体对然后用一个独立的分类器判断它们之间是否存在关系这种方式称为“管道法”。管道法最大的问题是误差传播实体识别错了关系抽取大概率错。但对医学知识图谱的第一版来说管道法的可解释性和调试便利性远超联合模型。你可以在SQL里直接查某类实体被抽取的数量和置信度分布再决定要不要投入资源做联合抽取。关系分类模型可以用BERT做句子级分类把两个实体用特殊标记符包围起来例如“患者因[E1]发热[/E1]入院诊断为[E2]肺炎[/E2]”让模型判断这两个实体间的关系类别。如果训练数据不足先用规则抽取基于关键词和距离积累一定量后再切换到BERT关系分类。4.2 把实体和关系写进Neo4j一个可跟跑的Cypher实践知识图谱的存储和查询我一般首选Neo4j。它在医学实体关联查询、最短路径分析、科室-疾病-药物关系检索上的表达能力远超关系型数据库。下面这段Cypher语句演示了如何把抽取结果写入图谱// 假设实体表entities(id, name, category, source) // 关系表relations(subject_id, object_id, relation_type, confidence) UNWIND $rows AS row MERGE (e1:Entity {id: row.subject_id}) ON CREATE SET e1.name row.subject_name, e1.category row.subject_category WITH row, e1 MATCH (e2:Entity {id: row.object_id}) MERGE (e1)-[r:RELATES_TO {type: row.relation_type}]-(e2) ON CREATE SET r.confidence row.confidence, r.source row.source用UNWIND批量写入时MERGE会比CREATE安全得多它先检查节点是否存在存在则不重复创建。实体对之间同一关系如果被多条文本验证到理论上置信度应该累加但ON CREATE SET只在首次创建时生效因此如果需要更新置信度应该改成SET r.confidence coalesce(r.confidence, 0) row.confidence。另外属性名带上source字段非常关键标注了实体和关系来自哪份病历或哪篇文献后续数据审计和错误回查就全靠它了。4.3 图谱质量评估光看F1不够要看图结构知识图谱构建完成后很多工程师会陷入一个误区只关注实体识别和关系抽取的F1值却不评估图谱本身的一致性。实际上一个模型F1值很高的抽取系统构建出的图谱可能因为重复实体和矛盾关系而变得不可用。例如“高血压”和“高血压病”被识别成两个实体图谱中就出现两个节点关系查询时就会漏数据。针对这类问题需要做实体归一化基于字符串相似度加医学词典匹配将所有表达同一概念的名称合并到同一个标准节点上。MATCH (e:Entity {category: Disease}) WITH e.name AS raw_name, e CALL apoc.text.phonetic(e.name) AS phonetic WITH raw_name, collect(e) AS dupes WHERE size(dupes) 1 RETURN raw_name, size(dupes) AS duplicate_count LIMIT 50图谱质量的另一个硬性指标是孤立节点比例。如果大量实体没有任何关系边说明关系抽取召回不足图谱的使用价值会非常有限。这个指标在Neo4j里可以用一句Cypher统计出来MATCH (e:Entity) WHERE NOT (e)--() RETURN count(e)。当孤立节点占比超过30%时优先补充关系抽取而不是继续增加实体识别的样本量。这个判断逻辑非常实用能帮你明确下一阶段的项目重点。5. 医学实体识别避坑指南现象、原因和解决方案5.1 坑1CRF层loss为负数越训越低现象训练日志中CRF的负数loss绝对值持续增大验证集F1却纹丝不动。很多人第一次接触CRF看到loss永远是负数会慌以为梯度方向反了。原因CRF的loss本质上是负对数似然真实标签路径的概率越接近1算出的loss越接近0但始终为负模型训练有效时负数绝对值会逐渐变小而不是变大。如果变大说明模型正在往错误方向优化。解决优先检查学习率。BERT微调学习率若超过1e-4预训练权重被破坏CRF的发射分数会急剧变化导致loss异常。把BERT的学习率降到5e-5附近同时确认attention_mask正确应用到了CRF的mask参数——很多人会忘了这件事导致padding位置的标签参与约束计算转移矩阵被大量0概率污染。5.2 坑2中文病历里的英文和数字把实体边界切碎了现象实体识别时“阿司匹林100mg qd”被拆成“阿司匹林”“100”“mg”“qd”四个片段“qd”被识别成症状实体。原因中文BERT的词典里没有独立的英文子词且对日期、剂量等组合没有领域知识“qd”每日一次这种医学缩写缺乏上下文训练样本。解决不要在分词层面解决这个问题而是在预处理时加入规则症状和药物实体中如果包含数字和单位标注时把“100mg”作为一个整体token把常见医嘱缩写qd, bid, tid, po等加入一个固定词表在清洗阶段直接替换为中文全称如“每日一次”。这种方法比强行增加训练数据便宜得多而且规则透明度高——医学NLP项目中规则与模型结合从来不是耻辱是工程常态关键在于规则的维护边界要清晰。5.3 坑3验证集F1很高但图谱里关系一查全是错的现象实体识别模型在测试集上F1超过90%但构建出来的知识图谱里“糖尿病”与“二甲双胍”的关系竟然来自一句否定句“患者既往否认糖尿病病史未服用二甲双胍”。原因实体识别模型不考虑否定和时态它不会区分“有发热”和“无发热”。医疗文本中否定表达比例很高这对图谱质量来说是致命的。解决在实体识别和关系抽取之间增加一个否定判断层。常见做法是用一个BERT二分类模型输入实体所在的句子片段判断是“肯定”“否定”还是“疑似”。更简单的做法是维护否定词表“否认”“无”“未”“排除”当实体前面的三个字符内出现否定词时直接给这条实体打上否定标记不写入图谱或写入时带上negationTrue属性。5.4 坑4GPU显存不足batch size怎么都上不去现象CUDA out of memorybatch size调到4还是崩。原因BERTBiLSTMCRF叠加起来显存占用大头是BERT的中间层激活值而不是模型的参数量。医学文本序列长度普遍超过100动态padding让显存碎片更严重。解决先做梯度累积用一个较小的batch size跑多步再更新梯度等价于大batch效果。其次BERT层数从12层降到6层是一个被严重低估的优化手段很多医学实体识别任务上12层和6层的BERT在F1上几乎无差因为医学实体的边界更多依赖局部上下文和词典匹配而非深层语义。这个取舍需要跑一组对比实验确认但值得投入半天时间。5.5 坑5实体归一化做的太晚图谱返工成本指数上升现象图谱构建了十万个实体节点后发现“高血压”“高血压病”“原发性高血压”是三个不同节点关系查询无法聚合。原因实体识别模型输出的是文本原词不是标准医学概念。没有在写入图谱前做归一化后续所有关系数据都挂着不同别名导致查询要写一大堆OR条件。解决即使在项目早期没有标准术语表也应该做好最基础的字符串归一化去除空格和括号、全角转半角、繁体转简体、疾病名称后缀统一去掉“症”“病”等无区分性后缀。等规模做大后再引入ICD-10或者中文医学词典做语义层面的归一化。这个坑最伤人的地方在于前期做实体识别优化而推迟归一化导致后期返工涉及的代码和数据量完全不成比例。6. 从模型到可用的知识检索验证方法、后处理与维护习惯最后这段聊聊模型上线后的落地细节。模型训完、图谱写入了不等于系统能用了还有三件事需要认真做。第一件事是建立回归测试集。模型更新时你一定要有一套固定的、包含200-500个典型病历句子的测试集每次模型迭代后都在这套测试集上重新跑一遍实体识别和关系抽取。这是一个很惨痛的教训有一次我升级了模型版本整体F1提升了2个百分点但恰好把“肌酐清除率”这个实体从“检查项”错误识别成了“症状”而其他测试样本全是正常的。如果不做回归测试这个倒退可能要过几周才能被下游业务发现。把每轮迭代的测试结果连同badcase截图保存下来形成版本档案这是团队协作中最有价值的资产。第二件事是后处理规则的积累。我建议把规则和模型的配合看成一套组合拳模型负责发现实体边界规则负责修正模型不擅长的细节。常见病和药物的同义词映射例如“阿司匹林”与“乙酰水杨酸”、计量单位的统一mg和毫克、频繁出现的病历模板忽略“患者无特殊不适主诉”这类套话不需要抽取实体都适合沉淀成独立的Python模块。这样积累两个月左右实体识别和关系抽取的整体准确率能比纯模型再提高5-8个百分点而代价只是一些简单的字典匹配。第三件事是建立人工抽检机制。医学知识图谱不像商品推荐系统错了后果很严重。项目上线初期每天从新增的实体关系对中随机抽取50条交给标注人员复核把误报率控制在5%以内再扩大使用范围。这个机制在成本上远低于全量复核但它提供了持续的反馈循环每周把抽检中发现的高频错误类型汇总反馈给数据标注和模型训练环节整个系统的成长才是有方向感的。做医学NLP项目最重要的一个认知是模型指标只是入场券真正决定系统价值的永远是数据治理和工程细节。我自己的习惯是每跑完一个实验都会写一小段“如果重来一次我会在哪改动”这个习惯帮我避开了大量重复踩坑。医学文本的复杂性远超通用领域的想象实体识别永远做不完但把拿到的数据处理干净、把模型和规则的分工想清楚、把知识图谱的口径统一好这套方案就能在一线发挥实际价值。希望这些经验能帮你在建立医学知识图谱的路上少走几段弯路。本文还有配套的精品资源点击获取