简介:这份PDF面向医疗NLP方向的学习者与开发者,聚焦电子病历实体关系抽取这一具体任务,讲解如何借助PyTorch框架与BioBERT预训练模型完成迁移学习落地。内容从电子病历分析价值、实体关系抽取任务定义切入,梳理传统规则与机器学习方法的局限,再展开BioBERT与迁移学习原理,并覆盖环境搭建、数据收集与清洗、数据集划分、模型加载与微调等完整环节,配有可参考的代码片段。资源包为单个PDF文件,约273KB,轻量便于随时查阅,适合具备一定深度学习基础、希望把预训练语言模型应用到医学文本场景的读者。目前已有129人学习,可作为医疗文本挖掘入门与实战的参考材料。
1. 医疗文本分析实战:从电子病历里把实体和关系一起抽出来
一份三甲医院的出院小结,平均 800 到 1500 字,里面塞着诊断、症状、手术、用药、检查指标、时间线。人工读一份要三到五分钟,一个科室一天出上百份,信息科想把这些结构化进数据库,靠人肉标注根本不现实。电子病历实体关系抽取要解决的就是这件事:把「2型糖尿病」「二甲双胍」「2023-06-12」这些实体认出来,再把「患者服用二甲双胍控制血糖」这种关系连起来。通用 NLP 模型直接拿来用,在「主诉」「现病史」这种半结构化文本上准确率会掉一大截,因为病历里的缩写、否定、时间嵌套和口语化描述跟新闻语料完全不是一个分布。PyTorch 加 BioBERT 的迁移学习路线,是目前在中文电子病历上落地成本比较低、复现路径比较清晰的一种做法。这篇笔记按「模型怎么选、数据怎么处理、训练怎么跑、坑在哪」的顺序讲,适合有 PyTorch 基础、想把手头病历数据跑出第一版抽取结果的工程师。
2. 为什么是 BioBERT 加迁移学习:选型逻辑与数据准备
2.1 通用 BERT 在病历文本上到底差在哪
病历文本有几个很明显的特征。第一是实体密度高,一句话里可能同时出现三个症状、两个药名和一个时间,通用 BERT 的注意力头没有针对这种密集实体分布做过预训练。第二是缩写和同义表达多,「房颤」「心房颤动」「AF」指的是同一件事,通用词表里这些 token 的向量距离比较远。第三是否定和不确定表达频繁,「未见明显异常」「不排除」「考虑」这些词直接决定实体是否成立,通用模型容易把它们当成普通修饰。
BioBERT 的做法是在生物医学语料上继续做 MLM 预训练,让词向量和注意力分布往医学领域偏移。迁移学习在这里的价值不是「换个模型」,而是把预训练阶段学到的医学术语表示,迁移到实体关系抽取这个下游任务上。常见做法是冻结底层若干层,只微调顶层加任务头,这样在小样本病历数据上不容易过拟合。
提示:如果手头病历数据少于 2000 条标注样本,优先考虑冻结前 8 层,只训练后 4 层加分类头,学习率设到 2e-5 以下。
2.2 病历数据的清洗与标注格式对齐
原始电子病历导出后通常是 CSV 或 JSON,字段包括主诉、现病史、既往史、诊断、用药。第一步是把这些字段拼成模型输入,同时保留字段边界标记,因为「既往史」里的实体和「现病史」里的实体在关系抽取时权重不一样。
import json import re def clean_emr_record(record): # 去掉脱敏占位符和多余空白,保留字段边界标记 fields = ["chief_complaint", "present_illness", "past_history", "diagnosis", "medication"] segments = [] for f in fields: text = record.get(f, "") text = re.sub(r"\[MASK\]|XX+", "", text) # 去掉脱敏残留 text = re.sub(r"\s+", "", text) # 病历里空格无意义,直接去掉 if text: segments.append(f"[{f}]{text}") return "".join(segments) def build_bio_schema(entities, relations): # 转成 BIO 标注,实体边界用 B-/I- 区分 tags = ["O"] * len(entities) for ent in entities: start, end, label = ent["start"], ent["end"], ent["label"] tags[start] = f"B-{label}" for i in range(start + 1, end): tags[i] = f"I-{label}" return tagsclean_emr_record里去掉[MASK]和连续X是因为脱敏工具经常留下这些占位符,模型会把它们当成真实 token 学进去。字段边界标记[chief_complaint]这种写法是给模型一个位置信号,让它在做关系判断时知道当前实体属于哪个段落。build_bio_schema输出的是标准 BIO 序列,实体关系抽取通常先做 NER 再做关系分类,两步可以共享同一个编码器。
2.3 标注样本的划分与类别不平衡处理
病历数据里「症状」和「检查」类实体占大多数,「手术」和「家族史」类实体可能只占 5%。直接训练会让模型偏向多数类。常见做法是在损失函数里给少数类加权,权重按类别频率的倒数来算,但不要超过 10 倍,否则少数类会过拟合。
| 实体类别 | 样本占比 | 建议损失权重 |
|---|---|---|
| 症状 | 38% | 1.0 |
| 检查 | 27% | 1.0 |
| 用药 | 18% | 1.5 |
| 诊断 | 12% | 2.0 |
| 手术 | 5% | 4.0 |
划分训练集和验证集时,要按患者 ID 划分,不能按句子随机划分。同一个患者的不同段落如果同时出现在训练集和验证集,验证指标会虚高,这是病历数据里最容易翻车的地方之一。
3. PyTorch 环境搭建与 BioBERT 加载的完整命令
3.1 用 conda 建一个干净的 PyTorch 环境
PyTorch 版本和 CUDA 版本对不上是新手最常踩的坑。先确认显卡驱动支持的 CUDA 上限,再选对应的 PyTorch 安装命令。下面这套在 Ubuntu 22.04 加 RTX 3090 上验证过。
conda create -n emr_biobert python=3.9 -y conda activate emr_biobert # 按官方矩阵选 cuda 11.8 对应的 pytorch 2.0.1 pip install torch==2.0.1 torchvision==0.15.2 --index-url https://download.pytorch.org/whl/cu118 pip install transformers==4.30.2 datasets==2.12.0 seqeval==1.2.2 pip install scikit-learn pandas openpyxl--index-url指定 PyTorch 官方 wheel 源,避免 pip 默认源里版本不全。transformers选 4.30 是因为 BioBERT 的 tokenizer 在这个版本上加载中文医学词表比较稳定,再新的版本有些接口改名了,老代码直接跑会报AutoTokenizer参数错误。
注意:如果机器上没有独立显卡,把
cu118换成cpu,但训练时间会从小时级变成天级,建议先在小样本上验证流程。
3.2 加载 BioBERT 并接一个实体关系联合抽取头
下面这段代码把 BioBERT 编码器和两个任务头接在一起:一个 token 分类头做 NER,一个关系分类头做关系判断。关系分类用的是实体首 token 的向量拼接。
import torch import torch.nn as nn from transformers import AutoModel, AutoConfig class BioBERTForEMR(nn.Module): def __init__(self, model_name, num_ner_labels, num_rel_labels): super().__init__() self.config = AutoConfig.from_pretrained(model_name) self.bert = AutoModel.from_pretrained(model_name) hidden = self.config.hidden_size # NER 头:每个 token 输出类别 self.ner_head = nn.Linear(hidden, num_ner_labels) # 关系头:两个实体向量拼接后分类 self.rel_head = nn.Sequential( nn.Linear(hidden * 2, hidden), nn.ReLU(), nn.Dropout(0.1), nn.Linear(hidden, num_rel_labels) ) def forward(self, input_ids, attention_mask, token_type_ids=None): outputs = self.bert( input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids ) sequence_output = outputs.last_hidden_state ner_logits = self.ner_head(sequence_output) return ner_logits, sequence_outputAutoModel加载的是 BioBERT 的编码器权重,不包含预训练时的 MLM 头,因为下游任务不需要。ner_head是一个线性层,把 768 维隐状态映射到实体类别数。rel_head接收的是两个实体首 token 的向量拼接,所以输入维度是hidden * 2。实际训练时关系头单独走一个 batch,先抽完实体再配对。
3.3 训练循环里的三个关键参数
from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup optimizer = AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) num_training_steps = len(train_loader) * 5 scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * num_training_steps), num_training_steps=num_training_steps ) for epoch in range(5): model.train() for batch in train_loader: outputs = model(**batch) loss = ner_loss + 0.5 * rel_loss # 关系损失降权,避免压过 NER loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() optimizer.zero_grad()lr=2e-5是 BioBERT 微调的常用起点,再大容易把预训练权重冲掉。weight_decay=0.01对线性层生效,对 LayerNorm 和 bias 不生效,这是 HuggingFace 的默认行为。clip_grad_norm_设 1.0 是防止病历里长文本导致的梯度爆炸。关系损失乘 0.5 是因为关系分类样本比 NER 少,权重大了会让 NER 指标掉。
4. 实体关系抽取的联合解码与评估指标
4.1 从 logits 到实体和关系对
NER 输出的是每个 token 的类别概率,需要用 Viterbi 解码保证 BIO 序列合法,比如I-症状前面不能直接跟O。关系头输出的是实体对之间的关系概率,取最大且超过阈值的作为最终关系。
import numpy as np from seqeval.metrics import classification_report def decode_ner(logits, id2label): preds = np.argmax(logits, axis=-1) labels = [[id2label[p] for p in seq] for seq in preds] return labels def extract_relations(rel_logits, entities, id2rel, threshold=0.5): results = [] for i, ent1 in enumerate(entities): for j, ent2 in enumerate(entities): if i == j: continue prob = torch.softmax(rel_logits[i][j], dim=-1) score, idx = torch.max(prob, dim=-1) if score.item() > threshold: results.append((ent1, ent2, id2rel[idx.item()], score.item())) return resultsdecode_ner里用seqeval的classification_report可以直接输出每个实体类别的 P/R/F1,比手写准确率计算靠谱。extract_relations里的阈值 0.5 是起点,实际调的时候看验证集上的 P/R 曲线,如果召回太低就降到 0.4,精确率太低就升到 0.6。
4.2 评估时容易忽略的实体边界问题
病历里的实体边界经常有歧义,比如「左肺下叶」是一个解剖部位还是「左肺」加「下叶」。标注规范里如果没写清楚,模型学出来的边界会和人工评估对不上。常见做法是在评估脚本里加一个宽松匹配模式,只要实体类型对且重叠超过 50% 就算正确,同时单独统计严格匹配的 F1,两个指标一起看。
| 评估模式 | 症状 F1 | 用药 F1 | 关系 F1 |
|---|---|---|---|
| 严格匹配 | 0.81 | 0.76 | 0.68 |
| 宽松匹配 | 0.89 | 0.84 | 0.74 |
严格匹配和宽松匹配差 8 个点左右是正常的,如果差太多说明标注边界一致性有问题,需要回头检查标注规范。
5. 避坑与排查:病历抽取里最常见的五个翻车点
5.1 验证集指标很高但上线效果差
现象:验证集 F1 到 0.9,换一批新病历跑出来只有 0.6。原因:训练集和验证集按句子随机划分,同一个患者的相似表述同时出现在两边,模型记住了患者特有的表达方式。解决:按患者 ID 分组划分,确保同一个患者的任何段落只出现在一个集合里。如果患者 ID 拿不到,用病历号加就诊时间做近似分组。
5.2 模型把否定词后面的实体也抽出来了
现象:「未见肝脾肿大」里的「肝脾肿大」被抽成症状实体。原因:NER 头只学了实体边界,没学否定修饰。解决:在输入里加否定标记,或者在 NER 后面接一个否定检测分类器,对每个实体判断是否被否定词支配。否定词表可以先用规则匹配「未见」「不排除」「无明显」这些高频词。
5.3 训练到第三轮 loss 突然变 NaN
现象:前两轮 loss 正常下降,第三轮开始出现 NaN。原因:病历里有超长文本,超过 512 token 被截断后,某些 batch 里全是短文本,梯度累积异常。解决:在 dataloader 里做长度分桶,每个 batch 内文本长度接近,同时把max_grad_norm从 1.0 降到 0.5。另外检查学习率有没有被 scheduler 意外调大。
5.4 关系抽取把「患者」和「医生」也当成实体对
现象:关系结果里出现大量「患者-医生」这种无意义关系。原因:关系分类头的负样本里没有包含这类实体对,模型没学过它们应该被归为「无关系」。解决:在构造关系训练数据时,显式加入负样本,负样本和正样本比例控制在 2:1 到 3:1 之间。负样本要从同一段落里随机采实体对,不能从其他段落采。
5.5 换一台机器跑推理结果不一致
现象:同一份病历,开发机和服务器上输出的实体数量不一样。原因:PyTorch 版本不同导致softmax数值精度有差异,或者 tokenizer 的do_lower_case设置不一致。解决:固定torch.manual_seed,推理时用model.eval()加torch.no_grad(),tokenizer 的配置从同一个tokenizer_config.json加载,不要手写参数。
6. 把模型导出 ONNX 并压到 200MB 以内的实操技巧
训练完的 BioBERT 加两个头,PyTorch 权重文件大概 400MB 出头。如果要在内网低配机器上跑推理,导出 ONNX 再量化是比较稳的路子。下面这段导出脚本把 NER 和关系头分开导出,因为关系头的输入是动态实体对,ONNX 对动态 shape 支持有限,分开导更省心。
import torch from torch.onnx import export model.eval() dummy_ids = torch.randint(0, 1000, (1, 128)).long() dummy_mask = torch.ones(1, 128).long() # 只导出编码器加 NER 头,关系头单独处理 class NERExportWrapper(torch.nn.Module): def __init__(self, model): super().__init__() self.bert = model.bert self.ner_head = model.ner_head def forward(self, input_ids, attention_mask): out = self.bert(input_ids=input_ids, attention_mask=attention_mask) return self.ner_head(out.last_hidden_state) wrapper = NERExportWrapper(model) export( wrapper, (dummy_ids, dummy_mask), "biobert_ner.onnx", input_names=["input_ids", "attention_mask"], output_names=["ner_logits"], dynamic_axes={"input_ids": {0: "batch", 1: "seq"}, "attention_mask": {0: "batch", 1: "seq"}}, opset_version=13 )导出后用onnxruntime的量化工具做动态量化,权重从 FP32 压到 INT8,文件大小能降到 100MB 左右,推理速度提升 2 到 3 倍。量化后要在验证集上重新跑一遍 F1,如果掉超过 2 个点,就只量化编码器的中间层,保留 NER 头为 FP32。
from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( "biobert_ner.onnx", "biobert_ner_int8.onnx", weight_type=QuantType.QInt8 )QuantType.QInt8比QUInt8在医学文本上更稳,因为隐状态里有负数。量化完用onnxruntime.InferenceSession加载,对比 PyTorch 输出,最大绝对误差控制在 1e-3 以内算正常。
我自己的习惯是每次改完标注规范或换数据源,先跑一遍 200 条的小样本过拟合测试,loss 能降到 0.1 以下再上全量。这个习惯帮我省过至少两次通宵排查。希望帮到你。
本文还有配套的精品资源,点击获取