简介:这是一个面向中文医学文本实体关系抽取的Python实现源码包,适合自然语言处理方向的学生用于课程设计、期末大作业或项目入门,也适合医学信息抽取初学者参考学习。资源共13个文件,以12个Python脚本和1个说明文档为主,涵盖实体识别与关系抽取主流程、模型定义、数据处理、工具函数及Flask接口服务等模块,代码结构清晰,便于按需调用和二次开发。包体大小约28KB,轻量完整,并配有使用说明,下载后即可对照运行。目前已有514人学习/下载,适合需要完整可运行项目样例的读者直接使用。从内容预览看,项目不仅包含核心抽取算法实现,还提供了API服务封装与相关辅助函数,可帮助理解医学文本从原始输入到结构化知识输出的完整流程,也能作为后续实验扩展、参数调优或功能改造的基础框架。
1. 中文医学文本实体关系抽取是什么,为什么用 Python 实现
医学文本里大量信息是非结构化的。“反复头痛伴心悸一周,诊为原发性高血压,予氨氯地平5mg”,只做实体识别得到头痛、原发性高血压、氨氯地平三个孤立词,看不到“表现为”、“用于治疗”这类关系。把实体抽取出来并绑定语义关系,就是中文医学文本实体关系抽取。它是辅助诊断、病历质控和科研数据建库的基础环节,工程上可以拆成 NER 加关系分类,也可以用联合抽取一步产出三元组。Python 的优势在于 Transformers 承担编码、PyTorch 承担训练,再配合标注对齐与验证代码,一个源码工程就能覆盖清洗、建模、评估到部署。下面按数据、训练、抽取方式三块展开。
2. 医学文本预处理与实体关系标注:把语料变成可训练的 BIO 序列
2.1 清洗规则:先处理全半角、括号和剂量单位
拿到一批原始病历文本,不要急着丢给模型。医学文本的噪声集中在全半角、括号和单位上,“口服5mg 一天两次”这种字符串如果不处理,Tokenzier 会把全角“5”当成独立字符,破坏后续标签对齐。我一般第一道工序是字符级清洗,把全角数字和标点统一成半角,再把全角括号统一为半角括号,最后删除不可见控制字符和连续空格。
import re def clean_text(raw: str) -> str: # 全角数字与标点转半角,避免“5”和“5”被当成两个 token full2half = str.maketrans( "0123456789:;,。", "0123456789:;." ) text = raw.translate(full2half) # 括号统一为半角,医学文本里“(5mg)”和“(5mg)”同时存在 text = text.replace("(", "(").replace(")", ")") # 去掉 ASCII 控制字符和多余空白 text = re.sub(r"[\x00-\x1f\x7f]", "", text) text = re.sub(r"[ \t]+", " ", text).strip() return text清洗的边界要克制:不要顺手把句号、逗号全删掉,它们是后续分句和候选实体对过滤的天然边界。start/end偏移基于清洗后的文本重新计算,清洗和标注必须共用同一份文本,否则标签错位是最难排查的问题。
2.2 BIO 标注与三元组格式:实体边界和关系的一次对齐
NER 层使用 BIO 序列标注。每个字符有且仅有一个标签,B 表示实体首字,I 表示实体内部,O 表示非实体。给“原发性高血压”标注就是“B-疾病 I-疾病 I-疾病 I-疾病 I-疾病 I-疾病”。
def make_bio(text: str, entities: list) -> list[str]: # entities 元素为 (start, end, entity_type),start 含,end 不含 labels = ["O"] * len(text) for start, end, ent_type in entities: if start < 0 or end > len(text) or start >= end: continue labels[start] = f"B-{ent_type}" for pos in range(start + 1, end): labels[pos] = f"I-{ent_type}" return labels这个函数只是基础,实际项目里还要加一条校验:实体之间允许相邻但不允许重叠,两个实体共享同一个 start 时,取长度更长的那一个。否则一个字符既是“B-疾病”又是“B-症状”,模型训练时标签冲突,loss 会来回震荡。
关系层用三元组记录,通常是头实体、尾实体、关系类型三条信息。为了保留位置信息,建议直接存字符偏移而不是实体文本:
sample = { "text": "诊断原发性高血压,予氨氯地平5mg", "entities": [ {"start": 2, "end": 8, "type": "疾病"}, {"start": 11, "end": 15, "type": "药物"} ], "relations": [ {"head": 0, "tail": 1, "type": "治疗使用"} ] }head和tail是实体列表下标,这样关系标注复用实体位置,不需要在关系里再写一遍实体文本。中文医学实体关系抽取常用关系类型如下:
| 头实体 | 关系类型 | 尾实体 | 句子示例 |
|---|---|---|---|
| 疾病 | 表现为 | 症状 | 原发性高血压 -> 头晕 |
| 疾病 | 治疗使用 | 药物 | 原发性高血压 -> 氨氯地平 |
| 药物 | 剂量为 | 剂量 | 氨氯地平 -> 5mg |
| 检查 | 提示 | 疾病 | 心电图 -> 窦性心动过速 |
标注质量直接影响模型上限。常见做法是先由算法工程师写标注规范,再由医学背景人员校对关系类型,至少抽 10% 样本做一致性校验,不一致率超过 5% 就退回重标。
2.3 数据切分与类别不平衡处理
医学标注数据通常只有几千条,切分比例不能按通用项目的 9:1 硬套。我一般按以下方式处理:
- 按患者 ID 分组切分,保证同一条病历不会同时出现在训练集和验证集
- 训练集、验证集、测试集的比例设为 8:1:1
- 随机种子固定,确保多次实验可比
from sklearn.model_selection import train_test_split sample_ids = list(range(len(dataset))) train_ids, dev_ids = train_test_split( sample_ids, test_size=0.2, random_state=42 )关系抽取阶段最大的坑是负例爆炸。一个句子有 4 个实体,就会产生 12 个实体对,其中大多数实体对之间没有关系。如果全部作为负例训练,模型会倾向把所有候选对都预测成“无关系”,F1 虚高但实际抽取能力很差。常见做法是限制负例数量,让负正比例维持在 3:1 左右,并且负例只从同一句内抽取,避免跨句组合产生无意义样本。
3. BERT 微调训练医学实体识别:token 对齐是最大的坑
3.1 医学预训练模型选型:通用中文 BERT 还是领域继续预训练
医学实体识别不能只盯着标注数据,预训练模型的选择直接决定收敛速度和最终 F1。从头训练中文 BERT 需要十亿级语料,对绝大多数项目不现实。常见做法是直接用开源中文 BERT 做基底,如果有在医学语料上继续预训练过的权重,优先替换再对比结果。
判断标准只有一个:在验证集 NER F1 上做替换实验,不要凭语感。通用 BERT 在“高血压”这类常见词上表现不差,但遇到“慢性肾脏病 5 期”这种带数字分期的实体时,领域继续训练模型的边界识别明显更稳。模型名可以直接传给AutoModelForTokenClassification.from_pretrained,替换成本很低。
3.2 用 Transformers 搭建 NER 训练代码
模型结构是典型的序列标注方案:BERT 输出每个 token 的向量,再经过一个线性分类层映射到标签空间。数据量小时可以直接用 Trainer 训练,省去自己写循环的麻烦。
from transformers import ( AutoTokenizer, AutoModelForTokenClassification, Trainer, TrainingArguments ) model_name = "bert-base-chinese" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForTokenClassification.from_pretrained( model_name, num_labels=len(label2id) ) training_args = TrainingArguments( output_dir="./ner_output", learning_rate=2e-5, per_device_train_batch_size=16, per_device_eval_batch_size=32, num_train_epochs=5, evaluation_strategy="epoch", # 较新版本可改为 eval_strategy="epoch" save_strategy="epoch", logging_dir="./logs", ) trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=dev_dataset, ) trainer.train()几个参数需要重点解释。learning_rate用 2e-5 到 3e-5,BERT 微调的学习率比随机初始化模型低一个数量级,调太大会破坏预训练权重。batch_size=16是在 16GB 显存下比较稳的值,显存不够时优先降 batch 而不是缩短序列长度。evaluation_strategy="epoch"表示每个 epoch 结束跑一次验证,避免训练完才发现过拟合。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| learning_rate | 2e-5 ~ 3e-5 | 过大导致灾难性遗忘 |
| max_length | 128 ~ 256 | 超长病历先分句再处理 |
| batch_size | 16 ~ 32 | 由显存决定,梯度累积可兜底 |
| num_train_epochs | 5 ~ 10 | 配合早停,看验证集 F1 |
3.3 token 对齐:标签和 token 序列长度对不上
中文 BERT 大多按字切词,但数字和英文仍可能被子词分词器再次拆分。例如“5mg”可能变成“5”和“mg”两个 token,模型输出的标签长度就和字符级 BIO 序列不一致。解决方式是开启return_offsets_mapping,用每个 token 在原文中的字符偏移去取标签。
def tokenize_and_align_labels(text, char_labels, tokenizer, max_len=128): encoding = tokenizer( text, max_length=max_len, truncation=True, padding="max_length", return_offsets_mapping=True, ) labels = [] for offset in encoding["offset_mapping"]: if offset == (0, 0): labels.append(-100) # 特殊 token 不参与损失计算 else: labels.append(char_labels[offset[0]]) encoding["labels"] = labels return encodingoffset_mapping返回每个 token 在原文中(start, end)的字符位置,取start对应的字符标签即可。-100是 PyTorchCrossEntropyLoss内置的忽略值,padding 和[CLS]、[SEP]都不会产生梯度。预测阶段还原实体时,用同样的 offset 信息把 token 级标签映射回字符位置,再按相邻同标签合并成实体跨度。
对齐这一步写错不会报错,只会让 F1 一直上不去,这是实体关系源码里最常见的隐性 bug。
4. 实体关系抽取两种主流实现:Pipeline 关系分类与 CasRel 联合抽取
4.1 Pipeline 做法:前提是已经拿到 NER 实体列表
Pipeline 是先把 NER 模型跑一遍,拿到句中所有实体,再对实体两两组合做关系分类。构建输入时把原句和两个实体拼在一起,交给关系分类器:
def build_relation_input(sentence, head, tail, tokenizer): # “与”作为触发词,帮助模型判断两个实体的语义角色 text = sentence + "[SEP]" + head + "与" + tail return tokenizer( text, max_length=160, truncation=True, padding="max_length", return_tensors="pt", )关系分类头常用多标签二分类。一个实体对可能同时有“剂量为”和“用法为”两种关系,比如“氨氯地平 5mg 口服”,所以不要用单标签 Softmax,改用 Sigmoid 加BCEWithLogitsLoss。
import torch import torch.nn as nn class RelationClassifier(nn.Module): def __init__(self, hidden_size, num_relations): super().__init__() self.classifier = nn.Linear(hidden_size, num_relations) def forward(self, pooled): logits = self.classifier(pooled) return torch.sigmoid(logits)Pipeline 实现简单、每个阶段都可独立调优,但有一个绕不开的问题:实体对复杂度是 O(n²)。一句话 10 个实体就是 90 个候选对,其中绝大多数没有关系。实际工程中要先用共现规则过滤,比如症状和疾病必须出现在同一分句,才进入关系分类器。
4.2 联合抽取:CasRel 一步建模三元组
CasRel 是联合抽取中常用的一类思路,核心是“先抽主体,再根据主体抽客体和关系”。它的解码分两步,第一步找到句子里的主体实体,第二步对每个主体,在每种预定义关系下预测客体实体的头尾指针。
# 伪代码:CasRel 的两个解码阶段 # encoder_out: (batch, seq_len, hidden) sub_head_logits = sub_head_layer(encoder_out) # 主体起始位置 sub_tail_logits = sub_tail_layer(encoder_out) # 主体结束位置 for rel in predef_relations: # 把主体表示拼接到每个 token 上 sub_emb = extract_subject_embedding(encoder_out, sub_span) obj_head = obj_head_layers[rel](sub_emb) # 客体起始位置 obj_tail = obj_tail_layers[rel](sub_emb) # 客体结束位置训练时主体抽取和客体指针预测共用同一个编码器,总损失是主体头尾损失与每种关系客体头尾损失之和。推理时先解码主体,再对每个主体逐关系解码客体。所谓联合,本质是客体预测必须依赖主体表示,而不是把 NER 和关系分类两个模型简单串联。
CasRel 能解决重叠三元组问题。同一个主体“原发性高血压”,可以同时和“头晕”组成“表现为”关系,又和“氨氯地平”组成“治疗使用”关系,这在 Pipeline 里需要额外处理,在联合模型中是天然支持的结构。
4.3 两种方式的取舍:数据量决定选型
| 对比维度 | Pipeline 关系分类 | CasRel 联合抽取 |
|---|---|---|
| 实现成本 | 低,两个阶段可分开训练 | 高,解码逻辑和标签构造都要单独写 |
| 误差传播 | NER 错误会传导给关系分类 | 主体错误会影响客体,但可联合优化 |
| 重叠三元组 | 需要用规则补偿 | 天然支持 |
| 小数据表现 | 更稳 | 容易过拟合或欠收敛 |
我一般这样判断:标注样本在 2000 条以下时优先 Pipeline,先把 NER 和关系分类两个模块各自跑通,确认数据质量后再考虑联合抽取;标注量到 5000 条以上且关系重叠明显,再迁移到 CasRel。不要一开始就上复杂模型,医学标注样本错一条,后期排错的成本远高于模型结构的收益。
5. 验证和部署:Span 级评估与人工复审边界
5.1 实体评估用 Span 精确匹配,不数 Token
很多项目在 NER 评估时数 token 命中数,这个做法在医学场景会掩盖实体边界错误。“左房”和“左心房”只差一个字符,token 级评测会把“左房”识别错但“左”命中当成部分正确,实体级 F1 才能反映真实情况。评估代码按实体跨度精确匹配:
def evaluate_entities(pred_spans, gold_spans): # span 格式统一为 (start, end, entity_type) pred_set = set(pred_spans) gold_set = set(gold_spans) hit = len(pred_set & gold_set) precision = hit / max(1, len(pred_set)) recall = hit / max(1, len(gold_set)) f1 = 2 * precision * recall / max(1e-9, precision + recall) return precision, recall, f1关系抽取的评估在此基础上多做一层约束:三元组中头实体、尾实体、关系类型三者全部预测正确才算一个有效三元组。只对关系类型做分类评估是有问题的,两个实体边界都不对,关系类型再对也没有业务价值。
实践中还会遇到一个后处理问题:模型输出“B-疾病 I-症状”这种非法标签序列。要在还原 span 时加规则,I 标签和 B 标签类型不一致时,将 I 视为新实体起始或直接置为 O,否则实体跨度会横跨两种类型造成脏数据。
提示:关系分类的阈值不要只看整体 F1,要看精确率曲线。医学场景宁可漏掉三元组,也不能给出错误三元组,所以阈值应倾向高精确率一侧。
5.2 部署阶段注意两个落地点
模型训练完成后,服务端部署时我优先做 ONNX 导出。导出时固定max_length=128,batch 维度保持动态,同时把label2id.json和 tokenizer 配置一并打包。运行时如果输入长度超过 128,先按句号分句再进入模型,而不是暴力截断,否则跨句关系会丢失。
医学实体的上线不能全自动。模型输出要带置信度字段,低于阈值的三元组需要进入人工复审队列。这个队列不需要医生参与判断,可以由医学背景的标注人员快速确认,只有高置信度结果直接进入知识库。这样既保证模型能跑起来,也把最终风险控制在业务可接受范围内。
最后补一条实操建议:两个三元组头尾实体相同但关系不同时,保留置信度更高并且与药品说明书表述一致的那条,其余放入人工队列,不要直接用 NMS 式去重。实体关系抽取的线上效果往往差在这类细节上,而不是模型结构本身。
本文还有配套的精品资源,点击获取