
简介面向自然语言处理入门者与医疗文本分析人员这份实战资源以MIMIC-IV数据库中的英文影像报告为对象演示了使用gensim训练word2vec词向量并基于PyTorch搭建Transformer网络完成文本分类的完整流程。资源共20个文件压缩包约2.96MB其中包含8个Python脚本、5个pyc编译文件、2个CSV数据文件以及bin、pth、model、txt、xlsx等模型与辅助文件类型覆盖源码、中间产物与训练结果便于按需取用。Python代码覆盖数据预处理、词向量训练、模型搭建、训练与测试等主要环节同时打包了训练好的word2vec模型和网络权重可直接加载运行或对照学习减少了重复训练的时间成本。附带的分词结果CSV与疾病编码表有利于理解数据切分方式和标签映射关系整体流程清晰模块划分合理。目前已有515人学习下载适合希望借助真实医学文本数据快速上手Transformer分类任务的开发者也适合作为相关课程设计与毕设项目的参考实现。1. 用 PyTorch 在 MIMIC-IV 上做影像报告分类word2vec 加 transformer 的组合为什么够用中文医疗 NLP 圈里有个普遍偏见一提到英文影像报告分类就默认必须上 BioBERT、ClinicalBERT 这类预训练模型。但实际上在 MIMIC-IV 这类规模几万到几十万份报告的任务上word2vec 静态词向量加上一层 transformer encoder效果通常能达到预训练模型的 90% 以上训练成本却低一个量级。这个项目就是把这条路径完整走了一遍——从 MIMIC-IV-Note 里抽出放射报告清洗分词训练 word2vec再搭建 transformer 分类器做多标签分类。适合正在找 PyTorch NLP 入门实战、又不想只跑 IMDb 情感分类的读者也适合需要快速在真实医疗文本上做基线模型的从业者。2. MIMIC-IV 报告获取与预处理从原始 CSV 到干净训练语料2.1 MIMIC-IV-Note 中定位放射报告MIMIC-IV 的文本数据在mimic-iv-note模块里下载后是 CSV 压缩包其中放射报告和出院小结混在同一个文件里。按字段note_type过滤出Radiology类型即可。注意这份数据需要先在 PhysioNet 完成 CITI 认证才能下载别想着跳过这一步没有那个physionet.org的登录态数据包根本拉不下来。import pandas as pd df pd.read_csv(mimic-iv-note-demo-2.2.csv.gz, compressiongzip) rad_df df[df[note_type] Radiology].copy() print(rad_df.shape) # 示例输出库里报告总数 print(rad_df.columns.tolist()) # 关键字段全在这这段代码做两件事读入 Note 全量表然后按note_type字段筛出放射报告。为什么不用 discharge summary因为影像报告文本结构相对固定、描述部位集中类别标签的可提取性比出院小结好得多且报告里出现的发现如肺不张、胸腔积液和分类任务目标直接对应。2.2 清洗规则医疗文本比普通文本脏得多影像报告的脏主要体现在三处一是大量非标准缩写比如 ptx 指气胸、pna 指肺炎、rll 指右肺下叶二是数字和剂量混在描述里比如 2.5 cm nodule三是报告里残留换行符和表格占位符。清洗顺序很重要先做 HTML 实体转义再归一化空白否则标签规则匹配时会踩坑。import re import html def clean_report(text: str) - str: text html.unescape(text) text text.replace(\n, ).replace(\r, ) text re.sub(r\[[^\]]*\], , text) # 去掉 [**日期**] 这类脱敏占位符 text re.sub(r[^a-zA-Z0-9\s.,;:/-], , text) text re.sub(r\s, , text).strip().lower() return text rad_df[clean_text] rad_df[note_text].map(clean_report)这里的清洗重点是去掉 MIMIC-IV 脱敏时留下的方括号占位符同时保留连字符和斜杠——因为像 non-specific、right/left 这类词去掉连字符后语义会变。小写化要放在最后一步避免缩写转换时出问题。2.3 标签构建用规则标注器给报告打多标签MIMIC-IV 不像 CheXpert 那样自带标注影像报告里没有现成的标签列。常见做法是用关键词规则做自动标注把 14 个常见胸部发现的对应关键词配成字典。这里有个关键点必须处理否定表达。import re label_rules { atelectasis: ratelectasis|atelectatic, cardiomegaly: rcardiomegaly|enlarged cardiac silhouette, consolidation: rconsolidation|airspace disease, edema: rpulmonary edema|interstitial edema, pleural_effusion: rpleural effusion|effusion, pneumothorax: rpneumothorax|ptx, pneumonia: rpneumonia|pna, } neg_words r\b(no|without|no evidence of|absence of|negative for|unlikely)\b def is_negated(text: str, match_start: int, window: int 8) - bool: before text[max(0, match_start - window):match_start] return bool(re.search(neg_words, before)) def build_labels(text: str, rules: dict) - list: labels [] for name, pattern in rules.items(): found False for m in re.finditer(pattern, text): if not is_negated(text, m.start()): found True break labels.append(1 if found else 0) return labels label_names list(label_rules.keys()) rad_df[labels] rad_df[clean_text].map(lambda t: build_labels(t, label_rules))规则标注器的核心逻辑找到关键词后往前看 8 个字符内有没有否定词。注意no evidence of pneumonia这种表达no离pneumonia有 10 个字符窗口给 8 偏小、给 12 又容易误伤not clearly exclude pneumonia 这类双重否定在临床文本里不少见。我的经验是窗口取 10 到 12宁可漏标也别错标因为多标签分类里漏标比错标更容易被后续训练修正。3. 训练 word2vec 嵌入让模型先读懂医疗词汇3.1 用 gensim 训练词向量模型选择与参数关于 word2vec很多人直接拿 GoogleNews 或 glove 的预训练向量来用但医疗报告里充满了医学缩写和特殊拼写通用词向量对 ptx、rll 这类词的覆盖率很差。我的做法是在 MIMIC-IV 自己的报告语料上重新训练一份词向量。模型用 skip-gram因为医疗场景里低频词占比高skip-gram 对低频词的表征比 CBOW 好。from gensim.models import Word2Vec sentences [text.split( ) for text in rad_df[clean_text].tolist()] w2v Word2Vec( sentences, vector_size200, window5, min_count2, sg1, workers8, epochs5, seed42, ) w2v.save(report_w2v.model)参数选择上vector_size200是性价比比较高的点128 偏小、300 在该语料规模下收益有限min_count2很关键MIMIC-IV 报告里拼写错误多只出现一次的词大多是噪声window5保持默认因为放射报告的局部搭配关系形容词加部位在 5 窗口内基本能覆盖。训练时间在纯 CPU 上大约十几分钟不用上 GPU。3.2 搭建嵌入矩阵对齐词表与 OOV 处理gensim 训练完只是第一步真正要喂给 PyTorch 的是嵌入矩阵。这里必须严格对齐词表否则索引错位会导致模型学到的全是噪声。先构建词表把min_count2以外的词统一归到 UNKpadding 位置的索引固定为 0。from collections import Counter import numpy as np import torch word_counter Counter() for tokens in sentences: word_counter.update(tokens) vocab {PAD: 0, UNK: 1} for word, count in word_counter.most_common(): if count 2: vocab[word] len(vocab) embedding_matrix np.random.uniform(-0.1, 0.1, (len(vocab), 200)).astype(float32) embedding_matrix[0] 0 # padding 向量置零 hit 0 for word, idx in vocab.items(): if word in w2v.wv: embedding_matrix[idx] w2v.wv[word] hit 1 print(f词表大小: {len(vocab)}, 命中的词向量: {hit}, 覆盖率: {hit / len(vocab):.2%})embedding_matrix[0] 0是把 padding 位的向量强制置零这样模型在计算 attention 时 padding 位置不会贡献有效信息。覆盖率一般会在 90% 以上剩下没命中 OOV 初始化为随机值在训练中用微调来纠正。把整个矩阵拷贝到 nn.Embedding 时有个细节padding_idx0要在创建 Embedding 时指定否则即使向量为 0梯度更新也会把 padding 位改坏。3.3 位置编码为什么静态词向量必须配 positionword2vec 本身不编码词序而影像报告的关键信息高度依赖顺序比如 no pneumothorax 和 pneumothorax no 其实是同一个意思但 right lower lobe opacity 和 opacity right lower lobe 在临床上几乎等价——顺序变化不改变语义但 increased opacity 和 opacity increased 在病变进展描述里含义不同。因此必须加位置编码。class PositionalEncoding(nn.Module): def __init__(self, d_model: int, max_len: int 512): super().__init__() pe torch.zeros(max_len, d_model) position torch.arange(0, max_len).unsqueeze(1).float() div_term torch.exp(torch.arange(0, d_model, 2).float() * -(math.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(position * div_term) pe[:, 1::2] torch.cos(position * div_term) pe pe.unsqueeze(0) self.register_buffer(pe, pe) def forward(self, x: torch.Tensor) - torch.Tensor: return x self.pe[:, :x.size(1)]这里用的是 Transformer 原文里的正弦位置编码。为什么不学一个位置嵌入因为在训练数据有限时学出来的位置向量容易过拟合到训练集的长度分布而正弦位置编码对任意长度都能外推。register_buffer保证位置编码随模型迁移到 GPU 或推理设备时不参与梯度更新。4. 构建 transformer 分类模型从 Encoder Layer 到多标签输出4.1 手写 Encoder Layer拿到注意力权重是关键PyTorch 自带的nn.TransformerEncoderLayer能直接用但它不暴露注意力权重后面做可视化时很被动。我在这个项目里选择手写一个简化版 Encoder Layer结构完全遵循 Transformer 原论文只多了一个need_weights开关。import torch import torch.nn as nn import torch.nn.functional as F import math class TransformerEncoderLayer(nn.Module): def __init__(self, d_model: int, nhead: int, dim_feedforward: int 512, dropout: float 0.1): super().__init__() self.self_attn nn.MultiheadAttention(d_model, nhead, dropoutdropout, batch_firstTrue) self.linear1 nn.Linear(d_model, dim_feedforward) self.linear2 nn.Linear(dim_feedforward, d_model) self.norm1 nn.LayerNorm(d_model) self.norm2 nn.LayerNorm(d_model) self.dropout1 nn.Dropout(dropout) self.dropout2 nn.Dropout(dropout) self.dropout3 nn.Dropout(dropout) def forward(self, src, src_key_padding_maskNone, need_weightsFalse): if need_weights: attn_out, attn_weights self.self_attn( src, src, src, key_padding_masksrc_key_padding_mask, need_weightsTrue, ) else: attn_out self.self_attn( src, src, src, key_padding_masksrc_key_padding_mask, ) attn_weights None src src self.dropout1(attn_out) src self.norm1(src) ff_out self.linear2(self.dropout2(F.relu(self.linear1(src)))) src src self.dropout3(ff_out) src self.norm2(src) return src, attn_weightskey_padding_mask实现的是 mask 掉 padding 位置让 attention 不看那些无效 tokenneed_weightsTrue时会返回一个形状为(batch, nhead, seq_len, seq_len)的权重矩阵推理阶段用来可视化。原生 Transformer 里还有 causal mask但分类任务不需要所以这里只处理 padding mask。4.2 完整分类器Embedding、Encoder 与分类头分类器由三部分组成word2vec 初始化的嵌入层、位置编码、堆叠的 Encoder Layer。池化策略我选择 mean pooling 而不是取第一个 token——因为影像报告没有特殊 token[CLS]这种约定mean pooling 对报告这种「整体描述性」文本更稳。class ReportClassifier(nn.Module): def __init__(self, vocab_size: int, d_model: int 200, nhead: int 8, num_layers: int 4, num_classes: int 7, dropout: float 0.1): super().__init__() self.d_model d_model self.embedding nn.Embedding(vocab_size, d_model, padding_idx0) self.pos_encoder PositionalEncoding(d_model, max_len512) encoder_layer TransformerEncoderLayer(d_model, nhead, d_model * 4, dropout) self.transformer_encoder nn.ModuleList([ copy.deepcopy(encoder_layer) for _ in range(num_layers) ]) self.dropout nn.Dropout(dropout) self.classifier nn.Linear(d_model, num_classes) def forward(self, x, maskNone): src self.embedding(x) * math.sqrt(self.d_model) src self.pos_encoder(src) for layer in self.transformer_encoder: src, _ layer(src, src_key_padding_maskmask) pooled src.mean(dim1) pooled self.dropout(pooled) return self.classifier(pooled)使用nn.ModuleList而不是nn.Sequential是因为后面做注意力可视化时需要逐层遍历操作src self.embedding(x) * math.sqrt(self.d_model)做了一次缩放这是 Transformer 原文的惯例防止 embedding 后的数值范围与位置编码相加时出现尺度失衡。4.3 数据加载与 padding mask 生成加载器要解决的核心问题是变长文本。报告长度差异很大短的 20 个 token长的 500 多个 token。统一截断到 256 个 token超过部分直接切掉——处理长文本时前面那段「IMPRESSION」往往是结论后面 description 部分重复信息多截断反而能去掉噪声。class ReportDataset(torch.utils.data.Dataset): def __init__(self, texts, labels, vocab, max_len256): self.texts texts self.labels labels self.vocab vocab self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): tokens self.texts[idx].split( )[:self.max_len] ids [self.vocab.get(t, 1) for t in tokens] # 1 UNK ids ids [0] * (self.max_len - len(ids)) return torch.tensor(ids, dtypetorch.long), torch.tensor(self.labels[idx], dtypetorch.float) def collate_fn(batch): ids torch.stack([item[0] for item in batch]) labels torch.stack([item[1] for item in batch]) mask (ids 0) return ids, labels, maskself.vocab.get(t, 1)里get的默认值是 1也就是UNK索引。padding 的 token id 是 0collate_fn里mask (ids 0)直接生成 maskbatch 里的所有样本共享同一个 max_len速度比动态 padding 快不少代价是短文本会多算一些 padding 位置的 attention实际影响很小。4.4 训练循环BCE Loss、类别不平衡与 warmup多标签分类的损失函数用BCEWithLogitsLoss。MIMIC-IV 的标签分布极不平衡大多数报告只有 1 个或 2 个阳性发现pleural_effusion可能占 30% 以上而pneumothorax可能只占 5%。直接训练会让模型把所有样本都预测成阴性所以要给每个类别算一个pos_weight。class_counts rad_df[labels].sum(axis0).values neg_counts len(rad_df) - class_counts pos_weight torch.tensor(neg_counts / np.maximum(class_counts, 1), dtypetorch.float) criterion nn.BCEWithLogitsLoss(pos_weightpos_weight) optimizer torch.optim.AdamW(model.parameters(), lr2e-4) total_steps len(train_loader) * epochs scheduler torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr2e-4, total_stepstotal_steps, pct_start0.1 ) for epoch in range(epochs): model.train() train_loss 0.0 for ids, labels, mask in train_loader: ids, labels, mask ids.to(device), labels.to(device), mask.to(device) logits model(ids, mask) loss criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() optimizer.zero_grad() train_loss loss.item()pos_weight neg_counts / class_counts的意思是正样本的权重被放大到负样本数量的比值。比如pneumothorax正样本比例是 5%那它的权重大约是 19等效于把正样本的数量「补到」和负样本差不多。梯度裁剪clip_grad_norm_很重要transformer 训练时偶尔会出现 loss 突增裁剪能避免梯度爆炸直接毁掉整个训练。OneCycleLR的pct_start0.1表示前 10% 的步数线性升温到max_lr后面余弦退火到接近 0这个调度在中小数据集上比固定学习率收敛快且稳定。验证阶段用 AUROC 而不是 accuracy因为正样本太少时 accuracy 全猜零也有 90% 以上AUROC 才能真正反映排序能力。from sklearn.metrics import roc_auc_score model.eval() all_probs, all_labels [], [] with torch.no_grad(): for ids, labels, mask in val_loader: ids, labels, mask ids.to(device), labels.to(device), mask.to(device) logits model(ids, mask) probs torch.sigmoid(logits).cpu().numpy() all_probs.append(probs) all_labels.append(labels.cpu().numpy()) y_true np.concatenate(all_labels, axis0) y_prob np.concatenate(all_probs, axis0) auc_scores roc_auc_score(y_true, y_prob, averageNone) print(各类别 AUROC:, dict(zip(label_names, auc_scores))) print(平均 AUROC:, np.mean(auc_scores))调用roc_auc_score(y_true, y_prob, averageNone)返回每个类别的独立 AUC比如atelectasis0.86、pneumothorax0.91这样能看出哪些类别分得好、哪些需要额外处理。5. 避坑与常见问题数据、训练、环境三个层面的踩坑记录5.1 conda 环境无法识别 or 装上 PyTorch 后 CUDA 不可用现象命令行输入conda activate pytorch直接报conda : 无法将“conda”项识别为 cmdlet、函数、脚本文件或可运行程序的名称或者安装完 PyTorch 后torch.cuda.is_available()返回 False。原因前者是 conda 没加到系统 PATH后者是装了 CPU 版 PyTorch 或 CUDA 版本与 PyTorch 编译版本不匹配。这是新手最常见的问题甚至很多老手在不同机器上重配环境时也会翻车。解决conda 不在 PATH 时用 Anaconda Prompt 或 PowerShell 里先执行conda init powershellCUDA 不可用时先查nvidia-smi看驱动支持的 CUDA 版本再按pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118的方式装对应版本。注意 PyTorch 的 CUDA 版本只需小于等于驱动支持版本即可不需要严格相等。5.2 word2vec 嵌入矩阵与模型维度对不上现象加载 embedding 权重时直接报size mismatch for embedding.weight: copying a param with shape torch.Size([vocab_w2v, 200])或训练时 loss 不降。原因gensim 训练出来的w2v.wv词表和你建模的 vocab 字典不一致或者vector_size与模型d_model不同。常见翻车点是gensim 默认过滤min_count1时的词表比自定义 vocab 大或者忘了把PAD、UNK两个特殊 token 算进 vocab。解决全部以自定义 vocab 为准加载后做一个对齐检查确保len(vocab) embedding_matrix.shape[0]并且embedding_matrix[0]是零向量。加载完成后打印一次覆盖率低于 85% 说明min_count设置太高或清洗步骤丢了太多词。5.3 MIMIC-IV 的 note_type 筛错混入大量非影像报告现象数据清洗完发现文本里出现手术记录、护理记录的文字标签规则命中率极低。原因MIMIC-IV-Note 的note_type字段值有Radiology、Discharge summary、Nursing等多种但早期版本里放射报告的note_type可能显示为Report而不是Radiology按df[note_type] Radiology过滤会漏掉大量数据。解决筛选时用rad_df df[df[note_type].str.contains(Radiology, caseFalse)]匹配完再随机抽 10 条打印note_text开头部分人工确认。养成抽取后先做 sanity check 的习惯别急着训练。5.4 长序列 OOM 或训练速度极慢现象batch size 设为 32跑第一个 epoch 到一半显存溢出或者 GPU 利用率只有 20%。原因影像报告平均 200 个 token 不算长但集中在 400 到 600 token 的长尾样本拖垮了整个 batch 的显存占用。另外batch_firstTrue虽然方便但如果不配合动态 padding所有样本都被补齐到 256 长度短的也被浪费。解决方案有三个梯队。最省事的是把max_len从 256 降到 192因为多数报告的关键结论在前 150 个 token 内中等级是自定义collate_fn做动态 padding让每个 batch 只按当前 batch 最长样本截断高级方案是直接用torch.nn.utils.rnn.pad_sequence配合 mask 处理变长但代码复杂度会上去。我通常先用第一招降max_len到 192够用且快。5.5 规则标签器把否定表达标成正样本现象训练出的模型对pneumothorax这个类别的 precision 极低验证时把大量 no pneumothorax 的报告预测成正样本。原因规则匹配只做了关键词命中没考虑否定词。影像报告的典型尾部结论是IMPRESSION: No pneumothorax or pleural effusion.关键词pneumothorax命中了但紧跟上文里有No规则直接把这个样本标为阳性。如果这个错标样本恰好也在验证集里训练和评估双双被污染。解决is_negated函数里把window设成 12 以上并且补充without evidence of、no acute这类组合否定词。更稳的做法是我在 2.3 里预留一个手动复核步骤对每个标签按probability1的样本随机抽 50 条人工看一遍如果错标率超过 5%就回去调规则或加窗口。6. 推理实战从单条报告预测到注意力可视化6.1 用训练好的模型做单条推理训练完之后直接面对实际报告写一个清晰的推理函数比在 Notebook 里反复复制粘贴训练代码高效得多。核心是把清洗、分词、转 ID、跑模型这几个步骤封装成一条流水线。def predict_report(text: str, model, vocab, max_len192): cleaned clean_report(text) tokens cleaned.split( )[:max_len] ids [vocab.get(t, 1) for t in tokens] ids ids [0] * (max_len - len(ids)) ids_tensor torch.tensor([ids], dtypetorch.long).to(device) mask (ids_tensor 0) model.eval() with torch.no_grad(): logits model(ids_tensor, mask) probs torch.sigmoid(logits).cpu().numpy()[0] return dict(zip(label_names, probs)) raw_report IMPRESSION: No acute intrathoracic process. Mild cardiomegaly. pred predict_report(raw_report, model, vocab) for name in label_names: if pred[name] 0.3: print(f{name}: {pred[name]:.3f})阈值设 0.3 而非 0.5是因为多标签分类里正样本本来偏少0.5 会漏掉大量低置信度阳性。0.3 是经验值要追求更高 precision 就往上调追求 recall 就往下调。这个函数最好导出为一个独立脚本predict.py而不是留在 Notebook 里后面做批量验证或者给同事用都方便。6.2 第 4.1 节的手写 Layer 如何拿注意力权重用第 4.1 节自定义的TransformerEncoderLayer我需要额外保留每一层的 attention 输出。做法是写一个封装函数传入need_weightsTrue手动循环各层收集权重矩阵而不是修改训练代码。def extract_attention(model, ids, mask): model.eval() src model.embedding(ids) * math.sqrt(model.d_model) src model.pos_encoder(src) attn_list [] with torch.no_grad(): for layer in model.transformer_encoder: src, attn_weights layer(src, src_key_padding_maskmask, need_weightsTrue) attn_list.append(attn_weights) return src, attn_listattn_weights的形状是(batch, nhead, seq_len, seq_len)。我一般只看最后一层 attention 里[CLS]或某个关键词 token 对其他 token 的注意力分布。比如打印pneumothorax所在位置的 top-5 高权重 token看模型是不是真的在关注 no、left 这类修饰词。这一步对调试模型很有帮助比如发现模型根本不在看no这个词就说明它没学会否定表达需要回去检查标签构建的否定处理。6.3 用 ONNX 导出模型训练结束后把模型转 ONNX 是常用操作。PyTorch 模型直接跑推理有框架和 Python 环境依赖转 ONNX 后可以部署到其他推理引擎。导出时注意把 mask 设为固定形状的占位输入。import torch.onnx import onnxruntime as ort dummy_ids torch.randint(0, len(vocab), (1, 192), dtypetorch.long).to(device) dummy_mask torch.zeros(1, 192, dtypetorch.bool).to(device) torch.onnx.export( model, (dummy_ids, dummy_mask), report_classifier.onnx, input_names[token_ids, padding_mask], output_names[logits], opset_version13, dynamic_axes{token_ids: {0: batch}, padding_mask: {0: batch}}, )导出后建议立刻用 onnxruntime 验证一遍输出和 PyTorch 原模型是否一致误差通常在 1e-5 以内。dynamic_axes只设 batch 维为动态seq_len 保持固定 192这样导出时能少很多算子兼容问题。从那以后我每次训练完模型都强制走一遍「单条预测测试 → 注意力检查 → ONNX 导出验证」这个流程顺序不能颠倒因为注意力检查能发现 ONNX 导出后看不到的语义问题而单条预测是所有验证的起点。希望这条经验能帮你在类似项目上少走几条弯路。本文还有配套的精品资源点击获取