简介:本资源为基于Python实现的中文医学文本实体关系抽取完整源码包,面向人工智能、自然语言处理方向的高校学生与开发者,尤其适合作为期末大作业、课程设计或入门级NLP项目实践参考。压缩包共13个文件,以12个Python源码文件为主,辅以1个使用说明文本,整体约28KB,涵盖实体识别、关系抽取、模型定义、评估脚本及Flask接口服务等模块,结构清晰、便于二次开发。资源围绕中文医学文本这一垂直领域展开,涉及实体与关系的联合处理流程,可帮助读者理解从数据组织到模型推理的完整链路。目前已有514人学习下载,具备一定参考热度。对于需要快速搭建医学文本抽取原型、撰写课程报告或对比不同实现思路的读者,这份源码可作为可直接运行的基础工程,节省环境配置与框架搭建时间,并在此基础上进行功能扩展与实验调优。
1. 中文医学文本实体关系抽取:一份 Python 源码能帮你省掉多少脏活
拿到一份中文电子病历,你想让机器自动读出「患者服用阿司匹林后出现胃部不适」这句话里,药物是阿司匹林、症状是胃部不适、两者是「导致」关系——这就是中文医学文本实体关系抽取要干的事。它属于 NLP 里的信息抽取任务,拆开看是两步:先做命名实体识别(NER),把疾病、药物、症状、检查、手术这些实体从自由文本里框出来;再做关系分类(RE),判断两个实体之间是「治疗」「导致」「检查」「所属」还是「无关系」。和通用领域不同,医学文本有大量嵌套实体、缩写、否定句和长距离依赖,通用模型直接搬过来往往翻车。这份基于 Python 的源码,价值不在于算法多新,而在于它把数据预处理、标注格式转换、模型训练、推理预测这条链路串通了,让你不用从零搭脚手架。适合有 Python 基础、想快速跑通医学信息抽取 pipeline 的工程师和医学信息学方向的学生。
2. 先搞清楚实体关系抽取在医学场景里到底怎么定义
2.1 实体类型和关系类型不是拍脑袋定的
医学实体关系抽取的第一步不是写代码,是确定标注体系。你拿到的源码里如果带标注数据,先看它的 schema。常见做法是参照 SemEval 或 CCKS 的医学任务定义,实体类型一般包括:疾病(Disease)、药物(Drug)、症状(Symptom)、检查(Examination)、手术(Operation)、身体部位(BodyPart)。关系类型则围绕临床语义,比如药物治疗疾病(Drug-Disease-Treat)、药物导致症状(Drug-Symptom-Adverse)、检查诊断疾病(Exam-Disease-Diagnose)、疾病表现症状(Disease-Symptom-Manifest)。
为什么这件事要先做?因为关系抽取的模型结构、损失函数、评估方式全依赖标注体系。如果你把「高血压」标成疾病,又把「血压高」标成症状,模型学到的边界就是乱的。我一般会先拿 50 条病历人工过一遍,统计实体类型分布和关系类型分布,确认没有歧义再批量标注。
源码里通常有一个config.py或schema.json定义这些类型。打开它,确认三件事:实体类型列表、关系类型列表、以及是否有「无关系」类别。没有「无关系」类别的源码要小心,推理时模型会对任意两个实体都强行给一个关系,噪声很大。
2.2 流水线式 vs 联合抽取,源码用的是哪种
实体关系抽取有两条技术路线。流水线式(Pipeline)是先跑 NER 模型得到实体,再把实体两两配对送进关系分类模型。联合抽取(Joint Extraction)是一个模型同时输出实体和关系,常见结构有 CasRel、TPLinker、SPN 等。
流水线式的优点是模块解耦,NER 和 RE 可以分别换模型、分别调参,调试方便;缺点是误差传递,NER 漏掉的实体在 RE 阶段永远找不回来。联合抽取的优点是全局优化,实体和关系互相约束;缺点是训练难度大,标注数据少的时候容易过拟合。
你拿到的源码大概率是流水线式,因为这是最容易复现的结构。判断方法:看代码里有没有两个独立的模型类,一个输出 BIO 序列,一个输出关系分类 logits。如果有,就是流水线。流水线式在医学场景里其实够用,因为医学实体边界相对规范,NER 的 F1 通常能到 85% 以上,误差传递可控。
2.3 数据格式:BIO 标注和关系三元组怎么对应
源码里的数据一般有两种格式。一种是序列标注格式,每行是「字符 + 标签」,用 BIO 体系标记实体边界:
患 O 者 O 服 O 用 O 阿 B-Drug 司 I-Drug 匹 I-Drug 林 I-Drug 后 O 出 O 现 O 胃 B-Symptom 部 I-Symptom 不 I-Symptom 适 I-Symptom另一种是关系三元组格式,每行是「头实体 + 关系 + 尾实体」:
阿司匹林 | 导致 | 胃部不适流水线式源码需要你把这两种格式都准备好:BIO 格式喂给 NER 模型,三元组格式喂给 RE 模型。RE 模型的输入通常是「句子 + 头实体位置 + 尾实体位置」,输出是关系类别。这里有个关键细节:实体位置怎么表示。常见做法是用实体在句子中的起止 token 索引,然后在模型里把实体对应的 token 向量做平均或最大池化,拼到句子表示上。
注意:如果你的源码用的是字符级 tokenizer,实体位置就是字符索引;如果是 BERT 的 wordpiece tokenizer,实体位置要映射到 subword 索引,映射错了关系分类全乱。
3. 把源码跑起来:环境、数据和训练命令
3.1 环境配置和依赖安装的坑
拿到源码先看requirements.txt或README里的环境说明。医学文本实体关系抽取的典型依赖包括:Python 3.7+、PyTorch 或 TensorFlow、transformers(如果用预训练模型)、numpy、pandas、scikit-learn、seqeval(评估 NER)、tqdm。如果源码用了中文预训练模型,还会依赖pytorch-transformers或transformers的特定版本。
安装时最容易翻车的是 transformers 版本和 torch 版本的兼容性。我一般会先建一个干净的虚拟环境:
python -m venv med_ner_re source med_ner_re/bin/activate # Linux/Mac # med_ner_re\Scripts\activate # Windows pip install torch==1.10.0 transformers==4.12.0 seqeval==1.2.2 pip install -r requirements.txt参数说明:torch 版本不要盲目追新,源码里如果有自定义 CUDA 算子,新版本可能编译不过。transformers 版本要和源码里from transformers import ...的导入方式匹配,4.x 和 3.x 的 API 差异很大。seqeval 是 NER 评估的标准库,不要用 sklearn 的 classification_report 代替,因为实体级别的 P/R/F1 和 token 级别的不是一回事。
如果源码没有requirements.txt,就按报错逐个装。常见报错ModuleNotFoundError: No module named 'transformers'直接 pip 装;ImportError: cannot import name 'BertTokenizerFast'说明 transformers 版本太低,升级到 4.x。
3.2 数据预处理脚本怎么改
源码里一般有一个data_process.py或preprocess.py,负责把原始病历文本转成模型输入格式。你需要改的地方通常有三处:数据路径、实体类型映射、关系类型映射。
import json import os # 配置路径 RAW_DATA_DIR = "data/raw" PROCESSED_DATA_DIR = "data/processed" os.makedirs(PROCESSED_DATA_DIR, exist_ok=True) # 实体类型和关系类型映射,必须和标注数据一致 ENTITY_TYPES = ["Disease", "Drug", "Symptom", "Examination", "Operation"] RELATION_TYPES = ["Treat", "Adverse", "Diagnose", "Manifest", "NoRelation"] def load_annotations(file_path): """加载标注文件,假设是 JSON 格式""" with open(file_path, "r", encoding="utf-8") as f: data = json.load(f) return data def convert_to_bio(text, entities): """把实体列表转成 BIO 序列""" chars = list(text) labels = ["O"] * len(chars) for ent in entities: start, end, ent_type = ent["start"], ent["end"], ent["type"] if start >= len(chars) or end > len(chars): continue labels[start] = f"B-{ent_type}" for i in range(start + 1, end): labels[i] = f"I-{ent_type}" return chars, labels def convert_to_triples(text, entities, relations): """把实体和关系转成三元组""" triples = [] for rel in relations: head = entities[rel["head"]] tail = entities[rel["tail"]] triples.append({ "text": text, "head": head["text"], "head_start": head["start"], "head_end": head["end"], "relation": rel["type"], "tail": tail["text"], "tail_start": tail["start"], "tail_end": tail["end"] }) return triples if __name__ == "__main__": for file_name in os.listdir(RAW_DATA_DIR): if not file_name.endswith(".json"): continue data = load_annotations(os.path.join(RAW_DATA_DIR, file_name)) bio_data = [] triple_data = [] for item in data: chars, labels = convert_to_bio(item["text"], item["entities"]) bio_data.append({"chars": chars, "labels": labels}) triple_data.extend(convert_to_triples(item["text"], item["entities"], item["relations"])) with open(os.path.join(PROCESSED_DATA_DIR, f"bio_{file_name}"), "w", encoding="utf-8") as f: json.dump(bio_data, f, ensure_ascii=False, indent=2) with open(os.path.join(PROCESSED_DATA_DIR, f"triple_{file_name}"), "w", encoding="utf-8") as f: json.dump(triple_data, f, ensure_ascii=False, indent=2)逻辑说明:convert_to_bio把每个字符映射到一个标签,实体起始位置标 B-类型,后续标 I-类型,非实体标 O。convert_to_triples把关系列表展开成扁平的三元组,每个三元组记录头尾实体的文本和位置。参数说明:ENTITY_TYPES和RELATION_TYPES必须和标注文件里的类型字符串完全一致,大小写敏感。start和end是字符级索引,左闭右开。
改完脚本先跑一遍,检查输出文件里有没有空标签序列或越界索引。常见问题是原始标注里实体位置是 token 级而不是字符级,直接转 BIO 会错位。
3.3 训练 NER 模型:命令和关键参数
源码里 NER 模型的训练入口一般是train_ner.py。典型命令:
python train_ner.py \ --train_file data/processed/bio_train.json \ --dev_file data/processed/bio_dev.json \ --model_name bert-base-chinese \ --max_seq_length 128 \ --batch_size 32 \ --learning_rate 2e-5 \ --num_epochs 10 \ --output_dir outputs/ner_model参数说明:max_seq_length是最大序列长度,医学病历句子通常不超过 128 个字符,设太大浪费显存。batch_size根据显存调,12G 显存跑 bert-base 可以到 32。learning_rate用 2e-5 到 5e-5,太大不收敛,太小训练慢。num_epochs看验证集 F1 什么时候不再涨,一般 5 到 10 轮。
训练过程中重点看验证集的实体级 F1,不是 loss。loss 降但 F1 不涨说明过拟合了,早点停。如果 F1 一直低于 70%,检查标注质量,医学文本里实体边界模糊是常见问题。
3.4 训练关系分类模型:输入构造和负采样
RE 模型的训练入口一般是train_re.py。输入是「句子 + 头实体位置 + 尾实体位置」,输出是关系类别。关键代码在数据加载部分:
import torch from torch.utils.data import Dataset from transformers import BertTokenizer class REDataset(Dataset): def __init__(self, triples, tokenizer, max_len=128, relation2id=None): self.triples = triples self.tokenizer = tokenizer self.max_len = max_len self.relation2id = relation2id def __len__(self): return len(self.triples) def __getitem__(self, idx): item = self.triples[idx] text = item["text"] head_start, head_end = item["head_start"], item["head_end"] tail_start, tail_end = item["tail_start"], item["tail_end"] relation = item["relation"] encoding = self.tokenizer( text, max_length=self.max_len, padding="max_length", truncation=True, return_offsets_mapping=True, return_tensors="pt" ) offsets = encoding["offset_mapping"][0].tolist() head_mask = self._get_entity_mask(offsets, head_start, head_end) tail_mask = self._get_entity_mask(offsets, tail_start, tail_end) return { "input_ids": encoding["input_ids"].squeeze(0), "attention_mask": encoding["attention_mask"].squeeze(0), "head_mask": torch.tensor(head_mask, dtype=torch.long), "tail_mask": torch.tensor(tail_mask, dtype=torch.long), "label": torch.tensor(self.relation2id[relation], dtype=torch.long) } def _get_entity_mask(self, offsets, start, end): mask = [0] * len(offsets) for i, (s, e) in enumerate(offsets): if s == 0 and e == 0: continue if s >= start and e <= end: mask[i] = 1 return mask逻辑说明:return_offsets_mapping=True让 tokenizer 返回每个 token 在原始文本中的字符偏移,这样就能把字符级的实体位置映射到 token 级。head_mask和tail_mask标记哪些 token 属于头实体和尾实体,模型里用这两个 mask 做池化。参数说明:max_len要和 NER 阶段一致,否则同一句话在两个模型里的截断位置不同。relation2id是关系类型到 id 的映射,必须包含「NoRelation」类别。
负采样是 RE 训练的关键。正样本是标注了三元组的实体对,负样本是同一句话里没有关系的实体对。负样本比例一般控制在 1:3 到 1:5,太多负样本会让模型偏向预测「无关系」。源码里如果有negative_sampling函数,检查它的采样策略是不是随机的,随机采样在医学场景里可能采到「药物-药物」这种明显无关系的对,训练效果反而好。
4. 避坑与排查:医学文本实体关系抽取的 5 个血泪教训
4.1 实体边界标注不一致导致 NER 学废
现象:NER 模型在训练集上 F1 到 95%,验证集只有 60%,预测结果里实体边界忽长忽短。
原因:标注人员对实体边界的理解不一致。比如「2型糖尿病」有人标成「糖尿病」,有人标成「2型糖尿病」;「阿司匹林肠溶片」有人标全称,有人只标「阿司匹林」。模型学到的是矛盾的边界信号。
解决:制定标注规范,明确「修饰词是否纳入实体」。我一般会规定:疾病实体包含分型和分期(如「2型糖尿病」),药物实体包含剂型(如「阿司匹林肠溶片」),症状实体不包含程度副词(如「严重恶心」只标「恶心」)。规范定好后,让标注人员先标 100 条做一致性检查,Kappa 系数低于 0.8 就重新培训。
4.2 关系分类模型把「无关系」预测成高频关系
现象:RE 模型在测试集上「Treat」关系的 F1 很高,但「NoRelation」的召回率极低,大量无关系的实体对被强行分类。
原因:训练数据里正负样本不均衡,正样本远多于负样本,模型学会了「只要两个实体在同一句话里就给一个关系」的捷径。
解决:调整负采样比例,让负样本至少占 50%。或者在损失函数里给「NoRelation」类别更高的权重。还有一种做法是推理时设阈值,模型输出的 softmax 概率低于 0.5 就判为「无关系」,不取 argmax。
4.3 中文分词和字符级 tokenizer 混用导致位置错乱
现象:RE 模型训练 loss 正常下降,但验证集准确率始终在随机水平附近。
原因:NER 阶段用的是字符级输入,实体位置是字符索引;RE 阶段用了 BERT 的 wordpiece tokenizer,实体位置没有从字符索引映射到 token 索引,导致 head_mask 和 tail_mask 标错了位置。
解决:统一用字符级 tokenizer,或者用return_offsets_mapping做映射。映射时注意特殊 token([CLS]、[SEP]、[PAD])的 offset 是 (0,0),要跳过。另外中文 BERT 的 tokenizer 对某些字符会拆成多个 subword,实体边界可能落在 subword 中间,这时候要么取整到最近的 token 边界,要么用字符级模型。
4.4 长文本截断把关键关系截没了
现象:模型在短句上表现好,在长病历上关系抽取召回率骤降。
原因:max_seq_length设了 128,但很多病历段落超过 200 个字符,截断后头实体和尾实体不在同一个窗口里,关系自然抽不出来。
解决:两种方案。一是滑动窗口,把长文本切成有重叠的片段,每个片段单独抽关系,最后合并去重。二是用 Longformer 或 BigBird 这类支持长序列的模型,但显存开销大。我一般先用滑动窗口,窗口大小 128,重叠 32,合并时按实体位置去重。
4.5 评估指标选错导致模型看起来很好
现象:NER 的 token 级准确率 98%,但实体级 F1 只有 70%。
原因:用了 token 级的 classification_report,O 标签占绝大多数,准确率被拉高。实体级评估要求预测的实体边界和类型完全匹配才算对,严格得多。
解决:用 seqeval 的classification_report,它按实体级别算 P/R/F1。RE 的评估用宏平均 F1,不要用微平均,因为「NoRelation」样本太多会掩盖其他关系的表现。
5. 进阶技巧:用规则兜底和主动学习把 F1 再拉 5 个点
模型跑通之后,想在实际业务里用起来,纯模型方案往往不够。医学文本里有一批高频模式,用规则兜底比模型更稳。比如「患者既往有 X 病史」里的 X 几乎一定是疾病,「给予 X 治疗后症状缓解」里的 X 大概率是药物或手术。我一般会在模型输出后面加一层规则后处理:
import re # 规则模板:正则捕获 + 关系映射 RULES = [ (r"给予(.{2,10}?)治疗后", "Treat"), (r"既往有(.{2,10}?)病史", "Diagnose"), (r"服用(.{2,10}?)后出现(.{2,10}?)", "Adverse"), ] def rule_based_extract(text): triples = [] for pattern, relation in RULES: for match in re.finditer(pattern, text): groups = match.groups() if len(groups) == 2: triples.append((groups[0], relation, groups[1])) elif len(groups) == 1: triples.append((groups[0], relation, None)) return triples def merge_results(model_triples, rule_triples): """模型结果和规则结果合并,规则优先""" merged = {} for head, rel, tail in model_triples: key = (head, tail) merged[key] = rel for head, rel, tail in rule_triples: if tail is None: continue key = (head, tail) merged[key] = rel # 规则覆盖模型 return [(h, r, t) for (h, t), r in merged.items()]逻辑说明:RULES是正则模板列表,每个模板捕获头实体和尾实体。merge_results把模型输出和规则输出合并,规则结果覆盖模型结果。参数说明:正则里的.{2,10}?是非贪婪匹配,长度限制根据实体类型调整,药物名一般 2 到 10 个字符,症状名可能更长。规则不要写太多,覆盖高频模式就行,写多了维护成本高还容易误匹配。
另一条路是主动学习。模型在验证集上预测置信度低的样本,人工标注后加入训练集,迭代几轮 F1 能涨 3 到 5 个点。具体做法:用模型跑一遍未标注数据,按 softmax 最大概率排序,取最低的 100 条人工标注。这些样本是模型最不确定的,信息量最大。标注完重新训练,重复 3 轮。我做过的一个项目里,第一轮模型 F1 是 78%,三轮主动学习后到了 84%,代价是人工标了 300 条数据,比从头标 2000 条划算得多。
最后说一个我自己的习惯:每次改完模型或数据,先跑一个 50 条的小样本测试集,看输出有没有明显退化。这个测试集要覆盖各种边界情况——嵌套实体、否定句、长距离关系、无关系实体对。小样本过了再跑全量评估,能省很多等待时间。希望帮到你。
本文还有配套的精品资源,点击获取