资讯详情

资讯详情

深度学习驱动的医学实体关系抽取实战指南

简介面对医学文献与诊疗记录中海量的非结构化文本自动识别病理实体并抽取其关联关系是构建医学知识图谱、支撑智慧医疗的关键一步。围绕这一目标一套基于深度学习的医学实体关系抽取代码包应运而生适合自然语言处理研究者、医学信息学学生及知识图谱工程师学习参考。包内共包含113个文件压缩包整体约157KB其中以77个Python脚本为核心覆盖模型训练、序列评估、生成评测等环节另有18个备份文件、5个YAML配置、7个文本说明及2个Markdown文档便于理解运行流程并复现实验。已有52人学习下载。借助脚本与配置可完整了解从医学文本预处理、实体标注到关系分类的深度学习实现路径获得数据准备、模型推理与指标评估的一站式代码参照为后续构建知识图谱或开展临床文本挖掘提供可扩展的工程基础。1. 基于深度学习的医学实体关系抽取为什么临床文本比想象中难搞从一份出院小结里抽取“药物导致不良反应”这类三元组看起来像是标准NLP流程先找出实体再判断关系。但真正把深度学习模型放到医学文本上时你会发现通用工具在“阿司匹林”和“胃出血”这种带专业后缀的实体上频频翻车。医学实体关系抽取并不是把通用关系抽取换一个数据集跑一遍它有自己特有的约束实体往往很长、会嵌套、缩写多关系标签高度偏斜而错误预测在临床场景里的代价又极高。我会按一线落地顺序把任务定义、数据准备、模型选型、参数调优和踩坑记录拆开讲适合正在做医疗信息抽取、电子病历结构化或临床文本分析的工程师。读完你会知道这条路值不值得走以及每一步背后真正要解决的瓶颈是什么而不是拿到一个模型就盲目训练。2. 关系定义与数据准备模型是从标注开始的2.1 关系类型与标注粒度怎么定医学文本中的关系抽取首先要定义清楚三元组的边界。常见做法是把任务限定在句子级别抽取“头实体-关系-尾实体”三元组比如“阿司匹林-导致-胃出血”。我一般会先用一个小的标注规范来确定实体类型和关系类型。医学场景里最常遇到的是药物、疾病、症状、检查、操作这几类实体关系则围绕“治疗”“加重”“诊断”“并发症”等展开。关系类型不是越多越好控制在10个以内有利于标注一致性和模型收敛。如果一开始就定义几十种关系标注者会频繁犹豫最后标注质量反而不如精简单关系。标注方案上我推荐采用字符偏移加实体ID的JSON格式。用字符偏移的核心原因是后续解码时可以直接对齐原始文本不需要额外修正分词误差。下面是一个文本抽取项目里实际使用过的标注格式{ text: 患者服用阿司匹林后出现胃出血停用后症状缓解。, entities: [ {id: E1, type: Drug, start: 2, end: 6, name: 阿司匹林}, {id: E2, type: Disease, start: 10, end: 14, name: 胃出血} ], relations: [ {head: E1, tail: E2, type: causes} ] }这段JSON最关键的地方是start和end必须基于原始文本的Unicode字符偏移而不是字节偏移。中文场景下字节偏移会带来大量索引错位尤其是当文本里混入全角标点时。实体name字段可以用于后处理但训练时通常不使用因为模型预测的是偏移量而非名字。关系类型中的causes可以替换成你项目里的“副作用”“禁忌”等标签但建议统一大小写否则预处理阶段要多做一层映射。标注时还需要规定实体边界像“胃出血”这种疾病名不要把“出现”这样的动词纳入实体范围。2.2 预处理把原始标注转成模型输入拿到标注后的JSON我们通常要把它转成模型能读的序列。如果采用序列标注思路做NER需要把字符偏移转成BIO标签。这里最容易出错的点是实体重叠和嵌套后面避坑章节会专门讲。先看一个标准的转换函数def encode_entities(text, entities): labels [O] * len(text) for ent in entities: start, end ent[start], ent[end] labels[start] B- ent[type] for i in range(start 1, end): labels[i] I- ent[type] return labels这个函数假设实体没有重叠如果同一个字符属于两个实体后写的实体就会覆盖前一个。因此在标注规范里需要约定同一个字符最多只属于一个实体或者某些类型优先级更高。例如“药物-不良反应”里的症状实体优先于普通疾病实体。函数中labels的长度必须和原始文本一致预处理时如果做过全角半角转换或大小写归一化必须同步更新实体偏移量否则会出现错位。我一般在转换前先跑一遍文本清洗把肉眼不可见的控制字符、零宽空格去掉再重新计算偏移量。这一步简单但实际项目中过拟合和F1虚高的原因往往就藏在这种索引错位里。为了避免清洗时索引错位建议在转换前统一做一次文本规范化。我会用类似下面的预处理import re def normalize_text(text): text re.sub(r[\x00-\x08\x0b-\x0c\x0e-\x1f], , text) text text.replace(\u3000, ) text text.lower() return text注意lower()操作会改变英文药物名的大小写中文不受影响。如果你的实体包含英文需要在转换后重新映射偏移量。更稳妥的做法是先对原始文本做偏移位置的记录再规范化最后用记录调整偏移。这个环节不能省略任何文本变换都会破坏start/end必须写一个偏移修正函数而不是直接调用normalize_text后沿用旧偏移。2.3 数据增强与类别不均衡增强也要守住实体边界医学关系抽取任务普遍存在长尾关系比如“禁忌”关系远少于“治疗”关系如果不处理模型学到的只是多数类。最直接的办法是给损失函数加权。我用过比较有效的是Focal Loss它对难分类样本更关注。下面是一个PyTorch里的实现示例class FocalLoss(nn.Module): def __init__(self, alpha0.25, gamma2.0): super().__init__() self.alpha alpha self.gamma gamma def forward(self, logits, targets): ce_loss F.cross_entropy(logits, targets, reductionnone) pt torch.exp(-ce_loss) focal_loss self.alpha * (1 - pt) ** self.gamma * ce_loss return focal_loss.mean()这里的alpha控制正负样本权重gamma控制困难样本的聚焦程度。在医学数据更偏斜时建议把alpha调大到0.4到0.5。如果发现多数类分数过高就调高alpha如果模型对少数类毫无识别能力优先调gamma而不是盲目增加数据。除了损失函数数据增强也能缓解长尾问题。医学文本增强不比通用领域同义词替换必须使用专业术语词典比如“阿司匹林”换成“乙酰水杨酸”。替换后所有实体偏移量都要重新计算这个动作不能省。类别不均衡还有一种常见修复是过采样。我会对少数关系样本做简单复制但复制时注意不要让同一个实体对在训练集里重复太多次否则模型会对特定实体组合过拟合。过采样后配合Focal Loss效果更好但要监控开发集上少数类的精确率和召回率。如果项目对临床决策要求高宁可少召回也不能错报此时调整alpha和gamma的思路要反过来。在一个模拟项目X里我通过这种方式把某类罕见关系的F1从0.12提到了0.43因此这条路径是值得尝试的。数据增强还可以用实体替换的方式做。下面这段函数演示了如何安全地替换实体并返回新的文本和实体def replace_entity(text, entities, entity_id, new_name, synonym_dict): ent entities[entity_id] if ent[name] not in synonym_dict: return text, entities replacement synonym_dict[ent[name]] gap len(replacement) - (ent[end] - ent[start]) new_text text[:ent[start]] replacement text[ent[end]:] for e in entities: if e[start] ent[end]: e[start] gap e[end] gap elif e[start] ent[start] and e[end] ent[end]: pass new_entities entities.copy() new_entities[entity_id][name] replacement new_entities[entity_id][end] ent[start] len(replacement) return new_text, new_entities这段代码里gap表示替换前后长度差所有在替换实体之后的实体偏移都要加上这个差值。嵌套实体需要额外处理我在这里只做了最外层的替换。实际使用中替换词典可以从公开的医学同义词表中整理每替换一次都要随机检查确认实体的类型标签没有改变。增强不是越多越好一般控制在小数类样本的两倍以内否则模型会记忆增强模式而不是医学语义。3. 模型选型流水线和联合抽取哪个更适合医学文本3.1 流水线先抽实体再判关系流水线方法是我在小规模数据上最常用的起步方案。它把任务拆成两个模型第一个做NER识别文本中的实体第二个对候选实体对做关系分类。优点是每一步都能单独调试。比如实体边界错的样本可以先优化NER关系类别分不准再单独调关系分类器。训练资源也省NER模型跑完关系分类只用取出实体上下文进行预测。关系分类器的输入设计很关键。常见做法是把头实体文本、尾实体文本和原句拼到BERT里def build_relation_input(text, head_span, tail_span, tokenizer, max_len): head text[head_span[0]:head_span[1]] tail text[tail_span[0]:tail_span[1]] encoded tokenizer( head, tail, text, max_lengthmax_len, truncationTrue, paddingmax_length, return_tensorspt ) return encoded这里tokenizer是transformers库的AutoTokenizer。head和tail会作为两个文本片段再和整个原句拼接。注意到我们传入了head、tail、text三个参数tokenizer会自动用[SEP]分隔。max_len要根据句子长度设置一般256足够。如果句子特别长要优先保留真实文本而截断head或tail我建议对text只截取包含两个实体的窗口而不是从开头截断。否则实体对距离太远时关系分类器根本看不到尾实体。流水线方法还有一个容易忽略的问题候选实体对的生成。假设一句话里有4个实体那么最多有12个有向实体对。如果全部送入关系分类器很多无效对会带来大量误报。我一般会先按实体类型过滤比如只有药物和疾病之间存在“导致”关系那么无关类型对直接丢弃。然后再按距离过滤保留实体间token距离不超过30的候选对。距离过大时模型很难学到上下文模式反而会把不相关的实体拉上关系。流水线最大的缺陷是错误传导。NER阶段漏抽了一个实体关系分类器就永远不会看到这个候选对。还有一种情况是NER抽出了正确的实体但关系分类阶段因为候选实体对太多而产生误报。所以流水线适合实体类型少、关系类型少、文本规范的任务。遇到实体重叠或者关系依赖全局语义时就得考虑联合抽取。3.2 联合抽取一次预测三元组联合抽取在医学文本里越来越常用因为很多关系需要同时依赖两个实体的边界和上下文。一种稳健的实现是基于指针网络的方案。模型先用BERT编码得到每个token的向量然后对每个token分别预测是否为头实体开头或结尾以及是否为尾实体开头或结尾。关系类型被融入到尾实体的预测里。比如有5种关系就对每个token输出5个“尾实体起始”的logits。训练阶段我们可以定义一个简单网络class JointExtractor(nn.Module): def __init__(self, bert, hidden_size, num_relations): super().__init__() self.bert bert self.head_start nn.Linear(hidden_size, 1) self.head_end nn.Linear(hidden_size, 1) self.tail_start nn.Linear(hidden_size, num_relations) self.tail_end nn.Linear(hidden_size, num_relations) def forward(self, input_ids, attention_mask): outputs self.bert(input_ids, attention_maskattention_mask) seq_out outputs.last_hidden_state hs self.head_start(seq_out).squeeze(-1) he self.head_end(seq_out).squeeze(-1) ts self.tail_start(seq_out).permute(0, 2, 1) te self.tail_end(seq_out).permute(0, 2, 1) return hs, he, ts, te这个网络里head_start和head_end对每个token输出一个起始或结束概率。tail_start的shape是(batch, num_relations, seq_len)这样每个关系类型都有独立的尾实体起始概率。训练时对这四个输出分别计算BCE损失再加权平均。推理时先找出所有头实体起始位置再为每个头实体配对关系类型的尾实体最后通过边界约束构成三元组。推理时的解码一般分三步第一步根据头实体起始和结束概率筛出头实体第二步根据尾实体起始和结束概率筛选出所有可能的关系尾实体对第三步做边界匹配要求头实体的结束位置在尾实体开始之前并且两个实体不能完全重叠。下面是一段简化后的解码核心逻辑def decode_triples(hs, he, ts, te, threshold0.5): heads extract_spans(hs, he, threshold) triples [] for h_start, h_end in heads: for rid in range(ts.shape[0]): tails extract_spans(ts[rid], te[rid], threshold) for t_start, t_end in tails: if h_end t_start: triples.append((h_start, h_end, rid, t_start, t_end)) return triples这里extract_spans负责把连续概率大于阈值的token聚合成span。注意要求h_end t_start这是为了确保头实体和尾实体不是同一个片段也避免产生交叉实体对。联合抽取对实体重叠的支持比流水线好因为它不要求实体先完整抽出来再分类。但缺点是解码逻辑复杂我在调联抽模型时经常遇到“头实体对了但尾实体错”的情况原因是关系类型对应的尾实体概率矩阵数据稀疏。一个缓解办法是在解码阶段加入span长度限制比如头实体和尾实体的长度都必须在2到20个字符之间超过就丢弃这样能有效降低误报。3.3 预训练模型选型与领域微调医学文本与通用文本差异很大直接用通用中文BERT会出现很多OOV。比如“间质性肺病”这类词通用BERT词表里可能没有模型只能拆成子词导致实体边界识别不稳定。所以我会优先使用医学领域语料预训练过的BERT。这类模型通常在医学NER任务上能高出几个点但具体高多少取决于数据规模。我们项目里对比过通用BERT的关系抽取F1大约是0.74医学领域BERT能到0.81而医疗词典增强版本能到0.84。差异主要来自词表覆盖和预训练阶段对医学句式的理解。下表是一个典型的选型对比参数仅供参考。模型方向词表覆盖实体边界关系抽取F1参考训练成本通用中文BERT中一般0.72-0.76较低医学领域BERT高更好0.78-0.82中等医学BERT词典特征高最好0.82-0.85较高如果资源有限也可以先用通用BERT然后在下游任务里加入一个静态词向量拼接或者在解码层增加一个术语特征。但最省事的方法仍是换医学预训练模型。微调时要注意学习率。通用BERT常用2e-5但医学任务数据量小我一般用1e-5到2e-5并开启warmup。另一个细节是冻结部分底层参数只微调高层和分类头这在小数据集上能防止灾难性遗忘。如果发现验证F1波动大可以尝试冻结前10层BERT只微调后2层和分类头。这样训练速度更快但收敛不一定更好需要实验确认。4. 训练和调优医学NLP里必须盯紧的5个参数4.1 序列长度别让实体在截断处消失医学病历里的句子经常超过256个字符。BERT默认最大序列长度是512但实际显存不允许。如果直接把超长句子截断到256位于句尾的实体和关系全部丢失模型等于在学习残缺样本。一个常见做法是使用滑动窗口把长文本切成多个有重叠的窗口每个窗口单独预测后处理时把重叠区域的实体合并。窗口长度我一般设为256重叠64。这样既能避免OOM又能保住跨窗口的实体。但要注意当实体跨越窗口边界时会被切成两个片段合并时需要一个“实体打分”规则选择分数更高的那一段作为实体span。这个规则可以在后处理函数里实现。4.2 学习率和warmup医学关系抽取数据集往往只有几万条甚至几千条学习率过大会导致微调不稳定。我的经验是BERT层使用2e-5以下分类头可以稍微大一点比如1e-4。下面是一个典型配置from transformers import get_linear_schedule_with_warmup total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps )这里warmup_steps设为总步数的10%意味着前10%的步数里学习率从0线性升到峰值然后线性衰减。在医学NLP里warmup比例太低容易让模型前期震荡太高会拖慢收敛。如果数据量特别小比如只有2000条warmup可以提升到20%。还要注意optimizer用AdamWweight_decay一般设0.01。我的习惯是先跑一个短实验观察前100步的loss曲线如果loss剧烈波动就把warmup调大或学习率减半。4.3 损失函数和标签平滑关系分类通常用交叉熵但在医学里标签噪声不可避免。多标签关系抽取任务里同一个实体对可能有多个关系标签比如一种药物既能治疗疾病又能引起不良反应。如果用softmax交叉熵模型只能选择一个就会导致训练矛盾。这时候要用多标签BCE或者把每个关系分类为独立二分类。我还用过一个技巧是标签平滑它把真实标签的1替换成0.9把0替换成0.05能够抑制模型过度自信减轻噪声影响。在PyTorch里可以手动实现def smooth_one_hot(targets, n_classes, smoothing0.1): return targets * (1 - smoothing) smoothing / n_classes标签平滑值一般设置在0.05到0.1。如果标注质量很高可以设小一点如果是从远程监督拿来的弱标签建议设到0.15甚至0.2。但不要设太高否则模型欠拟合连高频关系都学不稳定。4.4 验证集划分与指标医学数据有一个特殊问题同一个患者的多次就诊记录高度相关。如果随机划分训练集和验证集同一患者的记录可能同时出现在两边模型会在验证时“记住”患者级别的模式导致指标虚高。我一般按患者ID、病历ID或入院时间分组划分。如果实在没有ID就按文档去重后再划分。评估指标上实体识别常用token级F1和实体级F1关系抽取则看三元组级F1即头尾边界和关系类型全部正确才算一个正例。此外还要监控每个实体类型和关系的单独F1因为总F1可能被多数类掩盖。特别是“并发症”这类长尾关系单独F1容易很低如果没有监控模型上线后才发现问题就晚了。4.5 早停与模型保存模型保存不应该只看总F1。我习惯每跑完一个epoch把实体级F1、关系级F1、三元组严格F1都记录下来保存最好的那个模型。早停的阈值一般要看验证集的噪声程度。医学标注成本高验证集可能也只有几百条F1波动大。这时候我提前停的耐心值会设得大一些比如连续5个epoch没有改善才停。保存模型时最好同时保存tokenizer和分类头的配置避免推理时加载错。还需要注意在加载最好的模型后要用训练集重新评估一遍确认模型没在保存的epoch发生过退化。这个步骤看起来多余但能及早发现数据处理里的bug。早停后还要记录当时的优化器step防止后续恢复训练时scheduler状态不对。5. 医学实体关系抽取的避坑指南5个让F1虚高的隐藏因素这5条是我在多个医学文本抽取项目里反复遇到的共性问题每一条都直接导致过F1虚高或模型上线后效果大幅下跌按“现象-原因-解决”的方式记录下来方便对照自己的实验流程。5.1 实体重叠导致标注冲突现象同一个文本里“间质性肺炎”既被标成“疾病”又被标成“症状”。模型训练时看到相同实体边界对应两种标签会不断震荡最终验证F1卡在0.7上不去。原因标注规范没有定义嵌套实体或复合实体的优先级。解决在标注规范里明确规定当一个实体可以属于多个类型时按医学逻辑只取最具体的类型。比如“间质性肺炎”在这种情况下优先标为“疾病”如果它描述的是一种临床表现但本身是疾病就归入“疾病”。代码层面也可以在转换BIO时对重叠标签做冲突剪枝保留第一个出现的类型并记录日志提醒标注团队复核。这个坑不解决后面所有指标都是无意义的。5.2 远程监督噪声污染训练集现象用远程监督方法自动生成训练数据模型训练时loss下降很快但在人工标注的测试集上F1只有0.5。原因远程监督基于外部知识库与文本共现做对齐会出现大量“假阳性”。比如“患者否认服用阿司匹林”文本里有“阿司匹林”知识库里也有“阿司匹林导致胃出血”于是自动生成了一个“导致”关系但医生实际说的是“否认”。解决远程监督数据不能直接用于训练必须先用置信度分数过滤再人工抽检。我一般会让模型在远程监督数据上先跑一个弱标注然后按预测概率排序拿前10%最高置信度样本给医生复核。如果复核准确率低于90%就降低置信度阈值。此外在训练loss里给远程监督样本降低权重也能缓解噪声。记住远程监督可以用来补充数据但不能代替高质量标注。5.3 缩略词在不同语境下变成不同实体现象同一个缩略词“CT”在出院小结里可能是“计算机断层扫描”在化疗方案里可能是“化疗时间”模型会把它错误归一化。原因缩略词缺乏上下文消歧模型看到字形一致就归为同一实体。解决构建一个缩略词词典把常见冲突词列出输入时给BERT增加一个“上下文类型标记”例如在文本前加上“检查科室”或“治疗计划”等段落属性。这属于轻量级的特征注入。如果标注数据不够可以在预处理阶段用正则把缩略词替换成全称并记录映射让模型学习全称语义再在后处理阶段映射回缩略词。需要注意的是替换全称会改变字符偏移要在替换前保存原偏移处理完再修正。5.4 同一患者的记录泄漏到了训练集现象随机划分数据后验证集F1高达0.9但上线到新患者时只有0.6。原因同一患者的病史记录具有强相关性模型学到的是患者特征而不是通用的医学术语关系。解决按患者ID对文本进行分组划分。分组方式不止一种我习惯先把所有文本按患者ID聚合再用group k-fold做分层划分。划分时还要保证同一文档的所有片段不会被拆到不同集合。这个操作看着简单但在电子病历导出时经常出现ID缺失需要先用哈希或去重逻辑构建一个匿名患者ID。如果项目流程不允许保留ID至少要做到文档级去重。这里没有捷径数据划分不严谨所有实验对比都不可信。5.5 模型靠触发词偷懒现象把句子中的“导致”“引发”“治疗后”等触发词删除后模型关系预测准确率从0.8降到0.5。原因医学文本中关系表达高度依赖少量触发词模型学会了“看到导致就预测causes”这种捷径并没有真正理解实体间的语义关系。解决一是做触发词干扰测试在开发集上随机遮蔽触发词观察F1变化二是用对抗训练或数据增广降低对触发词的依赖。我在一个模拟项目X里试过在实体位置埋dropout输入时随机将某些token替换为[MASK]强迫模型从上下文推断关系这个技巧能提升对未见表达方式的泛化能力。还有一种办法是加入句法负采样抽取不包含触发词的阴性样本让模型学习在无触发词时输出“无关”。这类问题很容易被忽略但它决定模型换一个数据源后还能不能站得住。6. 把模型推向临床的3个进阶手段6.1 主动学习把人工标注用在刀刃上医学标注成本高主动学习能显著降低样本量需求。做法是用模型预测一批未标注数据把不确定性最高的样本比如预测概率接近0.5的关系对挑出来给医生标注。每次标注后重新训练迭代几轮后用很少的样本就能达到接近全量标注的效果。我会在每次迭代后对比验证集F1如果F1提升小于0.5个点就停止。6.2 规则后处理让模型学会对未知情形说“不知道”深度学习模型对关系判断没有置信度边界部署时往往会对低置信度输出也给出一个标签。我的做法是在模型输出后面接一个规则层给每个关系类型设一个置信度阈值低于阈值就输出“不确定”而不是硬选一个关系。对于临床场景“不确定”比“错误”更容易接受。阈值可以通过验证集上的精确率-召回率曲线来挑选一般选精确率0.9对应的置信度阈值。6.3 蒸馏与部署用更小的模型跑出接近的效果医学项目常需要把模型嵌入到院内系统资源有限。我会先用一个大模型微调再把它蒸馏到一个轻量模型上。蒸馏时不只是让轻量模型学大模型的输出标签还要使用软标签也就是大模型输出的概率分布。这样小模型能学到更多边界信息。一个小技巧是同时让轻量模型预测大模型中间层的表示效果更稳定。部署时再配合ONNX或量化单条文本的推理时延能降到可接受范围。这几招单独用都有效合在一起能让模型从“实验室指标好看”走向“临床可接受”。最后说一句我自己的习惯模型上线前一定要拿一批真实但疏于整理的原始文本做压力测试别用清洗好的数据。很多时候F1虚高就是因为在数据里偷了懒。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →