简介:面向医学知识图谱构建的实体识别综合资源,以BERT+BiLSTM+CRF三类模型融合为主线,配有完整数据与代码,适合自然语言处理初学者、进阶者、医疗AI研发人员以及想落地知识图谱的工程师。压缩包共1162个文件,约25.18MB,其中510个txt和449个ann构成标注语料,131个json存放中间处理结果,40个py与11个ipynb覆盖模型训练、评估与预测,另有PDF、Markdown等说明文档,目录按数据、代码、输出清晰划分,便于按需取用。项目从医学文献预处理、实体抽取到关系构建形成完整链路,示例中大量使用疾病、症状、药物等标注样本,能帮助读者理解BERT的双向语义表征、BiLSTM的序列依赖捕捉以及CRF的全局标签优化机制,并提供可交互调试的Jupyter Notebook。目前已有680人学习,适合作为毕业设计、科研入门或实际系统开发的参考样板,也可直接扩展至临床辅助决策等场景。
1. 把医学实体识别当成图谱的地基,BERT+BiLSTM+CRF 就是目前最稳的那一铲
医学知识图谱的价值不在“画了一张大图”,而在关系能不能被机器直接查询。关系从哪里来?从句子里的实体和实体之间的语义联系来。“阿司匹林”和“胃肠道出血”之间如果没有“不良反应”这条边,图谱就只是一堆孤立概念。而实体识别(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 | 手术操作 | 经皮冠状动脉介入治疗 |
一个容易被忽略的问题是嵌套实体。比如“胃窦黏膜糜烂”里,“胃窦”是部位,“黏膜糜烂”是症状,两段嵌套在一起。BiLSTM+CRF 是平面标注器,解决不了嵌套。常见做法是先按最外层实体标注,或者在数据层把嵌套拆成两个独立标注序列分别训练。我在实际项目里通常先做平面识别,把嵌套实体当成后续关系抽取阶段的输入去补。
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 层维护一个标签转移矩阵T,T[i][j]表示从标签 i 转移到标签 j 的得分。在训练时,模型要最大化正确标签序列的似然概率,损失函数是:
L = log_sum_exp(所有可能标签路径的得分) - 正确路径得分import torch def log_sum_exp(vec): max_score = torch.max(vec, dim=-1, keepdim=True)[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-DRUG;I-DRUG不能出现在句子开头。这些规则不用你手写,模型会从数据里自动学到,而且比手工规则更精细。
3. BERT 怎么接进来:从 token 切分到参数冻结策略
3.1 中文医学文本建议用领域预训练模型
通用 BERT 在医学文本上有个问题:分词和语义都不够“医学”。比如“呋塞米”“阿托伐他汀钙片”这类药名,通用分词器可能拆得七零八落。当前的主流做法是直接用中文医学预训练模型,比如在 PubMed 和中文医学语料上继续预训练的模型。如果训练资源受限,退而求其次是在通用 BERT 基础上自己用院内病历或公开医学语料做领域自适应预训练(继续 MLM),150 万到 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_tensors="pt", max_length=128, truncation=True) 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 三种参数训练策略的选择
BERT+BiLSTM+CRF 的参数训练有三种常见策略,效果和成本差异很大:
| 策略 | 做法 | 适用场景 | 训练时间 |
|---|---|---|---|
| 全参数微调 | BERT 和 BiLSTM、CRF 一起更新 | 标注数据充足(>2 万句) | 长 |
| 冻结 BERT | 只训 BiLSTM+CRF,BERT 只出特征 | 标注数据少、算力紧张 | 短 |
| 分层学习率 | BERT 层用较小学习率,BiLSTM+CRF 用较大学习率 | 大多数项目 | 中 |
我一般用分层学习率:BERT 部分学习率 2e-5,BiLSTM 和 CRF 层 1e-3。理由很简单:BERT 已经学好了通用语义,训练重点是让 BiLSTM+CRF 学会在医学标签空间里做决策。如果 BERT 也跟着大步长更新,很容易灾难性遗忘。
3.3 训练数据构建:标注一致性问题
医学数据标注最大的坑不是数量不够,而是标注不一致。同一个“肺部感染”,一个标注员标成B-DISEASE,另一个标成B-SYMPTOM,模型就学糊涂了。我一般会在训练前做一次标注一致性校验,统计每个实体的标签分布:
import json from collections import defaultdict label_count = defaultdict(set) with open("annotated_data.jsonl", "r", encoding="utf-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_length | 256 | 超过部分滑窗切分 |
| batch_size | 16~32 | 显存不够就 gradient_accumulation_steps=2 |
| epoch | 10~20 | 配合 early_stopping |
| optimizer | AdamW | weight_decay=0.01 |
| BERT 学习率 | 2e-5 | 太大容易遗忘预训练语义 |
| BiLSTM/CRF 学习率 | 1e-3 | 需要更快收敛 |
| warmup_ratio | 0.1 | 前 10% 步数线性升温 |
| 随机失活 | 0.1~0.3 | BERT 输出到 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 模型。另一个间接验证是看查询结果的应用效果:比如药物不良反应查询系统,能不能答出“阿司匹林 + 氯吡格雷联用增加出血风险”这种组合问题。知识图谱的价值最终要落到查询和推理上,建完图只是第一步,后续的实体链接、关系补全、图谱问答才是真正的长期工程。
本文还有配套的精品资源,点击获取