ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

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

中文医学文本实体关系抽取:从BIO标注到BERT与CasRel实战 简介这是一个面向中文医学文本实体关系抽取的Python实现源码包适合自然语言处理方向的学生用于课程设计、期末大作业或项目入门也适合医学信息抽取初学者参考学习。资源共13个文件以12个Python脚本和1个说明文档为主涵盖实体识别与关系抽取主流程、模型定义、数据处理、工具函数及Flask接口服务等模块代码结构清晰便于按需调用和二次开发。包体大小约28KB轻量完整并配有使用说明下载后即可对照运行。目前已有514人学习/下载适合需要完整可运行项目样例的读者直接使用。从内容预览看项目不仅包含核心抽取算法实现还提供了API服务封装与相关辅助函数可帮助理解医学文本从原始输入到结构化知识输出的完整流程也能作为后续实验扩展、参数调优或功能改造的基础框架。1. 中文医学文本实体关系抽取是什么为什么用 Python 实现医学文本里大量信息是非结构化的。“反复头痛伴心悸一周诊为原发性高血压予氨氯地平5mg”只做实体识别得到头痛、原发性高血压、氨氯地平三个孤立词看不到“表现为”、“用于治疗”这类关系。把实体抽取出来并绑定语义关系就是中文医学文本实体关系抽取。它是辅助诊断、病历质控和科研数据建库的基础环节工程上可以拆成 NER 加关系分类也可以用联合抽取一步产出三元组。Python 的优势在于 Transformers 承担编码、PyTorch 承担训练再配合标注对齐与验证代码一个源码工程就能覆盖清洗、建模、评估到部署。下面按数据、训练、抽取方式三块展开。2. 医学文本预处理与实体关系标注把语料变成可训练的 BIO 序列2.1 清洗规则先处理全半角、括号和剂量单位拿到一批原始病历文本不要急着丢给模型。医学文本的噪声集中在全半角、括号和单位上“口服mg 一天两次”这种字符串如果不处理Tokenzier 会把全角“”当成独立字符破坏后续标签对齐。我一般第一道工序是字符级清洗把全角数字和标点统一成半角再把全角括号统一为半角括号最后删除不可见控制字符和连续空格。import re def clean_text(raw: str) - str: # 全角数字与标点转半角避免“”和“5”被当成两个 token full2half str.maketrans( 。, 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] fB-{ent_type} for pos in range(start 1, end): labels[pos] fI-{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_size0.2, random_state42 )关系抽取阶段最大的坑是负例爆炸。一个句子有 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_labelslen(label2id) ) training_args TrainingArguments( output_dir./ner_output, learning_rate2e-5, per_device_train_batch_size16, per_device_eval_batch_size32, num_train_epochs5, evaluation_strategyepoch, # 较新版本可改为 eval_strategyepoch save_strategyepoch, logging_dir./logs, ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_datasetdev_dataset, ) trainer.train()几个参数需要重点解释。learning_rate用 2e-5 到 3e-5BERT 微调的学习率比随机初始化模型低一个数量级调太大会破坏预训练权重。batch_size16是在 16GB 显存下比较稳的值显存不够时优先降 batch 而不是缩短序列长度。evaluation_strategyepoch表示每个 epoch 结束跑一次验证避免训练完才发现过拟合。参数推荐值说明learning_rate2e-5 ~ 3e-5过大导致灾难性遗忘max_length128 ~ 256超长病历先分句再处理batch_size16 ~ 32由显存决定梯度累积可兜底num_train_epochs5 ~ 10配合早停看验证集 F13.3 token 对齐标签和 token 序列长度对不上中文 BERT 大多按字切词但数字和英文仍可能被子词分词器再次拆分。例如“5mg”可能变成“5”和“mg”两个 token模型输出的标签长度就和字符级 BIO 序列不一致。解决方式是开启return_offsets_mapping用每个 token 在原文中的字符偏移去取标签。def tokenize_and_align_labels(text, char_labels, tokenizer, max_len128): encoding tokenizer( text, max_lengthmax_len, truncationTrue, paddingmax_length, return_offsets_mappingTrue, ) 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_length160, truncationTrue, paddingmax_length, return_tensorspt, )关系分类头常用多标签二分类。一个实体对可能同时有“剂量为”和“用法为”两种关系比如“氨氯地平 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_length128batch 维度保持动态同时把label2id.json和 tokenizer 配置一并打包。运行时如果输入长度超过 128先按句号分句再进入模型而不是暴力截断否则跨句关系会丢失。医学实体的上线不能全自动。模型输出要带置信度字段低于阈值的三元组需要进入人工复审队列。这个队列不需要医生参与判断可以由医学背景的标注人员快速确认只有高置信度结果直接进入知识库。这样既保证模型能跑起来也把最终风险控制在业务可接受范围内。最后补一条实操建议两个三元组头尾实体相同但关系不同时保留置信度更高并且与药品说明书表述一致的那条其余放入人工队列不要直接用 NMS 式去重。实体关系抽取的线上效果往往差在这类细节上而不是模型结构本身。本文还有配套的精品资源点击获取
返回列表