ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

医学实体关系抽取实战:从数据标注到BioBERT部署与避坑

医学实体关系抽取实战:从数据标注到BioBERT部署与避坑 简介一份基于深度学习的医学实体关系抽取研究代码包面向医学文本挖掘、知识图谱构建方向的研究者与学习者重点解决医学文本中实体识别与语义关联提取问题。资料围绕“实体识别—关系抽取—知识网络构建”主线覆盖规则匹配、统计模型与循环神经网络、注意力机制等深度方法的实现可用于复现和进一步调优。压缩包共含113个文件、约157KB以Python脚本为主77个涵盖模型定义、训练、评估与生成辅以YAML配置、TXT/MD说明文档及备份文件方便按模块查阅。已有52人学习。通过源码和说明读者可理解医学概念归一化、关系标准化等关键环节也能基于现有代码快速搭建实验流程为构建疾病、症状、药物等实体间的知识关联提供可直接参考的工程实现。1. 医学实体关系抽取为什么通用NER在病历上不顶用刚接手一项电子病历结构化任务时我直接拿来一款开源NER工具把实体识别跑通就去抽关系。结果第二周做错误分析就发现用药-疾病的关系对一半是错的。后来才明白医学文本里的否定、时态和嵌套表达会实实在在干扰实体边界和关系判断。比如患者否认高血压史里的高血压到底算不算实体通用关系抽取模型根本分不清。这背后就是深度学习在医学实体关系抽取中的价值把非结构化的病历文本转成可计算的实体关系三元组。这个方向适合正在做医学NLP、临床知识图谱、循证医学检索的从业者也适合准备入门医学数据挖掘的研究生。下面我从数据标注、模型选型、避坑到工程部署逐一展开让你能照着复现并规避我踩过的坑。2. 先把数据问题谈清楚医学文本标注与关系类型设计2.1 医学实体关系抽取的任务定义与常见关系类型医学实体关系抽取的输入是一条临床句子输出是若干个实体对关系类别的三元组。举个例子患者因高血压服用硝苯地平出现头痛。标准输出应该包含硝苯地平→高血压治疗、硝苯地平→头痛副作用。注意这里一个药物可以同时连接多个实体而且实体类型可以是疾病、症状、检查、药物关系语义也随之变化。医学文本和新闻语料最大的不同是它的语义常常被否定词、时态和情态动词干扰。未见明显异常如果被模型当成正常陈述就会抽出大量不存在的实体关系。另一个典型问题是实体嵌套非小细胞肺癌是一个实体但它内部包含了小细胞肺癌和肺癌两个更短的实体。这种嵌套结构在通用数据集里不多在医学语料里非常常见。所以关系类型设计一般从实际临床问题出发而不是求大求全。我通常会把关系控制在10到15个比如治疗(treats)、预防(prevents)、副作用(side_effect)、诱发(causes)、诊断(diagnoses)、禁忌(contraindicates)。哪些关系该建取决于下游是药品说明书核对还是临床预警。举个例子如果要做药物不良反应监测那么副作用和禁忌就是核心关系其他可以先舍弃如果要做诊疗路径推荐治疗和诊断优先级更高。关系类别超过30个标注难度和模型复杂度都会指数上涨效果还未必好。这里有一个我常用的关系类型参考表来自对病历和文献的小型统计关系类型典型实体对示例治疗药物-疾病阿司匹林 → 冠心病副作用药物-症状阿司匹林 → 胃出血诱发疾病-疾病糖尿病 → 肾病检查检查-疾病血糖检测 → 糖尿病禁忌药物-疾病布洛芬 → 胃溃疡这个表只是起点不是标准。实际项目中要按语料里的频次分布做裁剪。我见过有人花了很长时间把药物-药物相互作用标得特别细结果模型训练完发现真实病历里根本没有多少这样的实体对数据不平衡直接把F1拖到0.3。所以先拿100条样本跑一个分布统计再定关系类型是最省时间的做法。2.2 标注数据的来源与最小标注规范在真实临床环境里能拿到的多是半结构化的电子病历要么是脱敏后的出院小结要么是门诊主诉。标注成本极高因为需要医学知识。常见做法是先用公开数据集比如i2b2关系抽取任务、BioCreative的蛋白交互抽取训练一个初版模型再用这个模型去预标注自己的病历最后由医学背景人员在标注平台上修正。标注工具我用过brat和Label Studiobrat很轻但多用户协作要自己配权限Label Studio界面直观还支持关系类型我用得更多。最小标注规范要明确实体边界、实体类型、关系方向三条。实体边界规范必须是完整医学术语不能把修饰词塞进实体比如重度高血压只标高血压不标重度高血压实体类型通常分为药物、疾病、症状、检查、手术等关系方向规定为主动者→承受者例如药物→疾病不能反向。这些规则需要在标注指南里写清楚最好配一页正反例。从Label Studio导出的JSON格式包含annotations列表每个标注里有result字段这个字段混合了实体label和关系relation而且互相通过id引用。下面这个脚本把导出数据转换成模型能直接读的句子级样本。import json def convert_labelstudio_to_samples(raw_json): samples [] for doc in raw_json: text doc[data][text] result doc[annotations][0][result] # 第一遍收集实体 id - span/type ent_map {} for r in result: if r[type] labels: val r[value] ent_map[r[id]] { start: val[start], end: val[end], type: val[labels][0], } # 第二遍解析关系关联实体id rels [] for r in result: if r[type] relation: val r[value] from_ent ent_map.get(val[from]) to_ent ent_map.get(val[to]) if from_ent and to_ent: rels.append( { head: from_ent, tail: to_ent, type: val[relation], } ) # 实际项目中要按句切分这里保留完整文本 samples.append({text: text, entities: list(ent_map.values()), relations: rels}) return samples这段代码有两个关键点。第一result里实体和关系是混在一起的必须先按type过滤出实体再解析关系否则关系里的from、to引用找不到。第二真实电子病历往往是一个很长的文档关系可能是跨句的。但关系抽取模型大多按句处理所以落地时还要把文档切分为句子并把实体span映射到每一句上这一步不能省。我一般先用spaCy或自写的标点切分器做句子切分再逐句执行上面的转换。有人会问那跨句关系怎么办这是一个真实痛点。如果两个实体的关系需要跨越多句话才能判断比如前面说患者既往有糖尿病后面说因此长期服用二甲双胍那么句子级模型就只能看到糖尿病却看不到二甲双胍。常见做法是在预处理时用一个上下文窗口把前后若干个句子拼起来作为输入。这会增加计算量但能抓回不少长距离关系。2.3 数据增强与半监督让有限病历发挥更大价值标注数据稀缺是医学关系抽取最大的瓶颈。我常用的一个办法是回译增强但医学专业术语回译风险很大心肌梗死翻成英文再翻回来很可能变成心脏病发作不但实体变了关系语义也可能跟着变。更稳妥的是基于实体替换的增强在不改变关系语义的前提下用同义实体替换。比如高血压替换成HTN糖尿病替换成T2DM。下面是一个实体级同义词替换的实现替换后同步修正实体坐标。import random SYNONYMS { 高血压: [HTN, hypertension], 糖尿病: [T2DM, diabetes mellitus], } def entity_substitute(text, entities, p0.3): 按概率随机替换实体为同义词并保持实体位置正确。 if random.random() p: return text, entities # 按实体长度降序替换避免外层实体被替换后影响内层 new_entities [] for ent in sorted(entities, keylambda e: (e[end] - e[start]), reverseTrue): std_name text[ent[start]:ent[end]] if std_name in SYNONYMS and SYNONYMS[std_name]: repl random.choice(SYNONYMS[std_name]) offset len(repl) - (ent[end] - ent[start]) text text[: ent[start]] repl text[ent[end] :] for e in new_entities: if e[start] ent[end]: e[start] offset e[end] offset new_entities.append( {start: ent[start], end: ent[start] len(repl), type: ent[type]} ) else: new_entities.append(ent) return text, new_entities这段代码里最关键的是按实体长度降序处理并先更新文本再平移后续实体的位置。如果顺序反了外层实体替换后内层实体坐标全部错位模型会拿错误的边界去学习损失直接乱跳。我写过第一版就是没按长度排序数据增强后F1反而掉了5个点查了半天才发现是坐标错乱。另外替换时不能把原实体的类型改掉比如HTN仍然是疾病类型type字段原样保留。半监督方面另一个常见做法是用当前模型对无标注病历做推理把置信度高的伪标签加入训练集。但医学模型对无关系类别往往过度自信所以伪标签不能只看关系置信度还要看实体对本身的置信度。我一般设定关系置信度大于0.9且实体对置信度大于0.8的样本才加入而且每个批次加入比例不超过20%。这样可以稳定提升数据规模又不会让错误标签污染模型。3. 模型选型从CNN-BiLSTM到BERT在医学关系抽取上的取舍3.1 为什么通用关系抽取模型在医学文本上表现不稳在深度学习还没有统治NLP的年代医学关系抽取常用基于特征和核函数的方法比如用SVM对手工构造的上下文、词性、句法依赖路径做分类。这些方法能利用句法信息但医学文本的依赖解析准确率本身就比新闻语料低一大截长句子更是错误百出。后来CNN接softmax成为主流把窗口内的词向量拼起来做分类。CNN的感受野有限对于患者长期服用阿司匹林虽然胃部不适但未停药这种跨越多个从句的关系窗口太小就抓不到关键联系。BiLSTM能建模长距离依赖但训练慢而且医学词向量如果只在通用语料上预训练心衰和心力衰竭仍然会被视为不同的token。BERT的Self-Attention机制可以建模任意跨度依赖通用领域的BERT已经大幅刷新技术指标。但直接拿BERT-base跑医学任务效果还是不够。原因很直接医学词汇分布与通用语料差异太大BERT用子词切分经常把一个完整的医学术语切成多个碎片语义信息被稀释。所以出现了BioBERT、ClinicalBERT这类在医学语料上继续预训练的模型。它们在词表和语义表征上更靠近医学文本迁移到关系抽取任务上效果提升明显。不过BioBERT也不是万能。如果目标是电子病历那么用PubMed摘要训练的BioBERT仍然和病历语料有差距ClinicalBERT更贴近一些。但如果手头没有临床语料访问权限BioBERT已经是预训练权重里相当好的起点。很多从业经验表明先加载BioBERT再在关系抽取数据上微调比从头训练或只用BERT-base有5到8个F1的提升。3.2 基于预训练模型的最小实现BioBERT 关系分类头现在做医学关系抽取常见做法是加载HuggingFace的transformers库把BioBERT当作编码器在顶部加一个关系分类头。对于句子级关系抽取最简单的输入构造是在两个实体前后插入特殊标记比如e1硝苯地平/e1治疗e2高血压/e2。这样模型可以明确感知到两个实体的位置[CLS]向量经过分类头输出关系类别。下面是一个最小实现用AutoModel加载dmis-lab/biobert-base-cased-v1.1取[CLS]向量做关系分类。这里用到的模型名称是公开HuggingFace Hub上的BioBERT base如果你本地没有会自动下载。import torch import torch.nn as nn from transformers import AutoModel, AutoTokenizer class BioBERTRelation(nn.Module): def __init__(self, num_labels, model_namedmis-lab/biobert-base-cased-v1.1): super().__init__() self.bert AutoModel.from_pretrained(model_name) self.dropout nn.Dropout(0.1) self.classifier nn.Linear(self.bert.config.hidden_size, num_labels) def forward(self, input_ids, attention_mask, token_type_idsNone): outputs self.bert( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids, ) cls_vec outputs.last_hidden_state[:, 0, :] logits self.classifier(self.dropout(cls_vec)) return logits网络结构很简洁。AutoModel.from_pretrained加载BioBERT权重classifier把768维的[CLS]向量映射到num_labels个类别。forward时返回的logits是未过softmax的原始分数训练时用CrossEntropyLoss就可以。这里有个容易被新手忽略的点如果不小心把整个句子编码成token_type_ids为0的单句其实是对的但如果对两个实体分别加segment id需要与预训练时的一致。多数情况下直接用None就行。3.3 参数详解与训练配置batch size、学习率与序列长度医学关系抽取的输入通常不超过512个token因为BERT的限制是512。但我一般把最大长度设为256已经能覆盖90%以上的句子还能省显存。如果句子太长就用滑动窗口切分这个在第5章展开。batch size方面BERT对batch size很敏感我常用的是16或32再配合梯度累积。显存不够时先调小到8然后让梯度累积步数对应不要轻易改学习率。学习率是另一个关键参数。医学BERT微调一般用2e-5到3e-5比通用BERT稍微低一点因为医学语料更稀疏步子太大会冲坏预训练权重。warmup比例通常设为总步数的10%让模型先小步探索再正常收敛。除此之外L2正则化在PyTorch里通过weight_decay实现这里有个很容易踩的坑一般只对非bias和LayerNorm权重做衰减否则LayerNorm参数也被正则化训练不稳定。下面这段训练配置示例展示了分组weight decay和warmup的写法from transformers import AdamW, get_linear_schedule_with_warmup def build_optimizer(model, num_training_steps, lr2e-5): no_decay [bias, LayerNorm.weight] grouped [ { params: [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)], weight_decay: 0.01, }, { params: [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)], weight_decay: 0.0, }, ] optimizer AdamW(grouped, lrlr) scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(num_training_steps * 0.1), num_training_stepsnum_training_steps, ) return optimizer, scheduler这里no_decay列表要匹配实际参数名。HuggingFace的BERT里LayerNorm参数名是LayerNorm.weight但如果你自己改了模型结构要检查参数名。另外这里的AdamW是老接口新版transformers会推荐用torch.optim.AdamW但逻辑一致。建议每训练一定步数用验证集算F1而不是只看loss因为类别不平衡时loss下降不代表关系抽取效果好。EarlyStopping用patience3就足够医学数据上过拟合很快。4. 关系抽取的避坑手册这些坑我替你先踩了4.1 实体边界错位导致的关系误判现象模型在开发集上实体识别F1有0.85但关系抽取F1只有0.6。错误分析发现大量样本是实体对正确但关系标签错误点开一看实体边界偏了一个字。原因实体识别模块输出的边界和标注不一致比如把高血压预测成高血压病或者实体识别无误但关系分类阶段用了错误的span去提取特征。在医学文本里修饰词和术语本身经常黏在一起模型很难分清到底该停在哪。解决如果关系抽取模型采用实体span表征边界错位会被放大。我一般先做严格的实体过滤宁可漏掉不确定的实体也不给错误边界只对预测正确的实体对做关系分类。另外可以加一个边界修正器用字符串匹配把预测实体对齐到标准词表比如高血压病和高血压都映射到高血压再进入关系分类器。4.2 实体嵌套与重叠关系的处理策略现象句子患者有非小细胞肺癌接受吉非替尼治疗标准答案里既有非小细胞肺癌→吉非替尼的治疗关系也有肺癌→吉非替尼的治疗关系。模型却只抽出了前者后者被漏掉。原因常见的关系抽取分类器一次只输出一个标签无法建模同一实体对同时存在多个关系的情况。在医学文本里嵌套实体和重叠关系非常普遍如果训练标签只保留一个关系模型会学到只预测一个的偏见。解决一种做法是把关系抽取改造成多标签分类输出sigmoid而非softmax用二分类交叉熵。这样可以对每个候选实体对预测多个关系。另一种做法是用生成式模型像CasRel那样把关系抽取视为序列生成同时解码多个三元组但部署成本高一些。我常用多标签分类配合显式构造同一实体对的所有有效关系作为训练标签简单且有效。4.3 类别不平衡大部分负样本让模型学到没关系现象训练过程中loss一直在降但关系F1不升输出几乎全是无关系。开发集上关系类别的recall接近0。原因在一个句子里实体对数量是O(n^2)其中绝大多数没有关系负样本比例往往超过90%模型偏向多数类。而且我一开始直接用普通的CrossEntropyLoss没有做任何处理所以模型学到的是大部分样本输出无关系就够了。解决三个常用方案按推荐顺序——第一负采样训练时随机选取一部分负样本控制负正比在5:1到10:1第二加权交叉熵给关系正类更高权重第三Focal Loss让模型聚焦难分的负样本。我通常先做负采样因为最简单可控。还要注意验证时也要按同样的负正比采样否则指标虚高。另外在推理时可以把无关系类别的阈值调高一点比如0.7从而减少假阳性。4.4 迁移学习中的词汇表冲突医学缩写与特殊字符现象用BioBERT在病历上推理发现HTN总被切分成H、T、N三个token。实体识别时模型把HTN识别成疾病但关系分类时特征向量里混入了三个子词的信息效果变差。原因BioBERT的vocab主要来自PubMed摘要和论文对电子病历里的口语化缩写覆盖不足tokenizer无法识别这个缩写退化成单字符切分。这导致实体语义被拆散关系分类器只能看到碎片化的表示。解决第一个办法是在预处理阶段做一个医学缩写词典把HTN统一替换为hypertension同时同步实体位置。第二个办法是把频繁出现的缩写加入tokenizer自定义词表并重新初始化embedding。第三个办法是使用专门在临床文本上继续预训练的ClinicalBERT它对缩写更友好。下面是一个缩写替换示例ABBREV_MAP { HTN: hypertension, T2DM: type 2 diabetes mellitus, MI: myocardial infarction, } def normalize_abbrev(text, entities): # 按实体位置逆序替换避免坐标错乱 for ent in sorted(entities, keylambda e: e[start], reverseTrue): span text[ent[start]:ent[end]] if span in ABBREV_MAP: new_span ABBREV_MAP[span] text text[:ent[start]] new_span text[ent[end]:] ent[end] ent[start] len(new_span) return text, entities注意这里按实体start逆序替换这样即使多个实体在同一个位置也不会因文本长度变化导致之前的坐标失效。但有个边界情况如果实体不是完整词比如T2DM嵌入在T2DM患者里这个简单替换会把患者顶掉。所以更稳妥的做法是在切词层面对齐而不是直接改原文本。对于初版系统先跑通再细化。5. 从论文到工程把关系抽取模型部署到临床文本流水线5.1 推理速度与长文档切分的折中在论文里模型输入往往是一句句干净的测试句。真实病历一个出院小结可能上千字没有干净的句子边界标记。如果直接截断前512个token关键实体对很可能被切断。最常见的做法是滑动窗口切分以句子为单位预测关系。但句子切分工具在医疗文本上也会出错比如患者于2019年5月1日入院可能被错误切分但至少比无脑截断要好。一个更稳妥的工程方案是固定步长窗口让相邻窗口有重叠重叠部分的预测通过实体span去重和合并。下面的代码用transformers对长文本做滑窗推理def predict_long_text(model, tokenizer, text, max_len256, stride64): tokens list(text) results [] for start in range(0, len(tokens), max_len - stride): end min(start max_len, len(tokens)) segment text[start:end] # 编码时自动截断到max_len encoded tokenizer( segment, truncationTrue, max_lengthmax_len, return_tensorspt, ) with torch.no_grad(): logits model(**encoded).logits probs torch.softmax(logits, dim-1) pred probs.argmax(dim-1).item() results.append( {offset: start, segment: segment, pred: pred, prob: probs.max().item()} ) return results这一步有个坑按字符滑窗会把完整单词从中间切开导致tokenizer切出很多unused token。我在生产环境里会先做句子切分再按句子滑窗而不是直接按固定间隔切字符。上面给的版本适合快速原型。推理速度方面BERT-base在单核CPU上每秒只能处理几段临床句子如果要做全院级别分析必须用GPU或蒸馏模型比如用DistilBERT蒸馏到医学语料上同时保持关系抽取效果。5.2 与知识图谱对接实体对齐与关系置信度阈值关系抽取的输出是实体对关系置信度但这些实体名可能是HTN、高血压病这样的自由文本知识图谱要求标准概念标识比如UMLS CUI。所以工程上需要加一步实体对齐。常见做法是用缩写词典还原再用快速模糊匹配或词向量相似度映射到标准概念。如果找不到标准概念用未映射标记丢弃否则会让下游图谱污染。关系置信度阈值也值得单独调。阈值设太高召回低设太低图谱噪音多。我会在开发集上画PR曲线找一个平衡点。关系头默认阈值0.5但医学文本语义模糊我习惯调高到0.7左右尤其是无关系类别本来就占多数。下面是对接图谱的简单筛选函数def filter_for_kg(relation_triples, threshold0.7): kept [] for subj, obj, rel, score in relation_triples: if score threshold: continue cui_s map_entity(subj) # 实体对齐到标准编码 cui_o map_entity(obj) if cui_s is not None and cui_o is not None: kept.append((cui_s, rel, cui_o, score)) return kept我一般会在阈值旁边加一个未映射日志统计被丢弃的三元组数量和原因。如果某类关系大量因为实体对齐失败被丢弃说明词表没覆盖重点补充词表比调阈值更有效。在初期模型不完善时宁可让图谱数据量小一点也不要让错误置信度叠加。5.3 模型迭代与评估指标的选择论文里常用的是宽松匹配实体对正确、关系标签正确就算命中不管实体边界多一个字。但现实医学场景严格边界匹配才靠谱。比如高血压病和高血压在关系抽取中可能都能接受但放到知识图谱里就是两个概念必须先对齐。所以我在开发集上同时算三种指标指标类型匹配要求适用场景宽松实体边界部分匹配且关系正确快速扫描候选严格实体边界完全一致且关系正确知识图谱入库头尾正确关系错误实体对相同关系标签不同错误分析模型迭代时不能只看整体F1。我习惯按错误类型分组实体识别错误、关系分类错误、实体对匹配错误、跨句关系遗漏。每组一个数量统计再决定优化方向。很多人一上来就是调参把学习率从2e-5调到1e-5调来调去最后发现数据标注错误才是最大瓶颈。所以先跑一轮错误分析再决定是修数据还是换模型更高效。6. 一个更实用的技巧用置信度校准和负样本难例挖掘再提3个点先讲置信度校准。模型输出的softmax概率往往不是真实概率尤其是医学数据类别不平衡时无关系类别的置信度虚高。一个简单做法是在验证集上学习一个温度参数T把它放在softmax前面p softmax(logits / T)。温度大于1时概率分布变平滑模型不那么自信小于1时变尖锐。我一般用验证集上的负对数似然作为目标用几行代码优化T这个技巧能帮你在实体对齐环节做出更可信的阈值。难负样本挖掘也很实用。在模型训练到一定阶段后找出关系置信度在0.4到0.7之间的负样本人工检查或加入训练集这能逼迫模型关注那些表面上像有关系的负样本。我做过一次对比在医学关系抽取数据上加入难负样本500条F1提升了3个点。具体做法是每轮训练结束时对训练集的负样本做一次预测按置信度从高到低排序拿到一批模型差点认为有关系的样本同时检查这些样本是否存在标注遗漏。动态加权正类权重是我踩过坑后总结的。一开始就固定一个很大的正类权重模型会过度激进把所有负样本都预测成关系。更好的做法是训练初期权重较小让模型先学会大数特征训练后期逐渐加大正类权重让模型聚焦边界样本。这个在PyTorch里可以自定义损失函数每n步更新一次权重。我现在的习惯是每次实验先记录错误分布再动参数因为关系抽取模型像一个黑匣子但错误分布能给你最直接的方向。这比盲目调参省力得多。希望帮到你。本文还有配套的精品资源点击获取
返回列表