news 2026/9/15 6:09:33

中文医学文本实体关系抽取:从BIO标注到BERT与CasRel实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
中文医学文本实体关系抽取:从BIO标注到BERT与CasRel实战

简介:这是一个面向中文医学文本实体关系抽取的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": "治疗使用"} ] }

headtail是实体列表下标,这样关系标注复用实体位置,不需要在关系里再写一遍实体文本。中文医学实体关系抽取常用关系类型如下:

头实体关系类型尾实体句子示例
疾病表现为症状原发性高血压 -> 头晕
疾病治疗使用药物原发性高血压 -> 氨氯地平
药物剂量为剂量氨氯地平 -> 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_rate2e-5 ~ 3e-5过大导致灾难性遗忘
max_length128 ~ 256超长病历先分句再处理
batch_size16 ~ 32由显存决定,梯度累积可兜底
num_train_epochs5 ~ 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 encoding

offset_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 式去重。实体关系抽取的线上效果往往差在这类细节上,而不是模型结构本身。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 6:08:34

微信小程序+SSM高校体育场预约系统开发实战指南

简介&#xff1a;微信小程序高校体育场管理系统是基于SSM框架开发的完整课程设计源码包&#xff0c;面向高校软件工程、计算机相关专业学生及需要完成微信小程序Java后端项目的开发者。系统覆盖场地预约、扫码签到、设施维护、活动发布、健康数据分析、会员积分、智能安防监控及…

作者头像 李华
网站建设 2026/9/15 6:07:46

XSS与文件上传漏洞:原理分析、绕过技巧与靶场实战

搞安全的同行应该都清楚&#xff0c;XSS跨站脚本和文件上传漏洞这两个名字&#xff0c;基本是Web渗透测试里“出镜率”最高的老面孔了。一个是在浏览器端玩“借刀杀人”&#xff0c;一个是在服务端玩“狸猫换太子”&#xff0c;单拎出来任何一个都能写出一堆文章。但真正把它们…

作者头像 李华
网站建设 2026/9/15 6:07:43

基于Matlab的声纹识别系统开发与优化实践

1. 项目概述&#xff1a;语音识别领域的GUI实践去年接手一个安防项目时&#xff0c;客户要求在不增加硬件成本的情况下实现门禁系统的语音身份验证。当时第一反应就是基于Matlab构建说话人识别系统&#xff0c;因为它的信号处理工具箱和GUI开发环境能大幅缩短开发周期。这个系统…

作者头像 李华
网站建设 2026/9/15 6:07:23

MATLAB实现3GPP TR 38.901信道模型的完整工程实践

简介&#xff1a;本资源是面向无线通信研究者、高校师生及5G/4G系统工程师的MATLAB信道建模工具集&#xff0c;聚焦3GPP标准下的E-UTRA与NR信道仿真&#xff0c;解决实际通信链路建模、衰落特性分析与系统性能预评估等核心问题。压缩包共20个文件&#xff0c;主体为15个MATLAB函…

作者头像 李华
网站建设 2026/9/15 6:06:55

国密人脸识别门禁选型落地指南:从SM算法到密评合规

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/15 6:06:19

字元组合实战:从CNSH四步法到高可用短码生成器

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华