ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

医学实体关系抽取实战:数据规范、模型训练与五个避坑记录

医学实体关系抽取实战:数据规范、模型训练与五个避坑记录 简介面向医学文本挖掘与知识图谱构建的中文医学实体关系抽取方法研究资源包适合NLP研究者、医疗信息处理学习者及知识图谱从业者用于解决非结构化医学文本中病症、临床表现、化学成分等实体识别及其语义关联提取问题。资源围绕CHIP2020_Relation任务提供基于深度学习的实体识别与关系分类实现方案涵盖数据预处理、模型训练、评估与推理等完整流程模型代码采用模块化设计便于替换网络结构与调整超参数。压缩包共113个文件包体约157KB以77个Python脚本为核心包含re_runner.py、eval_gen.py等执行与评估代码另含18个zbak备份文件、7个txt说明、5个yml配置、2个md文档及少量辅助脚本目录结构清晰便于按模块查阅与二次开发。已有52人浏览学习可作为医学信息抽取任务的入门参考与实验基线帮助理解概念归一化、关系标准化及医学知识网络构建的关键技术路径并为进一步开展临床辅助诊断、风险预警等应用研究提供基础。1. 从非结构化病历里捞关系医学实体关系抽取到底难在哪医学实体关系抽取也就是从出院小结、检查报告这类非结构化文本里识别出疾病、药物、检查等实体并判断它们之间是治疗、诊断还是不良反应关系这是医疗知识图谱自动构建的前置步骤。我此前在一个模拟项目X里带队做过这件事最初以为先训一个NER模型抽实体再训一个分类器判断关系就行结果验证F1卡在0.7附近怎么都上不去。拉出错误样本一看不是实体没认出而是边界错一位、负样本比例失衡、标注规范不统一这些更底层的问题。下面会把这套数据规范、模型基线、训练参数和五个踩坑记录拆开讲适合准备做医疗文本挖掘或知识图谱构建的开发者、算法工程师和NLP方向研究生。2. 数据与标注规范实体类型、边界规则与样本构造如果你拿到一批医学文本就急着开训大概率会在数据清洗阶段耗掉一周。常见做法是先定实体类型清单和标注边界规范再写脚本把BIO标签转成模型能吃的样本。这一步决定了整个模型的上限后面换什么网络结构都只能在这个区间里做文章。2.1 实体类型不是越多越好模拟项目X最初设计了疾病、药物、检查、症状、解剖部位、手术、操作、时间、数值九类实体标了五百条之后发现标注一致性急速下滑。两个标注员对“慢性阻塞性肺疾病伴急性加重”的拆分能吵起来有人把整个短语标成一个疾病有人拆成“慢性阻塞性肺疾病”加“急性加重期”。最后我们把实体类型收敛成四类疾病、药物、检查、症状。解剖部位并入疾病或症状的边界内不作独立类型时间和数值直接丢弃。这个收敛决策的出发点是实体类型每多一类边界模糊的样本数量会指数级上升。关系种类也随之膨胀四类实体两两配对除去无意义的组合真正需要判断的关系只剩下治疗、诊断、不良反应、检验结果异常这几种。对知识图谱而言越精准的实体越有价值对模型训练而言越收敛的类型越容易学稳。先跑通流程再加类比一开始把所有类型都塞进去再返工要省得多。2.2 边界规则最长匹配与预标注标注规范里最重要的两条是实体边界取最长匹配带有修饰词的整体只能整体标注不能拆开。下表是模拟项目X标注规范中的对照示例。场景正确标注错误标注左肺上叶结节左肺上叶结节左肺上叶 结节慢性阻塞性肺疾病伴急性加重慢性阻塞性肺疾病伴急性加重慢性阻塞性肺疾病 急性加重除了规范文档我强烈建议做预标注先用一个粗模型或词典对全部语料自动标一遍人工只负责删错、改类型而不是从零开始画线。这样标注速度能提升一倍一致性也好很多。标注完成后还要抽一部分样本做一致性检查比如计算两个标注员在相同句子上的实体边界重合比例低于0.9就要回去对齐边界规则。2.3 从BIO标注到关系候选样本标注数据最终要变成训练样本。第一步是把BIO序列还原成实体列表下面是我常用的转换函数。def bio_to_entities(tokens, bio_tags): entities [] cur_type None cur_start -1 for idx, (token, tag) in enumerate(zip(tokens, bio_tags)): if tag.startswith(B-): if cur_type is not None: entities.append({ text: .join(tokens[cur_start:idx]), type: cur_type, start: cur_start, end: idx }) cur_type tag[2:] cur_start idx elif tag.startswith(I-): if cur_type is None or tag[2:] ! cur_type: raise ValueError(fBIO序列在第{idx}个token处不合法) else: if cur_type is not None: entities.append({ text: .join(tokens[cur_start:idx]), type: cur_type, start: cur_start, end: idx }) cur_type None cur_start -1 if cur_type is not None: entities.append({ text: .join(tokens[cur_start:]), type: cur_type, start: cur_start, end: len(tokens) }) return entities这段代码按token顺序扫描遇到“B-”开启新实体遇到“I-”延续当前实体遇到“O”关闭实体。最后token循环结束后补了一次收尾否则尾部实体会被丢弃。注意我这里的end是后开区间end索引本身不包含在实体内后面做文本切片时直接用text[start:end]不会出现差一错误。拿到实体后下一步是构造关系候选对。同一个句子里的实体两两配对距离太远的直接过滤。def build_relation_candidates(entities, sentence, max_dist80): candidates [] for i, head in enumerate(entities): for j, tail in enumerate(entities): if i j: continue if abs(head[end] - tail[start]) max_dist: continue candidates.append({ sentence: sentence, head: head, tail: tail, relation: unknown }) return candidatesmax_dist这个参数值得单独说。关系判断理论上可以跨句但医学文本里大多数有效关系集中在同一句或邻近句内。跨句候选会让数据规模爆炸而且人工标注跨句关系的分歧也大。常见做法是先用句内候选跑基线效果不够再引入相邻句窗口。负采样是这里的重头戏。如果把全部候选都丢进训练集正负比可能到1:50模型很容易全部预测成“无关系”。我一般把正负比控制在1:5以内具体做法是先保留全部带标注的真样本再从unknown候选里按比例抽取负样本。提示负采样比例不要一开始就压到1:10先从1:5起步模型稳定后再逐步放大。3. 模型选型与基线搭建BERT管道法的落地参数这一章讲模型选型和基线搭建。医学实体关系抽取的资源包里有完整的训练代码但如果你不清楚每个参数在干什么换一批语料就会翻车。我按管道模型来讲这是目前落地最稳的路线。3.1 管道模型还是联合模型先给结论跑基线用管道模型也就是先做实体识别再做关系分类。联合抽取模型理论上能同时建模实体和关系参数共享、误差不累积但医学文本关系类型多、类目不均衡联合模型一旦实体边界预测偏了关系部分会连带出错定位问题非常痛苦。常见做法是先用管道模型把所有环节拆开确认实体F1和关系F1都达标后再考虑用联合模型压缩流程。实体识别部分用BERT微调加线性分类头关系分类部分用BERT的序列分类头。两个模型都用同一个医学预训练模型做初始化。领域预训练模型通常能比通用BERT涨两到三个点但如果你没有现成的医学预训练权重先用通用BERT跑通再自训练也不迟。3.2 关系分类的输入编码实战关系分类模块里我习惯把head和tail实体用特殊符号包起来让BERT直接看到实体边界。from transformers import BertTokenizer, BertForSequenceClassification tokenizer BertTokenizer.from_pretrained(./medical_pretrain) model BertForSequenceClassification.from_pretrained( ./medical_pretrain, num_labelsnum_relation_types ) def encode_relation_sample(sentence, head, tail): marked sentence[:head[start]] [HEAD] head[text] [/HEAD] sentence[head[end]:] marked marked[:tail[start]] [TAIL] tail[text] [/TAIL] marked[tail[end]:] inputs tokenizer( marked, max_length128, truncationTrue, paddingmax_length, return_tensorspt ) return inputs代码里做的事情是先在head实体两侧插入[HEAD]和[/HEAD]再在tail实体两侧插入[TAIL]和[/TAIL]最后交给tokenizer编码。这个标记操作把实体位置信息直接暴露给BERT分类头模型不用靠注意力自己去找实体位置收敛难度小很多。max_length取128是因为多数医学句子在128个token以内取太长会塞进大量PAD浪费计算。遇到长文本先按标点切成短句再编码。训练循环里还有两个关键参数值得注意。outputs model(**inputs, labelstensor_labels) loss outputs.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad()学习率2e-5是BERT微调的经验值1e-5更稳但收敛慢batch size在单卡上建议8到16。梯度裁剪到1.0是防止长序列训练时的梯度爆炸。医学语料通常3到5个epoch就会在验证集上过拟合从第二个epoch开始就要盯验证F1而不是训练loss。3.3 评估认准三元组F1医学实体关系抽取的评估不能只看关系分类accuracy要把任务拆成三元组看。一个三元组是(head, tail, relation)三个元素全对才算一个正确预测。指标说明参考做法实体F1实体边界和类型完全一致才算对边界差一个字也算错关系F1按head、tail、relation三元组计算阈值默认0.5可调到0.6关系精确率预测为正的三元组里真比例高于0.8才好交付阈值这块我一般会调关系分类的置信度阈值从0.5往0.7调精确率会明显上升召回率小幅下降。如果你的下游任务允许漏判但不允许错判阈值可以往0.8走。评测脚本里如果把“无关系”类也算进accuracy指标会虚高所以务必按三元组维度分别统计。4. 避坑与排查五个让我翻车的实际记录这章全部来自真实调试经验每一条都按现象、原因、解决三个角度记录。照着检查一遍能少走很多弯路。4.1 验证集F1很高一到新数据就崩现象模型在验证集上关系F1到0.84部署到另一批新病历后直接掉到0.62。原因按句子随机切分训练集和验证集时同一个患者的多条病历可能同时出现在两边。模型记住了这个患者的模板化表述习惯而不是真正泛化到不同人群。解决按患者ID切分保证同一个患者的记录只出现在一个数据集合里。import random records_by_patient {} for r in all_records: records_by_patient.setdefault(r[patient_id], []).append(r) patient_ids list(records_by_patient.keys()) random.shuffle(patient_ids) split int(len(patient_ids) * 0.8) train_ids patient_ids[:split] valid_ids patient_ids[split:] train_records [r for pid in train_ids for r in records_by_patient[pid]] valid_records [r for pid in valid_ids for r in records_by_patient[pid]]这里patient_id是脱敏后的编号。按患者切分后还要检查一下关系类别的分布某些疾病可能集中在少数患者身上这样切分会加剧类别不均衡。遇到这种情况先按患者分组再做分层抽样。4.2 实体边界总是差一个字现象模型反复把“左肺上叶结节”抽成“肺上叶结节”把“慢性阻塞性肺疾病伴急性加重”抽成“肺疾病”。原因标注规范对最长匹配执行不严格不同标注员对同一个实体的边界判断不一致。模型学到的不是边界规则而是“边界概率”所以每次预测都差一点。解决预标注阶段用词典把命中的实体全部按最长规则标出人工只负责删错和改类型。同时加一个后处理脚本用医学词典对边界做扩展。def post_process_entity(predicted_text, medical_lexicon): for term in medical_lexicon: if predicted_text in term and term.startswith(predicted_text): return term return predicted_text这段后处理能救回一部分边界缺失但它只是兜底不能根治标注不一致。建议在数据环节多花时间统一边界规则后处理做太多会让模型对词典产生依赖换数据就废。4.3 关系分类全在“无关系”上躺平现象训练loss一直在降但治疗、诊断这几个关系类别的F1几乎为0。拉出预测分布一看模型把所有样本都判成了“无关系”。原因正负样本比失衡到1:50模型只要全猜“无关系”就能把loss压到很低。优化器发现这个捷径自然不愿意学真正的分类边界。解决先做负采样控制比例再给损失函数配类别权重。from torch.nn import CrossEntropyLoss class_weights torch.tensor([1.0, 3.0, 3.0, 5.0, 1.0]) loss_fct CrossEntropyLoss(weightclass_weights) for batch in train_loader: ... loss loss_fct(logits.view(-1, num_labels), labels.view(-1))class_weights按关系类别频次反比设置。模拟项目X里“治疗”类样本多“不良反应”类样本少后者权重就拉到5.0。配合负采样把正负比控制在1:5这个翻车点基本能避开。4.4 显存溢出batch size调到8都不行现象16的batch size直接OOM调到8还偶尔溢出。原因max_length设置到512。BERT的注意力计算量随序列长度平方增长长文本里大量PAD token浪费了显存。解决先按标点把长文本切成短句再做滑窗截取。常见做法是保留实体所在位置的前后各64个token作为窗口。def split_long_text(text, entities, window64): chunks [] for ent in entities: start max(0, ent[start] - window) end min(len(text), ent[end] window) chunks.append({ text: text[start:end], head: ent, offset: start }) return chunks注意offset一定要传进后续特征构造逻辑否则实体位置在切片后会整体偏移导致head和tail标记插错地方。4.5 实体识别模型在PAD位置乱输出现象训练时实体F1很高验证集也正常但线上推理结果一塌糊涂。原因BERT对PAD位置的token也计算了分类loss模型学会了在PAD上输出“O”来降低训练loss但推理时根本没有PAD标记这部分行为就乱了。解决在loss计算时用attention_mask把非有效token剔除。active_loss attention_mask.view(-1) 1 active_logits logits.view(-1, num_labels)[active_loss] active_labels labels.view(-1)[active_loss] loss loss_fct(active_logits, active_labels)这个坑最隐蔽因为它对验证集F1的影响不大只有部署上线后才会暴露。从那以后我每次训练NER模型都会强制检查loss计算里有没有mask。5. 从零标注里省时间远程监督召回加主动学习筛选当手上只有几百条人工标注样本时模型怎么都跑不出稳定效果。我后来试了一条路线远程监督先灌出一批弱标签再用主动学习挑硬样本人工复核。这条路线在知识图谱构建实践中很常用能把标注成本压到原来的三分之一左右。5.1 远程监督生成候选远程监督的基本假设是一个句子中同时出现两个实体而这对实体正好在内部术语表里有对应关系那么这句话大概率在触发该关系。它召回率高但精度低所以我只拿它做候选召回不直接当训练标签。常见做法是用内部维护的临床术语表在语料里做词典匹配再按实体对出现频次排序。candidates [] for sentence in raw_documents: entities entity_tagger.tag(sentence) for head, tail in itertools.combinations(entities, 2): relation medical_lexicon.lookup(head.text, tail.text) if relation is not None: candidates.append({ sentence: sentence, head: head, tail: tail, relation: relation, score: 0.0 })这里entity_tagger是词典匹配器medical_lexicon存的是药物-疾病-关系三元组。把所有同时命中同一关系的句子都记下来就得到一批带弱标签的候选。5.2 主动学习挑硬样本候选规模往往上万全部人工复核不现实。常见做法是先用已有小模型对候选做预测按置信度排序低置信度的抽出来给人复核高置信度的直接进训练集。model.eval() scores [] with torch.no_grad(): for c in candidates: logits model(c[sentence]) prob, pred torch.max(logits, dim-1) scores.append((c, prob.item())) scores.sort(keylambda x: x[1]) hard_samples scores[:200]这段代码把预测置信度最低的200个候选挑出来。建议第一轮只抽200条人工校验确认远程监督生成的标签质量分布后再决定第二轮要不要扩展到2000条。高置信度样本也不是全对但作为初版训练数据足够把基线拉起来。从那以后我每次接到一批没有标注的医学文本都会先跑一遍远程监督生成候选再用主动学习挑硬样本剩下的高置信度样本直接凑成训练数据。项目交付前还会强制按患者ID重新切一遍测试集避免数据泄漏这种隐蔽问题。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进