ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PyTorch微调BioBERT:电子病历实体关系抽取迁移学习实战

PyTorch微调BioBERT:电子病历实体关系抽取迁移学习实战 简介这份PDF面向医疗NLP方向的学习者与开发者聚焦电子病历实体关系抽取这一具体任务讲解如何借助PyTorch框架与BioBERT预训练模型完成迁移学习落地。内容从电子病历分析价值、实体关系抽取任务定义切入梳理传统规则与机器学习方法的局限再展开BioBERT与迁移学习原理并覆盖环境搭建、数据收集与清洗、数据集划分、模型加载与微调等完整环节配有可参考的代码片段。资源包为单个PDF文件约273KB轻量便于随时查阅适合具备一定深度学习基础、希望把预训练语言模型应用到医学文本场景的读者。目前已有129人学习可作为医疗文本分析入门与迁移学习实践的参考材料。1. 医疗文本分析实战从一份 PDF 标题说起电子病历实体关系抽取到底难在哪电子病历里的实体关系抽取说白了就是从「患者主诉上腹隐痛 3 天既往高血压病史」这种半结构化文本里把「上腹隐痛」标成症状、「高血压」标成疾病再判断两者之间是「既往史」还是「伴随症状」。这件事在通用新闻语料上早就被 BERT 类模型刷到很高分数但一换到电子病历F1 经常掉十几个点。原因不玄学病历里缩写满天飞「房颤」「T2DM」「BP 160/95」否定和不确定表达密集「无发热」「不排除肺炎」实体边界模糊「左肺下叶」到底算部位还是解剖结构而且标注数据极度稀缺一个科室能攒出两三千条高质量标注就算不错。这份标题里的 PyTorch、BioBERT、迁移学习三个词恰好对应了落地时的三条主线用 PyTorch 搭训练管线用 BioBERT 做领域预训练权重初始化用迁移学习把通用医学语义搬到只有几百条标注的目标科室数据上。它适合两类人一类是手上已经有几百到几千条标注病历、想跑通一个能上线的抽取模型的算法工程师另一类是刚接触 NLP、想找一个真实领域练手的同学。下面我按自己踩过的顺序把选型、数据、训练、避坑、验证一条条讲清楚。2. 迁移学习方案选型BioBERT 到底比通用 BERT 强在哪什么时候不值得换2.1 先搞清楚 BioBERT 和通用 BERT 的差异来源BioBERT 不是新架构它和 BERT-Base 的网络结构完全一致都是 12 层 Transformer、768 隐层、12 个注意力头。差别只在预训练语料它在 PubMed 摘要和 PMC 全文上继续做了 MLM 预训练。这意味着它学到的词向量里「infarction」「carcinoma」这类医学术语的上下文表示比通用 BERT 更贴近临床语义。迁移学习在这里的价值不是「换个模型名字」而是让下游只有几百条标注的抽取任务不必从随机初始化开始学医学词汇。但要注意一个反直觉结论如果你的病历以中文为主直接拿英文 BioBERT 权重去微调效果往往不如中文通用 BERT。因为 BioBERT 的词表是英文 WordPiece中文会被拆成单字甚至 UNK医学语义根本传不过来。常见做法是找中文医学预训练权重或者用中文 BERT 在自己的脱敏病历上再做一轮领域自适应预训练DAPT这一步才是中文场景下真正的迁移学习。2.2 三种迁移策略的取舍策略做法标注量需求适用场景特征提取冻结 BERT只训分类头很少几百条快速验证、算力紧张全量微调解冻全部参数一起训几千条以上目标域和预训练域接近分层解冻先冻底层逐步解冻顶层一千到几千条标注有限又想提升我一般会先跑特征提取拿到一个 baseline再上全量微调对比。如果全量微调反而更差八成是学习率太大把预训练权重冲垮了这时候把 BERT 部分的学习率设成分类头的十分之一通常能救回来。2.3 环境搭建PyTorch 与 CUDA 版本对齐环境这一步翻车的人最多。PyTorch 和 CUDA 版本必须对齐否则torch.cuda.is_available()返回 False你还以为是自己代码写错了。先确认显卡驱动支持的 CUDA 上限再去 PyTorch 官网找对应组合。用 conda 装比 pip 省心因为它会把 cudatoolkit 一起解决。# 创建独立环境避免和系统里的其他框架打架 conda create -n medner python3.10 -y conda activate medner # 按显卡驱动支持的 CUDA 版本选择这里以 CUDA 11.8 为例 # 具体命令以 PyTorch 官方安装页为准不要照抄过时版本号 conda install pytorch torchvision torchaudio pytorch-cuda11.8 -c pytorch -c nvidia -y # 验证 GPU 是否真的可用 python -c import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.get_device_name(0))逻辑说明先隔离环境再装带 CUDA 支持的 PyTorch最后用一行命令确认。参数上pytorch-cuda11.8要和驱动匹配驱动太老就降 CUDA 版本。如果输出是False先查驱动版本nvidia-smi再查装的是不是 CPU 版。这一步没通后面所有训练都是白费。3. 数据准备电子病历的标注格式、脱敏与 BIO 标签转换3.1 病历数据的三个预处理动作拿到原始病历第一件事不是写模型是脱敏。姓名、身份证、电话、住院号必须替换成占位符否则数据没法流转。第二件事是分句病历里一句话可能塞了主诉、现病史、既往史按标点和语义切分后实体关系才清晰。第三件事是统一术语把「心梗」和「心肌梗死」映射到同一个标准词否则模型会当成两个实体。标注格式我推荐用 JSONL每行一条样本包含文本和实体列表实体带起止偏移和类型。关系抽取再单独一层用头实体、尾实体、关系类型三元组表示。这样实体和关系可以分开迭代不会互相拖累。3.2 把标注转成 BIO 序列序列标注任务需要把字符级偏移转成 BIO 标签序列。下面这个脚本处理单条样本注意中文按字切分英文和数字按 token 切分时要保持偏移对齐。def char_spans_to_bio(text, entities): 把实体偏移转成逐字的 BIO 标签。 text: 原始字符串 entities: [{start:0,end:3,type:DISEASE}, ...] labels [O] * len(text) for ent in entities: s, e, t ent[start], ent[end], ent[type] # 边界检查防止标注越界导致索引错位 if s 0 or e len(text) or s e: continue labels[s] fB-{t} for i in range(s 1, e): labels[i] fI-{t} return labels # 示例 text 患者无发热既往高血压病史 ents [{start: 3, end: 5, type: SYMPTOM}, {start: 8, end: 12, type: DISEASE}] print(list(zip(text, char_spans_to_bio(text, ents))))逻辑说明先全部填O再按实体区间覆盖成B-和I-。参数上start是闭区间起点end是开区间终点这是最常见的约定但不同标注工具可能相反转换前一定核对。边界检查那几行是后悔药标注数据里偶尔有脏数据不检查会直接抛异常中断整个训练。3.3 划分数据集时别按句子随机切血泪经验如果同一个患者的病历被随机切到训练集和验证集验证分数会虚高因为模型见过这个患者的表达习惯。正确做法是按患者 ID 划分保证同一个患者的所有记录只出现在一个集合里。这一步不做上线后指标会打脸。提示脱敏后的数据也要控制访问权限病历属于敏感信息本地训练完及时清理中间缓存文件。4. PyTorch 微调 BioBERT训练循环、超参设置与显存优化4.1 模型结构BERT 加一层序列标注头实体抽取用 token classification 头关系抽取可以在这之上再接一个分类器把两个实体的向量拼接后判断关系。先跑通实体再叠关系不要一上来就联合训练出了问题很难定位。import torch import torch.nn as nn from transformers import AutoModel, AutoConfig class BioBERTNER(nn.Module): def __init__(self, model_name, num_labels, dropout0.1): super().__init__() self.bert AutoModel.from_pretrained(model_name) hidden self.bert.config.hidden_size self.dropout nn.Dropout(dropout) # 线性层把 768 维映射到标签数 self.classifier nn.Linear(hidden, num_labels) def forward(self, input_ids, attention_mask, token_type_idsNone): out self.bert(input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids) seq out.last_hidden_state # [B, L, 768] seq self.dropout(seq) logits self.classifier(seq) # [B, L, num_labels] return logits逻辑说明AutoModel只取编码器输出不带头。last_hidden_state是每个 token 的上下文向量接 dropout 再进线性层。参数上num_labels等于 BIO 标签总数比如 3 类实体就是 1 3×2 7 个标签。dropout在标注数据少时调到 0.2 到 0.3 能缓解过拟合。4.2 超参怎么设学习率是最大的坑BERT 微调的学习率通常在 2e-5 到 5e-5分类头可以给到 1e-3。如果全量微调效果差先降学习率再考虑冻结底层。batch size 受显存限制8 到 16 是常见起点显存不够就用梯度累积模拟大 batch。from transformers import AdamW, get_linear_schedule_with_warmup # 分组设置学习率BERT 部分小分类头大 bert_params list(model.bert.named_parameters()) head_params list(model.classifier.named_parameters()) optimizer AdamW([ {params: [p for _, p in bert_params], lr: 2e-5}, {params: [p for _, p in head_params], lr: 1e-3}, ], weight_decay0.01) # 前 10% 步数做 warmup避免一开始就把预训练权重冲垮 total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps)逻辑说明参数分组是微调的关键技巧让预训练权重和随机初始化的头用不同学习率。weight_decay设 0.01 是常规值太大反而伤性能。warmup 那 10% 步数让学习率从 0 线性升到设定值训练更稳。如果 loss 在前几百步就爆掉八成是没做 warmup 或学习率太高。4.3 显存不够时的三个手段第一把max_length从 512 降到 256病历句子通常没那么长。第二开混合精度训练torch.cuda.amp能省将近一半显存。第三用梯度累积accumulation_steps4等效于 batch size 翻四倍。这三个手段可以叠加我一般先开混合精度再调长度最后才动累积。scaler torch.cuda.amp.GradScaler() for batch in train_loader: optimizer.zero_grad() with torch.cuda.amp.autocast(): logits model(**batch) loss criterion(logits.view(-1, num_labels), batch[labels].view(-1)) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()逻辑说明autocast自动把部分运算降到 fp16GradScaler防止梯度下溢。注意 loss 计算前要把 logits 和 labels 展平忽略-100的 padding 位置。这套写法在单卡 12G 显存上跑 BERT-Base 基本够用。5. 避坑与排查迁移学习在病历场景最容易翻车的五个地方5.1 验证集 F1 很高上线就崩现象本地验证 F1 到 0.9换一批新病历掉到 0.6。原因按句子随机划分导致同一患者泄漏或者验证集和训练集来自同一科室、同一时间段分布太像。解决按患者 ID 和时间划分留一个跨科室或跨时间段的测试集指标以它为准。5.2 实体边界总是差一个字现象「左肺下叶」被切成「肺下叶」「2 型糖尿病」漏掉「2 型」。原因BIO 标注里 B 和 I 的转换没学好或者分词和字符偏移没对齐。解决检查标注转换脚本中文按字切分最稳训练时加 CRF 层能约束标签转移减少非法序列。5.3 否定和不确定表达被当成阳性现象「无发热」抽出「发热」「不排除肺炎」抽出「肺炎」。原因模型只学了实体识别没学否定检测。解决把否定词作为特征拼进实体表示或者单独训一个否定分类器在实体后处理阶段过滤。这一步不做临床可用性基本为零。5.4 学习率没调好loss 震荡不收敛现象loss 在 2 到 8 之间来回跳几个 epoch 都不降。原因BERT 部分学习率太大或者 batch size 太小导致梯度噪声大。解决把 BERT 学习率降到 1e-5加 warmup增大 batch 或用梯度累积。还不行就冻结底层六层先训头。5.5 中文病历用英文 BioBERT效果不如通用中文 BERT现象换 BioBERT 后指标反而降了。原因词表不匹配中文被拆成单字医学语义没迁移过来。解决改用中文预训练权重或在脱敏病历上做领域自适应预训练。判断方法很简单看 tokenizer 对「心肌梗死」的输出如果是一堆单字加 UNK就别指望它。注意每次改动只动一个变量改完记录指标。同时改数据和超参出了问题根本不知道是谁的锅。6. 进阶技巧用对抗训练和模型导出把抽取效果再抬一档跑通基础版之后想再往上抬点分数我常用的两个手段是 FGM 对抗训练和 ONNX 导出。FGM 在 embedding 上加扰动让模型对输入噪声更鲁棒病历里错别字和缩写多这个技巧收益明显。实现上就是在反向传播前对 embedding 做一次扰动再算一次梯度累加。class FGM: def __init__(self, model, eps1.0): self.model model self.eps eps self.backup {} def attack(self): for name, param in self.model.named_parameters(): if param.requires_grad and embedding in name: self.backup[name] param.data.clone() norm torch.norm(param.grad) if norm ! 0: r_at self.eps * param.grad / norm param.data.add_(r_at) def restore(self): for name, param in self.model.named_parameters(): if name in self.backup: param.data self.backup[name] self.backup {}逻辑说明attack在梯度方向上加扰动restore还原参数。参数eps控制扰动幅度1.0 是常见起点太大反而伤性能。用法是正常反向传播后调attack再反向传播一次累加梯度最后restore并更新。这套写法在标注数据少的时候提升通常有一到两个点。导出 ONNX 是为了部署PyTorch 模型直接上生产推理慢转成 ONNX 后用 ONNX Runtime 能快不少。导出时注意把动态轴设好batch 和序列长度都要动态否则换个输入长度就报错。验证方法是导出后用同一批数据对比 PyTorch 和 ONNX 的输出误差在 1e-4 以内才算成功。最后说个我自己的习惯每次实验都固定随机种子把配置存成 yaml指标写进表格。迁移学习这行玄学和运气占一部分但可复现的实验记录能让你在翻车时快速定位是哪次改动引入的。病历数据敏感训练完记得清理缓存和临时文件。希望帮到你。本文还有配套的精品资源点击获取
返回列表