简介这份资源是基于Python实现的中文医学文本实体关系抽取完整源码包面向人工智能、自然语言处理方向的高校学生与开发者尤其适合作为期末大作业、课程设计或入门级科研项目的参考实现。包内共13个文件以12个Python源码文件为主辅以1个使用说明文本压缩包约28KB体量轻便便于快速阅读与二次开发。源码围绕实体识别与关系抽取两条主线展开涵盖模型定义、数据处理、关系API、评估脚本及Flask服务等模块结构清晰能帮助读者理解从数据组织到模型推理再到接口部署的完整流程。目前已有514人学习下载说明其在同类课程作业中具有一定参考价值。通过研读这套代码读者可以掌握中文医学文本的实体与关系联合抽取思路学习如何组织训练与评估脚本并借鉴API封装方式将模型落地为可调用服务为后续实验或项目开发提供可复用的基础框架。1. 中文医学文本实体关系抽取为什么它是 Python 医疗 NLP 里最值得啃的一块硬骨头中文医学文本的实体关系抽取说白了就是从病历、出院小结、药品说明书、临床指南这些非结构化文本里把「疾病—症状」「药物—不良反应」「检查—结果」这类关系自动抽出来变成结构化三元组。它跟通用领域的关系抽取完全不是一个难度量级医学术语嵌套严重「2型糖尿病肾病」里同时有疾病和部位缩写和全称混用「心梗」和「急性心肌梗死」否定和不确定表述满天飞「未见明显异常」「不排除肿瘤可能」而且标注数据极其稀缺。Python 在这个方向上是绝对主力从数据预处理、模型训练到推理部署整条链路都有成熟工具链。这篇面向的是手里已经有一份中文医学文本实体关系抽取源码、想真正跑通并理解每一步为什么这么写的从业者也适合刚入门 Python 医学 NLP、想找一个完整项目练手的人。接下来我会按「数据怎么进 → 模型怎么搭 → 训练怎么调 → 坑在哪 → 怎么验证」的顺序把这类源码里最核心的东西拆开讲。2. 数据管线从原始病历到模型能吃的格式中间隔着四道工序2.1 中文医学文本的标注体系与关系 schema 设计拿到一份实体关系抽取源码第一件事不是看模型而是看它的数据格式和关系定义。中文医学领域常见的关系 schema 大致分几类疾病-症状、疾病-药物、药物-不良反应、检查-检查结果、疾病-科室。不同来源的数据集 schema 差异很大比如有的把「治疗」和「用药」合并成一类有的拆开。你拿到源码后先找到数据目录下的标注文件确认它用的是哪种标注体系。常见做法是每个样本包含一段文本、一组实体 span带起止位置和类型、一组关系三元组头实体、尾实体、关系类型。源码里通常有一个data/目录里面是 JSON 或 BIO 格式的文件。如果是 JSON结构一般长这样{ text: 患者因反复咳嗽咳痰3年加重伴气促1周入院。, entities: [ {id: 0, start: 3, end: 5, type: 症状, text: 咳嗽}, {id: 1, start: 5, end: 7, type: 症状, text: 咳痰}, {id: 2, start: 12, end: 14, type: 症状, text: 气促} ], relations: [ {head: 0, tail: 2, type: 并发}, {head: 1, tail: 2, type: 并发} ] }这里start和end是字符级偏移不是 token 级。中文一个字就是一个字符所以偏移计算比英文简单但要注意全角标点和空格的处理。很多源码在预处理阶段会做一次字符归一化把全角转半角、去掉多余空白这时候偏移量必须同步更新否则后面实体对齐会整体错位。关系 schema 的设计直接决定模型输出层的维度。如果你要自己扩展关系类型改完 schema 后必须同步改三处数据标注文件、模型分类头维度、评估脚本里的关系映射表。漏改任何一处训练时不会报错但评估指标会莫名其妙地低——这是血泪经验。2.2 用 Python 做实体标注对齐与负样本构造实体关系抽取的训练数据里正样本存在关系的实体对通常很少大部分实体对之间没有关系。如果全部当负样本喂进去正负比可能到 1:50 甚至更极端模型会倾向于全预测「无关系」。所以源码里一般会做负样本采样。下面是一段典型的负样本构造逻辑我按常见写法还原import random from collections import defaultdict def build_relation_samples(entities, relations, neg_ratio3): 构建正负样本对 entities: 实体列表每个元素含 id/type relations: 正样本关系列表 neg_ratio: 每个正样本采样的负样本数量 pos_pairs set() for rel in relations: pos_pairs.add((rel[head], rel[tail], rel[type])) # 按实体类型分组只在可能产生关系的类型间采样 type_groups defaultdict(list) for ent in entities: type_groups[ent[type]].append(ent[id]) neg_samples [] all_heads [e[id] for e in entities] all_tails [e[id] for e in entities] for rel in relations: count 0 attempts 0 while count neg_ratio and attempts neg_ratio * 10: h random.choice(all_heads) t random.choice(all_tails) attempts 1 if h t: continue if (h, t, rel[type]) in pos_pairs: continue neg_samples.append({head: h, tail: t, type: no_relation}) count 1 return relations neg_samples这段代码的关键参数是neg_ratio。设太小模型见不到足够负例容易过拟合正样本设太大训练被负样本主导召回率会掉。医学领域我一般从 3 开始试根据验证集上的 F1 微调。另一个细节是attempts上限防止实体数量少的时候死循环——这个坑我在早期项目里踩过数据里只有两个实体时负采样直接卡死。还有一个容易忽略的点负样本不能随便采。比如「疾病-症状」关系里如果头实体是药物、尾实体是检查这种类型组合本身就不该产生关系采进来是无效负样本反而干扰模型。更稳的做法是按关系类型限定头尾实体的候选类型集合只在合法类型组合内采样。2.3 从 BIO 标注到关系抽取输入的转换脚本很多医学数据集原始格式是 BIO 序列标注只标了实体没标关系。如果你的源码要求输入是「文本 实体对」的形式就需要先做一步转换。常见做法是先用序列标注模型或规则抽实体再基于实体位置生成候选对。def bio_to_entities(text, bio_tags): 将 BIO 标注转为实体 span 列表 entities [] start None current_type None for i, tag in enumerate(bio_tags): if tag.startswith(B-): if start is not None: entities.append({start: start, end: i, type: current_type}) start i current_type tag[2:] elif tag.startswith(I-) and start is not None: continue else: if start is not None: entities.append({start: start, end: i, type: current_type}) start None current_type None if start is not None: entities.append({start: start, end: len(bio_tags), type: current_type}) return entities这段逻辑的核心是处理 B/I/O 三种标签的边界。B-开新实体I-延续当前实体O结束当前实体。注意最后循环结束后还要补一次收尾否则文本末尾的实体不会被收录——这个 bug 在不少开源代码里都存在跑小样本测试时不容易发现一上全量数据就丢实体。转换完成后实体 span 的end是开区间取文本时用text[start:end]。如果你的源码用的是闭区间这里要减一否则实体会多一个字。这种偏移问题在中文里特别隐蔽因为多一个字往往还是合法词模型能跑但指标就是上不去。3. 模型选型与训练Python 生态里哪条路最适合中文医学文本3.1 基于 BERT 的关系分类头怎么接才不浪费预训练权重中文医学文本关系抽取目前最稳的基线还是「预训练语言模型 关系分类头」。预训练模型选中文医学领域的比如在中文病历上继续预训练过的 BERT 变体比通用中文 BERT 在医学术语上的表现明显更好。源码里如果用的是transformers库加载模型通常就一行但分类头怎么接有讲究。常见两种接法一种是取[CLS]位置的向量直接过全连接另一种是把头尾实体的向量拼起来再过分类层。前者实现简单但对实体位置不敏感后者更符合关系抽取的任务特性。我一般用后者import torch import torch.nn as nn from transformers import BertModel class MedicalRelationModel(nn.Module): def __init__(self, pretrained_path, num_relations, hidden_size768): super().__init__() self.bert BertModel.from_pretrained(pretrained_path) # 头实体向量 尾实体向量 CLS向量 拼接 self.classifier nn.Sequential( nn.Linear(hidden_size * 3, hidden_size), nn.ReLU(), nn.Dropout(0.3), nn.Linear(hidden_size, num_relations) ) def forward(self, input_ids, attention_mask, head_pos, tail_pos): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) sequence_output outputs.last_hidden_state # [B, L, H] cls_vec outputs.pooler_output # [B, H] # 按位置取头尾实体向量这里假设已对齐到 token 级 batch_size input_ids.size(0) head_vec sequence_output[torch.arange(batch_size), head_pos] tail_vec sequence_output[torch.arange(batch_size), tail_pos] combined torch.cat([cls_vec, head_vec, tail_vec], dim-1) logits self.classifier(combined) return logitshead_pos和tail_pos是实体在 token 序列里的位置索引。中文 BERT 分词后一个词可能对应多个 token所以实体位置对齐是预处理阶段必须做好的事。常见做法是取实体第一个 token 的位置或者对实体范围内所有 token 做平均池化。平均池化更稳但计算稍慢。Dropout(0.3)这个值在医学小数据集上比较合适太大欠拟合太小过拟合。如果你的数据量超过五万条可以降到 0.1。分类层中间加一层hidden_size的隐层是为了增加非线性表达能力如果关系类型少于 10 类也可以直接一层线性映射到输出。3.2 训练参数怎么设学习率、batch size 与类别权重的实操取值医学文本关系抽取的训练参数设置比模型结构影响还大。下面这张表是我在多个中文医学数据集上试出来的经验区间参数推荐范围说明学习率2e-5 ~ 5e-5BERT 微调经典区间医学小数据取小值batch size16 ~ 32受显存限制太小梯度噪声大最大序列长度256 ~ 512病历句子一般不超过 256长文本截断训练轮数5 ~ 10配合早停验证集 F1 不升就停类别权重按频率反比负样本多时给正样本加权预热比例0.1前 10% 步数线性预热类别权重这块单独说。如果负样本是正样本的 10 倍可以在损失函数里给正样本 3~5 倍的权重。torch.nn.CrossEntropyLoss的weight参数直接传一个 tensor 就行# 假设关系类型顺序为 [关系1, 关系2, ..., no_relation] class_counts torch.tensor([120, 80, 2000], dtypetorch.float) weights 1.0 / class_counts weights weights / weights.sum() * len(class_counts) criterion nn.CrossEntropyLoss(weightweights)注意weights要归一化否则整体 loss 尺度会变学习率相当于被隐式放大了。这个细节很多源码没写清楚直接拿原始频率反比当权重结果训练 loss 震荡得厉害。早停策略建议监控验证集的关系分类 F1而不是 loss。医学数据里 loss 下降但 F1 不升甚至下降的情况很常见因为模型在拟合负样本。耐心值设 2~3 轮超过就停。3.3 用 PyTorch 写一个可复现的训练循环训练循环本身不复杂但医学文本项目里容易在数据加载和设备管理上翻车。下面是一个精简但完整的训练循环骨架from torch.utils.data import DataLoader from transformers import AdamW, get_linear_schedule_with_warmup def train(model, train_dataset, val_dataset, epochs8, lr3e-5, batch_size16): device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) train_loader DataLoader(train_dataset, batch_sizebatch_size, shuffleTrue) val_loader DataLoader(val_dataset, batch_sizebatch_size) optimizer AdamW(model.parameters(), lrlr, weight_decay0.01) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps ) best_f1 0.0 patience 0 for epoch in range(epochs): model.train() total_loss 0 for batch in train_loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) head_pos batch[head_pos].to(device) tail_pos batch[tail_pos].to(device) labels batch[labels].to(device) optimizer.zero_grad() logits model(input_ids, attention_mask, head_pos, tail_pos) loss criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() total_loss loss.item() f1 evaluate(model, val_loader, device) print(fEpoch {epoch1}, Loss: {total_loss/len(train_loader):.4f}, Val F1: {f1:.4f}) if f1 best_f1: best_f1 f1 torch.save(model.state_dict(), best_model.pt) patience 0 else: patience 1 if patience 3: print(Early stopping) break return best_f1几个关键点clip_grad_norm_设 1.0 是防止梯度爆炸的保险医学文本里长句多不加这个偶尔会 loss 变 NaN。weight_decay0.01是 BERT 微调的常规值不要设太大。scheduler.step()放在每个 batch 后不是每个 epoch 后这个顺序错了学习率曲线就完全不对。evaluate函数里记得把模型切到eval()模式并加torch.no_grad()否则验证阶段显存占用会持续增长跑几个 epoch 就 OOM。4. 避坑与排查中文医学实体关系抽取源码跑不通的五个典型原因4.1 现象训练 loss 正常下降但验证集 F1 始终为 0原因最常见的是标签映射错位。数据里的关系类型字符串和模型输出层的索引没有对上模型学的是「第 0 类」但评估时把「第 0 类」映射成了另一个关系名。另一种可能是验证集里全是负样本正样本在划分时被分完了。解决打印训练集和验证集的标签分布确认每个关系类型在两个集合里都有出现。然后检查标签映射字典确保label2id和id2label互为逆映射。我一般会在训练前加一段断言assert set(train_labels) set(val_labels), 训练集和验证集标签不一致 for k, v in label2id.items(): assert id2label[v] k, f标签映射不一致: {k}4.2 现象实体识别结果整体偏移一个字原因BIO 转换时区间开闭没统一或者文本预处理时做了全角转半角但没更新偏移。中文里全角逗号和半角逗号都占一个字符位置但有些清洗脚本会把连续空格合并导致后续所有偏移错位。解决在预处理阶段记录每次文本修改的操作日志偏移量同步更新。更稳的做法是预处理只做最小必要修改全角转半角放在分词之后做。如果已经错了用text[start:end]打印实体文本跟标注原文对比一眼就能看出偏移方向。4.3 现象模型在测试集上 F1 很高但实际推理时结果乱七八糟原因训练和推理的输入构造不一致。训练时实体位置是人工标注的推理时实体是模型预测的预测实体有误差导致头尾实体向量取错位置。另一个常见原因是推理时没加attention_maskpadding 部分参与了注意力计算。解决推理阶段要么用 gold 实体评估模型上限要么用预测实体评估端到端效果两者要分开报告。输入构造必须和训练时完全一致包括 tokenizer 的max_length、padding 策略、特殊 token 的添加方式。建议把输入构造逻辑抽成一个独立函数训练和推理共用。4.4 现象GPU 显存够但训练速度极慢原因DataLoader的num_workers设成了 0数据加载在主进程串行执行。或者 tokenizer 在__getitem__里每次重新加载没有缓存。解决num_workers设成 4 或 8根据 CPU 核数pin_memoryTrue。tokenizer 在数据集初始化时加载一次存成成员变量。如果数据量不大可以预先把所有样本 tokenize 好存成 tensor训练时直接取速度能快好几倍。4.5 现象换了预训练模型后指标反而下降原因不同预训练模型的 tokenizer 不同词表大小不同head_pos和tail_pos的对齐方式可能失效。另外有些医学预训练模型用的是全词掩码分词粒度和通用 BERT 不一样实体边界可能被切碎。解决换模型后重新跑一遍实体位置对齐的单元测试打印几个样本的 token 序列和实体位置确认实体没有被切到两个 token 里。如果被切碎改用实体范围内 token 平均池化而不是取第一个 token。5. 进阶技巧用规则兜底和置信度过滤把端到端效果再拉一截模型跑通之后真正上线前还有一步后处理。纯模型输出的关系三元组里总有一些明显不合理的比如「药物—症状」这种 schema 里不存在的关系被误判出来或者置信度只有 0.3 的弱预测。我一般会加两层过滤。第一层是 schema 约束。维护一个合法关系类型到头尾实体类型的映射表比如{治疗: (药物, 疾病), 并发: (疾病, 疾病)}。模型输出的三元组如果头尾实体类型不在合法组合里直接丢弃。这一层能干掉大部分低级错误而且零成本。第二层是置信度阈值。模型输出的 softmax 概率里取最大类别的概率作为置信度。阈值设多少要看业务容忍度召回优先就设 0.5准确优先就设 0.8。我一般会在验证集上画一条置信度-F1 曲线找拐点。def filter_relations(predictions, schema, threshold0.6): predictions: [{head:..., tail:..., type:..., confidence:...}] filtered [] for pred in predictions: if pred[confidence] threshold: continue head_type pred[head_type] tail_type pred[tail_type] allowed schema.get(pred[type]) if allowed and (head_type, tail_type) not in allowed: continue filtered.append(pred) return filtered还有一层规则兜底值得做否定检测。医学文本里「无」「未见」「不排除」这些词出现时后面的关系往往要翻转或丢弃。简单做法是在实体所在句子里匹配否定词窗口如果否定词在实体前 5 个字符内就把该实体相关的关系标记为「否定」输出时单独处理。这个规则不完美但在没有专门否定检测模型的情况下能明显减少假阳性。验证方法上除了常规的精确率、召回率、F1我建议再做一个「人工抽检」从测试集里随机抽 100 条人工判断模型输出的三元组是否正确算一个「人工准确率」。这个指标比自动指标更接近真实体感也能发现自动评估掩盖的问题比如模型学会了利用标注偏差刷分。最后说个习惯每次改完数据或模型先跑一个 50 条样本的小实验确认 loss 能降、F1 能升再上全量。全量训练一次动辄几小时用小样本快速验证能省下大量时间。这个习惯帮我避开了无数次「跑了半天发现标签映射错了」的翻车。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?