简介面向医学知识图谱构建的实体识别综合资源以BERTBiLSTMCRF三类模型融合为主线配有完整数据与代码适合自然语言处理初学者、进阶者、医疗AI研发人员以及想落地知识图谱的工程师。压缩包共1162个文件约25.18MB其中510个txt和449个ann构成标注语料131个json存放中间处理结果40个py与11个ipynb覆盖模型训练、评估与预测另有PDF、Markdown等说明文档目录按数据、代码、输出清晰划分便于按需取用。项目从医学文献预处理、实体抽取到关系构建形成完整链路示例中大量使用疾病、症状、药物等标注样本能帮助读者理解BERT的双向语义表征、BiLSTM的序列依赖捕捉以及CRF的全局标签优化机制并提供可交互调试的Jupyter Notebook。目前已有680人学习适合作为毕业设计、科研入门或实际系统开发的参考样板也可直接扩展至临床辅助决策等场景。1. 把医学实体识别当成图谱的地基BERTBiLSTMCRF 就是目前最稳的那一铲医学知识图谱的价值不在“画了一张大图”而在关系能不能被机器直接查询。关系从哪里来从句子里的实体和实体之间的语义联系来。“阿司匹林”和“胃肠道出血”之间如果没有“不良反应”这条边图谱就只是一堆孤立概念。而实体识别NER做不好后面所有关系抽取、属性对齐、图谱问答都是空中楼阁。在中文医学文本上纯 BERT 微调做序列标注有个典型问题模型可能把“左肺上叶”切成“左肺/上叶”或者把“肺腺癌”标成“疾病”而漏掉它同时是“解剖部位”的修饰关系。这本质上是标签之间缺少转移约束。把 BiLSTM 接在 BERT 后面做序列特征再压缩最后用 CRF 约束标签转移路径是工业界最常见的折中方案BERT 负责把字义和上下文压成向量BiLSTM 负责捕捉句子内部的顺序依赖CRF 负责保证输出标签序列“合法”。三个方面各管一段比单独用其中任何一个都稳。本文会把这条链路从标签体系、模型拼接、训练参数一路拆到图谱入库和验证方法每一步都给出可以直接抄走的东西。适合正在做医疗信息化、临床科研数据结构化、病历质控的工程师也适合想从“调包跑通”进阶到“知道为什么这样设计”的算法同学。2. 先定标签体系再谈 BiLSTM 和 CRF 的分工2.1 医学 NER 的标签集合不能照搬通用领域通用 NER 常用B-PER / I-PER / O这类标签但医学文本里“时间”“解剖部位”“症状”往往比人名地名更重要。我一般会把标签体系按图谱需要的维度重新设计比如标签含义示例B-DISEASE / I-DISEASE疾病冠心病、2型糖尿病B-SYMPTOM / I-SYMPTOM症状体征胸闷、双下肢水肿B-DRUG / I-DRUG药物阿司匹林肠溶片B-DOSAGE / I-DOSAGE剂量用法每次100mg、每日一次B-BODY_PART / I-BODY_PART解剖部位左肺上叶、胃窦B-CHECK / I-CHECK检查项目冠脉CTA、胃镜B-PROCEDURE / I-PROCEDURE手术操作经皮冠状动脉介入治疗一个容易被忽略的问题是嵌套实体。比如“胃窦黏膜糜烂”里“胃窦”是部位“黏膜糜烂”是症状两段嵌套在一起。BiLSTMCRF 是平面标注器解决不了嵌套。常见做法是先按最外层实体标注或者在数据层把嵌套拆成两个独立标注序列分别训练。我在实际项目里通常先做平面识别把嵌套实体当成后续关系抽取阶段的输入去补。2.2 BiLSTM 在这里不是特征提取器是序列特征压缩器BERT 输出的每个 token 向量已经包含了上下文信息但维度通常在 768 或 1024直接喂给 CRF 做标签决策有两个问题维度太高导致 CRF 的转移矩阵学不动向量里和标签决策无关的信息太多。BiLSTM 在这里做的事情是把 BERT 输出序列压缩成一个更低维、更贴近标签决策的隐状态序列。前向 LSTM 按句子顺序读入h_1, h_2, ..., h_n后向 LSTM 按逆序读入最终每个位置的隐状态是前向和后向的拼接h_t_final [h_t_forward; h_t_backward]这个拼接向量再经过一个线性层映射到标签数量维度的得分向量E表示“第 t 个字是某种标签的未归一化得分”。需要说明的是BiLSTM 并不强行决定每个字的标签它只是给每个标签打一个分数。真正的决策权在 CRF 手里。2.3 CRF 学的是标签之间的“合法转移”CRF 层维护一个标签转移矩阵TT[i][j]表示从标签 i 转移到标签 j 的得分。在训练时模型要最大化正确标签序列的似然概率损失函数是L log_sum_exp(所有可能标签路径的得分) - 正确路径得分import torch def log_sum_exp(vec): max_score torch.max(vec, dim-1, keepdimTrue)[0] return torch.log(torch.sum(torch.exp(vec - max_score), dim-1)) max_score.squeeze(-1) def forward_loss(emissions, tags, mask, trans): emissions: (batch, seq_len, num_tags) BiLSTM 输出的标签得分 tags: (batch, seq_len) 真实标签 mask: (batch, seq_len) padding 掩码 trans: (num_tags, num_tags) CRF 转移矩阵 batch_size, seq_len, num_tags emissions.shape score torch.zeros(batch_size) for t in range(1, seq_len): score trans[tags[:, t - 1], tags[:, t]] * mask[:, t].float() score emissions[torch.arange(batch_size), t, tags[:, t]] * mask[:, t].float() # 这里省略了 log_sum_exp 的完整实现实际用维特比算法或前向算法计算 return score这段代码展示了 CRF 训练的核心逻辑真实路径的得分由两部分相加一部分是转移矩阵中相邻标签的转移得分另一部分是 BiLSTM 在当前时间步给真实标签的发射得分。padding 位置用 mask 屏蔽不让它参与计算。解码时用维特比算法在全部标签路径里找得分最高的那条而不是简单地在每个时间步取 argmax这是 CRF 和 Softmax 标注的本质区别。CRF 的另一个隐性价值是它能学到一些硬规则。比如B-DISEASE后面只能跟I-DISEASE或O不能直接跟I-DRUGI-DRUG不能出现在句子开头。这些规则不用你手写模型会从数据里自动学到而且比手工规则更精细。3. BERT 怎么接进来从 token 切分到参数冻结策略3.1 中文医学文本建议用领域预训练模型通用 BERT 在医学文本上有个问题分词和语义都不够“医学”。比如“呋塞米”“阿托伐他汀钙片”这类药名通用分词器可能拆得七零八落。当前的主流做法是直接用中文医学预训练模型比如在 PubMed 和中文医学语料上继续预训练的模型。如果训练资源受限退而求其次是在通用 BERT 基础上自己用院内病历或公开医学语料做领域自适应预训练继续 MLM150 万到 300 万字就能看到明显效果。from transformers import BertTokenizer, BertModel tokenizer BertTokenizer.from_pretrained(pretrained_medical_bert_dir) model BertModel.from_pretrained(pretrained_medical_bert_dir) text 患者因胸痛伴大汗淋漓2小时入院 inputs tokenizer(text, return_tensorspt, max_length128, truncationTrue) outputs model(**inputs) # 取最后一层隐状态作为 BiLSTM 的输入 bert_output outputs.last_hidden_state # (1, seq_len, hidden_size)BERT 的输入是字级别的中文 BERT 基本按字切分所以不需要额外分词器。[CLS]和[SEP]这两个特殊 token 对应的位置在序列标注时要丢掉不进入 CRF 的标签序列。这一点经常被忽略导致标签序列和 Bert 的 token 序列长度对不上。3.2 三种参数训练策略的选择BERTBiLSTMCRF 的参数训练有三种常见策略效果和成本差异很大策略做法适用场景训练时间全参数微调BERT 和 BiLSTM、CRF 一起更新标注数据充足2 万句长冻结 BERT只训 BiLSTMCRFBERT 只出特征标注数据少、算力紧张短分层学习率BERT 层用较小学习率BiLSTMCRF 用较大学习率大多数项目中我一般用分层学习率BERT 部分学习率 2e-5BiLSTM 和 CRF 层 1e-3。理由很简单BERT 已经学好了通用语义训练重点是让 BiLSTMCRF 学会在医学标签空间里做决策。如果 BERT 也跟着大步长更新很容易灾难性遗忘。3.3 训练数据构建标注一致性问题医学数据标注最大的坑不是数量不够而是标注不一致。同一个“肺部感染”一个标注员标成B-DISEASE另一个标成B-SYMPTOM模型就学糊涂了。我一般会在训练前做一次标注一致性校验统计每个实体的标签分布import json from collections import defaultdict label_count defaultdict(set) with open(annotated_data.jsonl, r, encodingutf-8) as f: for line in f: item json.loads(line) for entity in item[entities]: label_count[entity[text]].add(entity[label]) # 找出同一实体文本被标成不同标签的样本 conflict {text: labels for text, labels in label_count.items() if len(labels) 1} print(f冲突实体数量: {len(conflict)})这段代码把标注数据里同一实体文本对应多种标签的情况直接列出来。医院数据里的简称、英文缩写比如“CAD”既可能是“冠状动脉疾病”也可能是“计算机辅助诊断”也会在这里暴露。冲突样本要么统一规则要么直接删除不要指望模型自己学会区分。数据量方面一个可参考的经验是单标签体系下5000 句人工标注能跑到 F1 0.80 左右20000 句能到 0.88 左右再往上就要靠半监督用训练好的模型打伪标签人工复核和规则兜底了。另外BERT 支持的最大长度一般是 512 token医学影像报告往往超长我通常的做法是滑窗切分窗口 256 或 300重叠 30 个字避免实体正好被切断。4. 训练、推理和工程化的关键细节4.1 训练超参的推荐配置给出一个经过验证的参考配置不代表最优但足够作为起点参数推荐值说明max_length256超过部分滑窗切分batch_size16~32显存不够就 gradient_accumulation_steps2epoch10~20配合 early_stoppingoptimizerAdamWweight_decay0.01BERT 学习率2e-5太大容易遗忘预训练语义BiLSTM/CRF 学习率1e-3需要更快收敛warmup_ratio0.1前 10% 步数线性升温随机失活0.1~0.3BERT 输出到 BiLSTM 前加 dropout训练时监控两个值验证集 F1 和 CRF loss。如果 loss 震荡不降第一件事看学习率是不是太大如果 F1 涨到某个点就停滞检查是不是标签体系里存在高频冲突。训练日志里要同时记录 token 级准确率和实体级 F1两者差距大说明边界预测有问题通常是要么标签转移约束不够要么数据里有大量边界模糊的实体。4.2 推理阶段的实测代码和结果解读训练完成后推理阶段需要把 BERT token 和原始字位置对齐。直接用 HuggingFace pipeline 会丢失实体级别的信息一般要自己写解码逻辑def decode_entities(text, tokenizer, label_ids, id2label): tokens tokenizer.tokenize(text) entities [] current_entity None for idx, token in enumerate(tokens): token token.replace(##, ) # 英文或医学缩写可能被 WordPiece 切开 label id2label[label_ids[idx]] if label.startswith(B-): if current_entity: entities.append(current_entity) current_entity {text: token, label: label[2:]} elif label.startswith(I-) and current_entity: current_entity[text] token else: if current_entity: entities.append(current_entity) current_entity None if current_entity: entities.append(current_entity) return entities这段代码的思路是按 token 顺序扫描遇到B-开头的标签就开启一个新实体遇到I-标签且当前有实体就续接遇到O或标签不连续就断开。因为中文医学实体里英文缩写不少比如“CT”“MRI”“TNM分期”WordPiece 会把它们切成多个子词所以替换##前缀重新拼接文本。一个需要强调的坑是CRF 解码可能输出B-DISEASE - I-DRUG这种边界不连续的路径虽然转移矩阵学了规则但数据里如果噪声大偶发错误还是会存在。所以上面代码里当I-标签和当前实体类型不一致时选择断开而不是强行拼接。4.3 推理速度优化ONNX 和 batch 策略医学场景如果要做全量历史病历的离线抽取BERT 的推理速度是瓶颈。常见做法是把模型转成 ONNX 并用 GPU 批量推理。1000 条文本的 batch 推理单卡跑完大约比逐条快 8 到 10 倍。如果要有更轻量的部署知识蒸馏是另一种思路用 BERT 的输出作为 teacher 信号蒸馏到一个 BiLSTM-CRF 小模型上在 CPU 上能做到单条毫秒级。代价是 F1 会掉 2 到 4 个点适合对延迟敏感的接口。5. 实体识别之后对齐、消歧和 Neo4j 入库5.1 从实体文本到图谱节点的两条路NER 模型的输出是“字符串 类型”但知识图谱的节点必须是唯一标识。同一个实体在病历里可能有不同写法“冠心病”和“冠状动脉粥样硬化性心脏病”是同一个概念“阿斯匹林”和“阿司匹林”是同一个药。直接拿字符串当节点图谱里会出现大量重复节点查询时一塌糊涂。常见做法是维护一个医学同义词表或者对接已有的医学本体如 ICD-10、ATC 分类。实体识别完成后先做一次归一化映射再决定是新建节点还是挂到已有节点下。没有现成本体可用时可以用规则做同义归一括号内缩写展开、中英文对照表、全角半角统一、单位标准化。归一化这一步的质量直接影响图谱的可用性值得花时间和医生一起整理一张高质量同义词表。5.2 三元组抽取和 Neo4j 写入实体识别只是第一步图谱还需要关系。关系抽取可以用基于规则的依赖分析比如找到“导致”“治疗”“禁忌”这些触发词也可以用远程监督训练的抽取模型。医学场景建议规则和模型并行规则保证高精度模型负责召回。把三元组写入 Neo4j 时需要注意节点要带label属性区分类型比如:Disease、:Drug这样 Cypher 查询可以直接按类型过滤关系类型用小写或下划线风格统一命名如(:Disease)-[:TREATS]-(:Drug)表示“该药治疗该病”保持一致对同一对实体间的重复关系做合并避免图谱膨胀写入时用MERGE而不是CREATE防止重复节点// 创建疾病和药物节点并建立治疗关系 MERGE (d:Disease {name: 冠心病}) MERGE (dr:Drug {name: 阿司匹林}) MERGE (d)-[:TREATS]-(dr) // 查询治疗冠心病的药物 MATCH (d:Disease {name: 冠心病})-[:TREATS]-(dr:Drug) RETURN dr.nameMERGE的语义是“有就复用没有就创建”用来防重。如果图谱数据量大先批量写入节点再建立关系比逐条MERGE快很多。有一种常见失败场景是图谱只显示了 25 个标签——这是 Neo4j Browser 默认限制不是数据问题把显示条数调大或者用 Cypher 聚合查询即可。5.3 如何验证图谱是真的“对”的图谱建完不能只看节点数。最有效的验证方式是随机抽取 100 个三元组让医生标注“正确/错误/不确定”计算精确率。如果精确率低于 95%问题大概率出在实体归一化而不是 NER 模型。另一个间接验证是看查询结果的应用效果比如药物不良反应查询系统能不能答出“阿司匹林 氯吡格雷联用增加出血风险”这种组合问题。知识图谱的价值最终要落到查询和推理上建完图只是第一步后续的实体链接、关系补全、图谱问答才是真正的长期工程。本文还有配套的精品资源点击获取 SEO 优化官网定制响应式建站教育培训建站