简介:面向医学知识图谱构建的实体识别项目资源,整合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竞赛任务),医院内部的科研病历库,以及从医学教材中人工标注的语料。自建数据要注意:病历文本中包含大量隐私信息,导出时要对人名、住院号等字段做脱敏处理。这块没有捷径,但可以通过主动学习策略降低标注成本——先用少量标注数据训练一版模型,用它对未标注数据进行预测,把置信度低或者模型“意见不统一”的样本挑出来优先人工标注。
清洗是将病历文本转换成标准BIO(Begin, 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] = f"B-{ent_type}" for i in range(start + 1, end): labels[i] = f"I-{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_dim=256, dropout=0.1): super().__init__() self.bert = BertModel.from_pretrained(bert_path) self.bilstm = nn.LSTM( input_size=self.bert.config.hidden_size, hidden_size=hidden_dim, num_layers=1, bidirectional=True, batch_first=True, ) 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, labels=None): outputs = self.bert(input_ids=input_ids, attention_mask=attention_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, mask=attention_mask.bool()) loss = -log_likelihood return loss else: decoded = self.crf.decode(emissions, mask=attention_mask.bool()) return decoded代码中attention_mask.bool()这个细节容易被忽略。CRF的mask必须能区分真实token和padding token,否则模型会把padding位置也纳入序列约束,导致训练时转移矩阵被污染。还有一点:BERT的last_hidden_state维度是[batch_size, seq_len, hidden_size],batch_first=True让BiLSTM直接对接,不需要做permute操作。在推理阶段,crf.decode返回的是每个样本的标签索引列表,长度不固定,做评估时要按attention_mask去掉padding部分再计算F1值。
3.3 训练参数设定与学习率分层策略
医学NER训练与普通文本分类最大的不同在于:预训练模型的微调学习率要远小于随机初始化的新层。如果把BERT和BiLSTM+CRF用同一个学习率1e-4去训练,BERT的预训练权重会在几百步内被破坏,模型整体表现会非常不稳定。我通常会做参数分组:BERT参数的学习率设为5e-5,而BiLSTM、全连接层、CRF层用2e-3。这套做法几乎适用于所有“预训练+下游结构”的模型。
from transformers import AdamW def build_optimizer(model, bert_lr=5e-5, task_lr=2e-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 坑1:CRF层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二分类模型,输入实体所在的句子片段,判断是“肯定”“否定”还是“疑似”。更简单的做法是维护否定词表(“否认”“无”“未”“排除”),当实体前面的三个字符内出现否定词时,直接给这条实体打上否定标记,不写入图谱或写入时带上negation=True属性。
5.4 坑4:GPU显存不足,batch size怎么都上不去
现象:CUDA out of memory,batch size调到4还是崩。原因:BERT+BiLSTM+CRF叠加起来,显存占用大头是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项目,最重要的一个认知是:模型指标只是入场券,真正决定系统价值的永远是数据治理和工程细节。我自己的习惯是每跑完一个实验,都会写一小段“如果重来一次我会在哪改动”,这个习惯帮我避开了大量重复踩坑。医学文本的复杂性远超通用领域的想象,实体识别永远做不完,但把拿到的数据处理干净、把模型和规则的分工想清楚、把知识图谱的口径统一好,这套方案就能在一线发挥实际价值。希望这些经验能帮你在建立医学知识图谱的路上少走几段弯路。
本文还有配套的精品资源,点击获取