
简介面向医学文本智能处理的深度学习实体关系抽取项目包聚焦中文医学文本中病症、临床表现、化学成分等实体识别与语义关联构建旨在解决医学文献和诊疗记录中非结构化文本的知识抽取难题适用于自然语言处理、知识图谱方向的开发者与研究者。压缩包共113个文件以77个Python脚本为主体覆盖数据预处理、模型训练、模块调用与结果评估等完整实验链路另有7个txt说明、5个yml环境配置、2个Markdown文档以及Matlab/Perl辅助脚本整体仅157KB轻量易部署。已有52人学习下载。通过代码与README可了解基于规则、统计模型及深度神经网络的多种提取路径掌握从实体标注到关系抽取的完整流程并借鉴CHIP2020相关数据集思路开展迁移实践代码结构清晰便于二次开发与算法对比。资源整理自公开分享适合用于学术交流、课题复现及课程设计参考。1. 医学实体关系抽取到底在解决什么问题从病历三元组到知识图谱临床研究者面对电子病历里的一句“服用阿司匹林后出现胃肠道出血”想要的不是保留原句而是把这句话转成“阿司匹林—导致—胃肠道出血”这样一组结构化事实。这正是基于深度学习的医学实体关系抽取要解决的核心问题先从文本中识别出药物、疾病、症状、检查等医学实体再判断实体之间的语义关系比如导致、治疗、预防、相关、相互作用。在药物警戒、临床辅助决策和医学知识图谱构建里这一步几乎是所有上游应用的前置条件。这个方向和通用领域的实体关系抽取同源但医学文本的缩写歧义、嵌套实体、类别不平衡问题比通用领域严重得多直接套普通模型往往翻车。适合动手的人是已经有 GPU 版深度学习环境、手上有医学语料或公开数据集、想从零跑通实体识别和关系抽取的 NLP 从业者以及准备评估这个方向是否值得投入的医学信息团队。2. 数据先行医学语料的实体定义与关系对齐决定模型天花板2.1 医学实体类型比通用NER复杂在哪先定义实体再谈模型通用命名实体识别通常只区分人名、地名、机构名到了医学语料里实体类型的复杂程度会直接失控。一套常见的医学实体体系至少要覆盖 Disease、Drug、Symptom、Test、Procedure、Chemical、Gene 这些大类而大类之下往往还要细分到解剖部位、微生物、生理指标等。更麻烦的是医学命名本身不稳定同一个实体在不同来源里有不同的写法。以“ACEI”为例在药理文档里它是血管紧张素转换酶抑制剂在基因文档里可能代表其他缩写而普通深度学习模型拿到这个词时只能根据上下文猜测。医学文本还有大量嵌套结构比如“非小细胞肺癌”里嵌着“肺癌”“2型糖尿病伴视网膜病变”里包含了疾病和并发症两层信息。如果数据标注时没有把实体边界和类型层级定义清楚后续关系抽取无论用什么模型都会在同一个位置反复出错。我的经验是医学实体关系抽取项目启动前第一件事不是选模型而是先做一份标注口径文档。常见做法是参照 UMLS 或 SNOMED CT 这样的术语体系把实体对齐到统一概念 ID同时规定“一个实体跨度是否可以包含另一个实体”“修饰词如‘重度’是否计入边界”等细则。深度学习模型的输入输出都依赖实体边界边界一旦不一致关系标签就会张冠李戴。数据准备阶段花的力气后面会在训练和调参时全部省回来。这也解释了为什么公开数据集和自建数据在处理上不能直接套同一个脚本。2.2 公开语料怎么选CDR、DDI、BC5CDR、i2b2 的关系类型与数据格式拿来即用的医学实体关系抽取语料并不少但每个数据集的实体体系和关系类型差异很大选择时先要看你的落地场景到底需要什么关系。下面是我常用的几个公开数据集覆盖了药物警戒、基因疾病关联和临床叙述这几个典型方向。数据集实体类型关系类型适合场景BC5CDRChemical, Disease化学物-疾病关联药物副作用挖掘CDRChemical, Disease导致/治疗/预防等药物警戒、知识图谱DDIDrug, Drug药物相互作用用药安全审查i2b2 2010临床问题、治疗、检查治疗/评估等电子病历研究GADGene, Disease基因-疾病关联精准医学、致病基因分析这些数据集大多以 PubTator 或 BioC 格式发布原始字段各有差异。如果直接为每个数据集写死解析器项目还没开始就陷进格式泥潭。我一般会先把所有原始标注统一成一种中间 JSONL 格式每条记录包含文本、实体列表和关系列表关系通过实体下标引用。下面这个脚本就是通用转换模板。import json def load_annotated_records(jsonl_path): 读取统一 JSONL 标注文件每行一条 JSON。 records [] with open(jsonl_path, encodingutf-8) as f: for line in f: line line.strip() if line: records.append(json.loads(line)) return records def build_relation_samples(records): 把实体列表 关系列表转换成关系抽取训练样本。 samples [] for rec in records: text rec[text] for rel in rec[relations]: head rec[entities][rel[head_idx]] tail rec[entities][rel[tail_idx]] samples.append({ text: text, head_text: head[mention], head_type: head[type], tail_text: tail[mention], tail_type: tail[type], relation: rel[type] }) return samples这段代码的逻辑不复杂但很关键。load_annotated_records把每行一条的 JSON 记录读进来build_relation_samples通过实体下标把关系三元组展开成模型训练要用的样本结构。这里用下标而不是实体文本连接是为了避免同一个文档里出现两个“阿司匹林”时关系对不上号。转换完成后的每条样本都独立携带上下文、头尾实体文本和关系标签后续做模型输入构造时就不再关心原始数据集长什么样子。除了字段映射负样本生成也在这个阶段完成。关系抽取数据里绝大多数实体对之间没有关系如果把所有实体对都当成负样本训练集会异常庞大模型也会被“无关系”类别淹没。常见做法是在同一文档内随机采样头尾实体对控制负样本与正样本比例在 3:1 到 10:1 之间然后在验证集中保留真实分布防止评估指标虚高。这一步会直接影响关系分类的召回率值得反复调。3. 模型怎么选从 BiLSTM 到 BioBERT 的医学适配路径3.1 从流水线到联合抽取两种建模方式的取舍医学实体关系抽取的主流建模方式可以分成两种流水线和联合抽取。流水线思路是先训练一个 NER 模型把文本里所有医学实体识别出来再对任意两个实体构造一个关系分类样本训练第二个模型判断它们之间是否有关系以及是哪种关系。联合抽取思路则用一个模型同时输出实体边界和关系三元组常见实现有基于跨度的分类、基于序列到序列的生成式抽取。流水线最大的优点是模块清晰实体识别和关系分类可以分别调优。医学实体识别本身就有很多特殊问题比如缩写归一化、实体嵌套如果混在关系模型里一起调定位错误来源会非常痛苦。缺点也明显NER 一旦漏掉实体后面的关系分类根本拿不到这个实体对误差会层层传递。另外一个文档里每两个实体都要过一遍关系分类器计算量随实体数平方增长。联合抽取在理论上能缓解误差累积因为它让模型在判断关系的同时感知实体边界但训练复杂度高得多尤其当关系类型多、实体类型杂时模型容易学会“取巧”而忽略真实语义。我的建议是第一版先用流水线建基线把实体识别和关系分类各自的指标跑出来当发现实体识别的 F1 已经不错、但关系召回率明显偏低时再考虑迭代到联合抽取方案。在医学场景里实体标注的规范性比模型结构更影响上限流水线至少能让你知道问题出在哪一环。3.2 为什么医学领域要用 BioBERT 或 PubMedBERT 而不是通用 BERT如果是通用领域的关系抽取加载一个预训练好的 BERT-base 就能跑但医学文本里缩写和术语密度太高通用模型的效果会打折扣。以“COPD”这个词为例通用 BERT 的分词器很可能把它拆成“CO”和“PD”两个子词语义信息在拆碎过程中损耗不少。医学领域的预训练模型比如 BioBERT 和 PubMedBERT是在 PubMed 摘要和全文上训练的对“COPD”“ACEI”这类医学缩写和术语的表示更完整加载方式也简单。from transformers import AutoTokenizer, AutoModelForSequenceClassification model_name microsoft/BiomedNLP-PubMedBERT-base-uncased-abstract-fulltext tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForSequenceClassification.from_pretrained( model_name, num_labelsnum_relations )这段代码加载了 PubMedBERT 的 tokenizer 和分类模型num_relations是你自己的关系类别数量。改模型时只需要换model_name其他代码不需要动。BioBERT 和 PubMedBERT 的预训练数据都来自医学文献区别在于 PubMedBERT 的训练词表更大对现代医学文本的覆盖更好所以我现在优先选 PubMedBERT。如果你处理的是电子病历而不是论文摘要还要额外注意文本的噪音程度必要时在领域数据上做增量预训练这一步会明显提升下游关系抽取的效果。加载完预训练模型后需要在 tokenizer 里加入实体标记。这是因为关系分类模型必须知道一句话里哪两个实体是“候选头尾实体”否则它只能靠猜。常见做法是加入四个特殊 token分别表示头实体开始、头实体结束、尾实体开始、尾实体结束。配合 PubMedBERT 的预训练权重这套配置在医学文本上的起步表现通常远好于直接用通用 BERT而且不用担心深度学习环境差异PyTorch 和 Hugging Face Transformers 的标准配置就能跑起来。4. 跑通最小可训练管线PyTorch 与 PubMedBERT 的关系分类实现4.1 训练样本构造实体标记与上下文长度控制关系分类的输入不能只把原句丢给模型必须显式标记出候选实体对的位置。我经常看到初学者直接把“阿司匹林”和“胃肠道出血”两个实体拼接在句子后面模型根本不知道这两个词和句子里的哪些 token 对应。正确的做法是在句子中实体跨度前后插入特殊标记类似“[E1] 阿司匹林 [/E1] 可能导致 [E2] 胃肠道出血 [/E2]”。下面这段代码实现了这个逻辑。def build_marked_text(rec, marker_tokens): 在原句中用 marker 包住候选头尾实体。 text rec[text] head rec[head_text] tail rec[tail_text] marked text.replace(head, f {marker_tokens[head_start]} {head} {marker_tokens[head_end]} , 1) marked marked.replace(tail, f {marker_tokens[tail_start]} {tail} {marker_tokens[tail_end]} , 1) return marked def tokenize_relation_sample(rec, tokenizer, max_length512): marker_tokens { head_start: [E1], head_end: [/E1], tail_start: [E2], tail_end: [/E2], } marked_text build_marked_text(rec, marker_tokens) encoded tokenizer( marked_text, truncationTrue, max_lengthmax_length, paddingmax_length, return_attention_maskTrue, return_tensorspt, ) return { input_ids: encoded[input_ids][0], attention_mask: encoded[attention_mask][0], labels: label_to_id[rec[relation]], }这里的build_marked_text使用str.replace把实体文本替换成带标记的版本replace只替换第一次出现避免同一实体名在句中出现多次时全部被标记。tokenize_relation_sample返回模型需要的input_ids、attention_mask和关系标签。需要注意实体文本在原文中可能被分词器拆成多个子词简单替换后的标记位置不一定精确对应实体边界但作为最小可训练管线它能让你先跑通反向传播。如果你处理的长文本较多max_length 的截断策略比标记位置更值得关注。默认截断是从句子开头保留 512 个 token但医学关系经常出现在文档中段甚至跨句。我的经验是改成以候选实体为中心的滑动窗口取实体前后各 128 个 token 作为上下文这样能显著减少关系对被截断搞丢的概率。这个调整放在数据预处理阶段而不是训练循环里效果更可控。4.2 训练循环与关键超参数学习率、warmup、类别权重训练关系分类模型本质上是在微调一个预训练语言模型常规 SGD 那种大步长策略在这里非常危险。下面是一个最小可训练的 PyTorch 训练循环我保留了最核心的部分方便你直接抄去改。from torch.utils.data import DataLoader from transformers import AdamW, get_linear_schedule_with_warmup import torch model.train() optimizer AdamW(model.parameters(), lr3e-5, 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, ) for epoch in range(epochs): for batch in train_dataloader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) outputs model( input_idsinput_ids, attention_maskattention_mask, labelslabels, ) loss outputs.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad()这里我刻意用了AdamW配合get_linear_schedule_with_warmup这是 BERT 系列微调的常规组合。学习率 3e-5 是经验起点医学语料如果噪音大降到 2e-5 更稳clip_grad_norm_(1.0)防止个别样本的梯度把预训练权重冲坏。weight_decay0.01是 L2 正则化的工程实现能抑制过拟合但作用不如数据增强明显。如果你显卡显存不够可以减小 batch size 到 8 或 4然后用梯度累积步数补足总 batch size。损失函数默认用交叉熵但如果关系类别不平衡比如“无关系”占了样本的 80% 以上普通交叉熵会让模型倾向于把所有样本都预测成多数类。我一般先统计训练集每个关系类别的数量再把频率倒数作为类别权重传入损失函数。from torch.nn import CrossEntropyLoss class_weights torch.tensor([1.0, 0.5, 2.0, ...]).to(device) loss_fct CrossEntropyLoss(weightclass_weights) logits outputs.logits loss loss_fct(logits.view(-1, num_labels), labels.view(-1))这段代码展示的是手动构建类别权重的做法。class_weights里的数值要按你的关系类别顺序排列权重越高模型对该类别的错分惩罚越大。实际操作中我会把训练集里的“无关系”类权重压在 0.1 到 0.3 之间把罕见但重要的关系类权重放到 2 以上然后观察验证集每类的 recall 变化再微调权重。这个环节没有玄学公式基本是靠跑实验和看错误样本迭代出来的血泪经验但比换模型结构收益大得多。5. 避坑指南医学文本让关系抽取翻车的 5 个高发点5.1 实体标记错位分词器把“阿司匹林”拆成三个 token现象训练 loss 降得很快验证集的 F1 却一直在振荡把预测结果打印出来发现模型判断的关系类型基本正确但实体边界整体偏移一两个字符。原因str.replace按字符匹配实体文本而 BERT 分词器会把“阿司匹林”拆成多个子词插入的[E1]标记落在了 tokenize 之后的错误位置模型实际看到的头尾实体并不是你标注的那两个词。解决不要直接用字符替换后的文本做标记对齐改用 tokenizer 返回的offset_mapping把实体在原文中的字符起始和结束位置映射到 token 下标再在对应 token 前后插入实体标记。这个方法能同时处理中文、英文缩写和标点导致的错位问题是医学文本关系抽取里必踩的一道坎。5.2 关系类别严重不平衡只看 accuracy 会让你误判模型“不错”现象验证集 accuracy 有 85%但把每个类别拆开看“无关系”类的 recall 有 95%真正关心的“导致”和“治疗”关系 recall 只有 20%。原因医学关系抽取数据里无关系样本天然占多数普通交叉熵损失函数会把模型推向多数类accuracy 被无关系类别拉高掩盖了少数类关系几乎没学到的真相。解决训练时使用类别权重或 Focal Loss评估时不要只报 accuracy而是看 macro-F1 和每个关系类别的 recall。如果用第三方的评估脚本也要确认它默认用的是 micro 还是 macro两个口径在类别不平衡时差异很大写论文或者汇报医生时这俩数字不能混着说。5.3 嵌套实体和跨句关系扁平标注很难一次说清现象模型把“非小细胞肺癌”识别成了“肺癌”导致和它关联的“易瑞沙”关系判断错误另一个常见问题是药物在上一句出现副作用在下一句出现关系分类器把它们当成了两个独立片段。原因医学实体经常嵌套组合扁平 BIO 标注只能表达一层实体边界而 transformer 模型默认截断到 512 token跨句关系很容易被截掉后半句。解决处理嵌套实体时改用基于跨度的模型先枚举所有可能的文本跨度做实体分类再两两组合做关系分类这是目前医学实体关系抽取项目里落地最稳的路线。跨句关系则用实体中心上下文窗口替代从头截断把一个句子的左右邻居都放进输入里保证候选实体对总能同时出现在有效上下文内。5.4 缩写与同义改写过多不同来源的实体文本对不上现象训练集和验证集来自不同医院或不同年份的病历同一个药物在训练集叫“阿司匹林”在验证集叫“拜阿司匹灵”或“ASPIRIN”实体识别准确率骤降关系抽取也跟着崩。原因医学命名没有统一规范商品名、通用名、缩写、大小写混用单纯靠模型记忆映射是记不住的尤其是小规模语料里根本没有足够样本覆盖这些变体。解决把这部分问题交给术语归一化而不是模型。常见做法是借助 UMLS 或 SNOMED CT 的字典把文本中的实体提及映射到统一概念 ID再拿概念 ID 作为实体表示去训练模型。这个预处理步骤看起来费时但它是解决医学文本稀疏性的最有效手段比你调任何深度学习模型参数都管用。5.5 评估指标和标注口径不统一模型输出对不上临床需求现象模型在公开数据集上 F1 达到 92%拿到真实病历上一测医生觉得输出结果没法直接用因为模型把“可能”导致和“明确”导致混在一起。原因公开数据集的关系标签是论文作者按研究目标定义的和临床决策需要的关系粒度往往不一致。深度学习模型学的是标签分布标签体系里没有“可能性”维度模型当然不会输出这个信息。解决项目启动前先和临床方确认关系类别的定义比如“导致”是否需要区分“确定导致”“可能导致”“疑似导致”然后在标注阶段就拆成三个标签而不是让模型事后去学。这个坑属于需求层面的问题模型再强也救不回来。真正做模型部署时还需要在输出层加一个阈值校准把低置信度的关系识别过滤掉宁可漏报也不给医生错误引导。6. 验证和进阶除了 F1 还要看什么6.1 错误分析把混淆矩阵拆到关系类别层面关系分类的验证不能只看一个 F1 数字。我会把验证集每个样本的预测结果保存下来按关系类别拆开算 precision 和 recall再画出混淆矩阵。最容易发现的问题是“导致”和“治疗”这两类经常互相混淆因为“用阿司匹林治疗头痛”和“阿司匹林导致头痛”在句式上非常接近模型往往只凭实体类型和共现频率做判断。看到混淆集中在某几类之间后我会针对性增强样本把这两类的对比负样本成倍加入训练集而不是盲目调整模型结构。6.2 进阶技巧实体类型标记一个高性价比的改进是把实体类型信息放进标记里比如用[E1_Drug] Aspirin [/E1_Drug]替代[E1] Aspirin [/E1]尾实体同理标记为[E2_Disease]。这让模型在关系分类时直接看到候选实体的类型组合比如“药物—疾病”组合更容易学到“治疗”或“导致”而“药物—药物”组合更容易学到“相互作用”。类型标记的改动很小只需要在构造输入时把实体类型拼进特殊 token但对医学文本这种实体类型本身就携带语义的场景效果提升非常明显。我现在养成的一个习惯是每次训练完先打印一批错误样本按关系类型分组翻一遍再决定改数据、改标记还是改损失函数。关系抽取是一个错误传导链路很长的任务黑匣子式地反复调学习率很难解决问题。这个习惯帮我省下了很多无效实验的时间希望帮到你。本文还有配套的精品资源点击获取