简介:面向MIMIC-IV英文影像报告分类任务,这份PyTorch实战资源包提供了从word2vec词向量训练到Transformer模型构建与评估的完整代码方案,适合具备一定Python基础、希望上手医学文本NLP项目的学习者或研究者。资源共20个文件,压缩包大小仅2.96MB,其中8个Python脚本覆盖文本提取、数据清洗、词向量训练、模型定义、训练与测试等流程;辅以预训练词向量文件(bin/model/txt)、PyTorch权重文件pth、CSV格式的发现列表与切分句数据,以及XLSX格式的疾病编码表。项目目录按code、saved_model、data等模块组织,主入口脚本清晰,代码模块划分与命名也较为规范,便于直接运行复现,也可借助已训练的词向量和模型权重开展迁移实验或二次开发。目前已有515人学习/下载,整体体量轻、闭环完整,是医学影像报告文本分类入门的实用参考资料,亦可作为相关课程设计或科研预实验的基线。
1. 把英文影像报告自动分类:MIMIC-IV + PyTorch 这条路值不值得走
放射科医生写报告时,习惯在同一个段落里塞下解剖部位、对比剂用量和一句“no acute intracranial abnormality”这样的结论。如果让你用 NLP 把这些报告自动分成“阳性/阴性”或者按部位归类,MIMIC-IV 几乎是国内研究生和一线工程师绕不开的免费数据集:它来自波士顿一家大型教学医院,包含数万条去标识化的影像报告文本。基于 PyTorch 做这条文本分类管线,常见做法是先拿 word2vec 把词向量训出来,再用 Transformer 编码上下文,或者干脆只取报告里的 Impression(结论段)喂给 word2vec + 简单分类头。这篇笔记按数据预处理、模型搭法、训练翻车点和评估验证的顺序写,新手照着做能跑通,熟手可以直接替换成自己的报告数据集。注意:MIMIC-IV 需要完成 PhysioNet 认证才能下载,但流程不复杂,半天能下来。
2. 先摸清 MIMIC-IV 的报告文本:数据授权、清洗与标签构造
2.1 拿到数据的第一步:PhysioNet 认证和文件清单
MIMIC-IV 的主数据在 PhysioNet 网站,需要注册账号、完成 CITI 培训并通过一个内容测试,然后签署数据使用协议。整个过程快的话半小时,慢的话等审核一两天。下载时注意:主 MIMIC-IV 数据集(比如mimic-iv-2.2)和笔记数据模块(mimic-iv-note-2.2)是分开的,影像报告在 note 模块里的radiology.csv。很多新手只下载了主数据,找半天找不到报告文本,其实就是漏了 note 模块。
# 在 PhysioNet 页面下载 mimic-iv-note-2.2 后的解压命令 unzip mimic-iv-note-2.2.zip ls -lh mimic-iv-note-2.2/note/ head -3 mimic-iv-note-2.2/note/radiology.csv解压后你会看到radiology.csv,每行对应一份影像报告,核心字段是subject_id、study_id、text和note_id。text里是完整的报告正文,包含 Exam、Indication、Technique、Comparison、Findings、Impression 等段落,段落之间用大写标题和换行分隔。列数不多,但同一个人会有多次检查,所以去重和按时间排序很重要。下一步的清洗,我只针对text字段做,不影响其他列。
2.2 清洗规则:去占位符、保小数点和剂量单位
影像报告本身不是干净的纯文本,里面经常出现[** 2022-11-02 **]这种去标识化占位符,还会把患者特征写成一堆数字和单位混排,比如2.5 cm、50 cc。清洗时不能直接按空格乱砍,否则2.5会被拆成2和5,剂量信息就废了。我的常见清洗顺序是:去占位符、统一换行、把数字周围多余空格清掉、保留小数点,最后再按需转小写。
import re def clean_report(text: str) -> str: # 去掉 MIMIC-IV 的去标识化占位符 [** ... **] text = re.sub(r'\[\*\*.*?\*\*\]', ' ', text, flags=re.DOTALL) # 把换行变成单个空格,避免 Findings 和 Impression 间产生奇怪的 token text = re.sub(r'\s+', ' ', text) # 让数字与单位之间有稳定空格: 2.5cm -> 2.5 cm text = re.sub(r'(?<=\d)([a-zA-Z]+)', r' \1', text) text = re.sub(r'(?<=[a-zA-Z])(\d)', r' \1', text) # 保留小数点和百分号,但去掉孤立标点 text = re.sub(r'([^0-9a-zA-Z.%\s])', ' ', text) # 压缩空格,返回干净文本 text = re.sub(r'\s+', ' ', text).strip() return text.lower()这段正则里,第一行DOTALL保证占位符里即使有换行也能被完整移除;第二行把多个空白压成一个空格是为了后续分句不产生空 token;数字与字母之间加空格是为了让word2vec把2.5cm拆成2.5和cm,而不是合成一个生僻词。注意最后一句转小写会在后面影响蛋白质名或缩写,像MRI变成mri,对分类任务一般没损失,但如果你的任务需要保留大小写,可以去掉这一步。清洗后建议把结果单独存一列clean_text,不要覆盖原始文本,方便回溯。
2.3 标签怎么定:用 Impression 规则和检查部位构造多标签
影像报告分类最常见的目标不是找病名,而是三级分类:检查部位(Chest/Abdomen/Head/Spine)或者结论极性(正常/异常)。如果你用 ICD 代码映射,会发现一份报告对应多个代码,且代码颗粒度和影像描述不一致,训练时容易教错模型。我一般更推荐从报告自身构造标签:一是用Impression段落里的高频否定短语,比如no acute intracranial abnormality标记为阴性;二是用表头里的检查部位直接作为类别标签,这是最省力也最稳的做法。
import pandas as pd df = pd.read_csv('mimic-iv-note-2.2/note/radiology.csv', nrows=5000) df['clean_text'] = df['text'].apply(clean_report) # 规则标签:从 text 的 Impression 字段判断有无阳性表述 # 这里用一个保守的 negative 集合,命中即认为阴性 negative_phrases = ['no acute', 'no evidence', 'negative', 'unremarkable'] def rule_label(text: str) -> int: # 只看 Impression 之后的内容,减少检查描述带来的干扰 idx = text.find('impression') if idx == -1: return 1 # 找不到 Impression 按阳性处理,保守做法 impression = text[idx:] for phrase in negative_phrases: if phrase in impression: return 0 return 1 df['binary_label'] = df['clean_text'].apply(rule_label) print(df['binary_label'].value_counts())两个注意点:find('impression')要求清洗后文本里保留小写,如果你在清洗阶段转了小写,必须确保impression仍能在正文里找到;如果找不到Impression段,直接判为异常是一种偏置,因为很多陈旧性报告只有 Findings 没有单独结论。统计value_counts的时候,你会看到类别不均衡——阴性报告通常占一半以上,这个数字决定了你后面用accuracy评判是否可靠。规则标签本质上是有噪声的,所以跑模型之前,最好抽 200 条人工看一眼标签和文本是否匹配,成本不高但能避免方向性错误。
3. word2vec 和 Transformer 怎么选:从文本长度到 PyTorch 实现
3.1 两种做法的适用边界:静态向量和上下文编码不是二选一
一份 MIMIC-IV 影像报告全文短则 50 个词,长则 600 个词,而Impression段通常只有 20 到 80 个词。如果只对Impression做分类,word2vec 加一个平均池化就已经非常能打,因为结论段的用词高度模板化,no acute intracranial abnormality这种固定搭配只要词汇表里有完整短语,均值池化照样能区分。Transformer 的优势在于能捕获上下文和指代关系,但代价是训练时间变长、显存占用变高,而且在小样本上容易过拟合。所以我会先问一个问题:你的分类目标是整份报告还是结论段?
如果是整份报告,我倾向于先用 word2vec 初始化一个嵌入层,再接一层nn.TransformerEncoder,这样既保留了预训练词向量对罕见医学术语的基础语义,又让注意力机制去调整上下文权重。这样做的另一个好处是,模型本身就是纯 PyTorch 代码,不依赖额外的 Transformer 库,部署和调试更可控。
3.2 用 PyTorch 训练 word2vec 并接分类头的完整步骤
训练 word2vec 我直接用gensim,但把它装进 PyTorch 需要走一遍nn.Embedding.from_pretrained。先训练:
from gensim.models import Word2Vec from nltk.tokenize import word_tokenize # 假设 df 里有 clean_text 列 tokenized_sentences = [word_tokenize(text) for text in df['clean_text']] w2v_model = Word2Vec( tokenized_sentences, vector_size=300, # 词向量维度 window=5, # 左右各看 5 个词 min_count=2, # 出现次数小于 2 的词不要 workers=4, epochs=20, seed=42 ) # 保存词向量,后面 PyTorch 要用 w2v_model.wv.save_word2vec_format('w2v_report.vec', binary=False)参数说明:vector_size=300是医学 NLP 里常用的嵌入维度,太小表达不了术语关系,太大在小数据上容易过拟合;window=5适合短句和模板化文本,把窗口调大到 8 会让远处语法关系参与训练,但也会稀释局部搭配;min_count=2是把只出现一次的罕见词扔掉,这些词在报告里往往是拼写错误或者专名,保留只会让词汇表失控。训练完务必检查一下词汇表里是否包含abnormality、fracture这类关键词,如果没有,说明清洗把词干破坏了,需要回看正则。
接下来用 PyTorch 加载这部分词向量并训练分类器:
import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader # 构建词汇表 vocab = {'<pad>': 0, '<unk>': 1} for word in w2v_model.wv.index_to_key: vocab[word] = len(vocab) # 用 gensim 的向量表初始化 PyTorch embedding 矩阵 emb_dim = 300 embedding_matrix = torch.zeros(len(vocab), emb_dim) for word, idx in vocab.items(): if word in w2v_model.wv: embedding_matrix[idx] = torch.tensor(w2v_model.wv[word], dtype=torch.float32) else: embedding_matrix[idx] = torch.randn(emb_dim) * 0.1 # 随机初始化 <pad> 和 <unk> class ReportDataset(Dataset): def __init__(self, texts, labels, vocab, max_len=200): self.texts = texts self.labels = labels self.vocab = vocab self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, i): tokens = word_tokenize(self.texts[i])[:self.max_len] # 把词映射成 id,OOV 词归一到 <unk> token_ids = [self.vocab.get(w, self.vocab['<unk>']) for w in tokens] # 补齐到 max_len,后面用 mask 区分 pad 位 token_ids += [self.vocab['<pad>']] * (self.max_len - len(token_ids)) return torch.tensor(token_ids, dtype=torch.long), torch.tensor(self.labels[i]) class W2VClassifier(nn.Module): def __init__(self, vocab_size, embed_size, num_labels, embedding_matrix): super().__init__() self.embedding = nn.Embedding.from_pretrained(embedding_matrix, freeze=True) self.fc = nn.Sequential( nn.Linear(embed_size, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, num_labels) ) def forward(self, x, mask=None): embedded = self.embedding(x) # [batch, seq_len, embed_size] pooled = embedded.mean(dim=1) # 平均池化 return self.fc(pooled)代码的关键是nn.Embedding.from_pretrained的freeze=True,这意味着 word2vec 向量在训练分类器时不会被进一步调整。对报告文本来说,预训练向量已经包含足够语义,微调反而容易在少量标记样本上被带偏。平均池化看似简单,但对模板化短语有奇效——它把整段结论里所有词的语义重心压成一个向量,no acute这类否定词高频出现时,方向性差异非常明显。训练时记得给max_len做截断,我一般取 200,因为一份报告最重要的结论集中在开头或结尾段落,截断不会丢核心信息。
3.3 在 word2vec 之上加一层 Transformer Encoder 做分类
把静态词向量升级成上下文表示,不需要从零写注意力。PyTorch 自带nn.TransformerEncoderLayer,我们直接用它对上面的embedded输出再做一次编码。区别在于,Transformer 需要attention_mask告诉它哪些位置是 padding,否则模型会把<pad>也算进语义。
class W2VTransformerClassifier(nn.Module): def __init__(self, vocab_size, embed_size, num_heads, num_layers, num_labels, embedding_matrix, max_len): super().__init__() self.embedding = nn.Embedding.from_pretrained(embedding_matrix, freeze=False) # 位置编码:不学复杂多项式,直接用可学习参数,简单有效 self.pos_embedding = nn.Embedding(max_len, embed_size) encoder_layer = nn.TransformerEncoderLayer( d_model=embed_size, nhead=num_heads, dim_feedforward=512, dropout=0.1, batch_first=True ) self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) self.fc = nn.Linear(embed_size, num_labels) def forward(self, x, mask): b, seq_len = x.shape embed = self.embedding(x) positions = torch.arange(seq_len, device=x.device).unsqueeze(0).expand(b, seq_len) embed = embed + self.pos_embedding(positions) # mask 为 True 的位置表示 padding,PyTorch 要求传入 True 的位置会被忽略 padding_mask = (x == 0) # 0 是 <pad> encoded = self.encoder(embed, src_key_padding_mask=padding_mask) # 取每个样本的平均池化,因为句子长度不一 mask_expanded = (~padding_mask).unsqueeze(-1).float() pooled = (encoded * mask_expanded).sum(dim=1) / mask_expanded.sum(dim=1).clamp(min=1.0) return self.fc(pooled)这里要注意两件事:第一,freeze=False,因为 Transformer 层需要梯度来调整词向量在上下文里的位置,但如果你数据量少于 1 万条,建议改成freeze=True以免微调过头;第二,src_key_padding_mask里True表示该位置是 padding,不要和我第一次写反。位置编码用可学习nn.Embedding是简化方案,对序列长度固定为 200 的任务足够,不用手写正弦编码。训练时把学习率调到1e-3,batch size 64,跑 10 到 20 轮就够,重点看验证集的 F1,不是训练 loss。
两个模型对比下来,word2vec 均值池化训练一轮不到 30 秒,Transformer 代码复杂但能把no acute和acute这种位置敏感的否定关系区分得更好。如果你的任务里有“正常但有既往病史”这类易混淆表述,Transformer 胜出;如果只是粗粒度二分类,word2vec 足够,别给自己找麻烦。
4. 训练和推理中常见的 5 个坑:从 GPU 到 tokenizer
4.1 坑一:全文截断后,把“Impression”截没了
现象:训练集 accuracy 98%,验证集掉到 80%。查看错误样本,发现大多数错误样本的text里找不到impression字样。
原因:我在 3.2 的max_len=200是按整份报告设计的,但有些报告很长,前 200 个 token 全是技术参数和既往对比,真正的Impression段在第 300 个字才出现。模型根本没看见结论,只能靠前面的“病史”瞎猜。
解决:清洗时先切分段落,把Impression:之后的内容提取出来作为新文本。如果找不到 Impression,则用全文前 200 词。这样截断从“按开头截”变成“按关键段截”,大部分情况下模型输入里都包含结论。切段代码很简单:text.split('impression')[-1],但要注意有些报告可能有indication里包含 “impression” 这个单词,建议用正则找段标题而不是简单 split。
4.2 坑二:词表里没有罕见病名,全被丢进
现象:模型对pneumothorax(气胸)的召回率特别低,打印 token id 发现这个词被映射成 1(<unk>)。
原因:min_count=2把出现次数低的医学术语全部剔除了,而气胸在几千份报告里可能只出现 30 次,正好低于阈值。word2vec 没有为它训练向量,PyTorch embedding 里 是随机向量,模型学不到任何信息。
解决:把min_count降到 1,或者设定一个特殊策略:凡是能被医学词典命中的罕见词,强制保留。更稳妥的做法是把min_count=1,让所有词都进词汇表,但这样词表可能膨胀到十几万。折中方案是:保留词频大于等于 2 的词,同时把所有词频为 1 的词先映射到一个“低资源词簇”的词向量上,即把出现一次的罕见术语和它相近的常见词共享一个向量。我用过最省事的办法是min_count=1加上 300 维向量,词表 5 万规模时内存也就几百 MB,训练慢一点点但换回召回率,很值。
4.3 坑三:安装 PyTorch 后 GPU 静默回退 CPU,训练慢十几倍
现象:代码里torch.cuda.is_available()返回False,或者刚才能跑到第 5 轮,突然卡住不动,日志里没有任何报错。
原因:安装 PyTorch 时用的 CUDA 版本和你本机驱动不匹配。比如驱动支持 CUDA 12.8,但你按老教程下载了cu121包,PyTorch 检测不到可用设备,就自动回退到 CPU。这种失败不打印红色报错,只会在第一次调用 GPU 时静默降级,等你发现已经是几小时后。
解决:安装前先查驱动版本:
nvidia-smi # 看右上角 CUDA Version,比如 12.8然后用官方命令按对应版本安装:
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu128装完立刻验证:
import torch print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))这两个输出必须是True和你的显卡名,像“NVIDIA GeForce RTX 4090”。如果仍显示False,先在conda activate后检查 Python 环境是不是新开的,很多用户是 conda 里装了 CPU 版,外面又是另一个环境,两个环境互相干扰。另外,intel 核显用户不要作死去装 CUDA 版,直接pip install torchCPU 版,别浪费半天调驱动。
4.4 坑四:padding 位置参与了注意力,模型被 带偏
现象:Transformer 模型训练 loss 能降到 0.2,但推理时对长句和短句表现两极分化,短句几乎全分成同一类。
原因:nn.TransformerEncoderLayer默认会计算所有位置的 attention,包括<pad>位置。当你把序列 padding 到 200,短句例如 “normal” 会变成 199 个 pad token,注意力平均分配后,语义向量被 pad 的零向量稀释成了噪声。我曾在代码里漏掉src_key_padding_mask,准确率掉了 7 个点。
解决:必须在 forward 里传src_key_padding_mask,并保证它的形状是[batch, seq_len],True代表 padding。每次写新模型时先跑一个单 batch 的 sanity check:
model.eval() dummy_x = torch.randint(0, 30, (2, 20)) # 假装 2 个样本,20 个 token dummy_x[0, 15:] = 0 # 第一条后半段是 pad with torch.no_grad(): logits = model(dummy_x, mask=(dummy_x == 0)) print(logits.shape)如果不传 mask 还能跑通,但传了 mask 反而报错,大概率是你把 mask 形状写成了[batch, batch],那是给tgt_mask用的,不是 padding mask。这个坑最难排查,因为报错信息只说“size mismatch”,不告诉你 true 和 false 含义。
4.5 坑五:类别不均衡却用 accuracy 看 loss,模型全猜阴性还能有 80% 准确率
现象:训练完打印test accuracy = 0.83,感觉很成功;一画混淆矩阵,发现阳性类别的召回率只有 0.1,模型几乎把所有样本都判成了阴性。
原因:MIMIC-IV 报告里阴性比例高,尤其脑血管影像,可能 70% 以上都是“no acute”。如果损失函数是交叉熵,模型只需要把权重偏向多数类就能把 loss 压得很低,但少数类的错误完全没被惩罚。你要是没看 PR 曲线,就会被 accuracy 骗了。
解决:在训练时给少数类更高的权重,PyTorch 的CrossEntropyLoss支持weight参数:
class_counts = df['binary_label'].value_counts().sort_index() weights = 1.0 / class_counts.values weights = torch.tensor(weights, dtype=torch.float32).to(device) criterion = nn.CrossEntropyLoss(weight=weights)weight会在每次前向计算时把某一类的 loss 乘以对应系数。比如阳性 1000 条、阴性 3000 条,weights是[1/3000, 1/1000],阳性单条 loss 相当于被放大了 3 倍。同时,评估阶段别再用 accuracy,改用 macro-F1 或者 PR 曲线,看少数类的召回率有没有上来。如果召回率还是低,就做随机过采样,把训练循环里的DataLoader换成WeightedRandomSampler,每轮让模型多看到几次阳性样本。这个坑是医学 NLP 里最常见的心态崩塌点,模型不是笨,是你的评估指标选错了。
5. 让分类结果可信:验证、混淆矩阵与模型导出
训练结束后别急着谈效果,先把验证集划分对。MIMIC-IV 里同一个病人有多次检查,如果你直接随机切分,同一病人的相似报告可能同时出现在训练和验证集,导致验证集 F1 虚高。正确的做法是按subject_id分病人:先抽出唯一病人列表,打乱后按 8:2 切分,再根据病人 ID 把报告分到两边。
python - <<'EOF' import pandas as pd from sklearn.model_selection import train_test_split patients = df['subject_id'].unique() train_patients, val_patients = train_test_split(patients, test_size=0.2, random_state=42) train_df = df[df['subject_id'].isin(train_patients)] val_df = df[df['subject_id'].isin(val_patients)] print(len(train_df), len(val_df)) EOF然后输出混淆矩阵和 PR 曲线指标,重点看少数类那一行的召回率。如果阳性召回率低于 0.6,回头检查 4.5 的类别权重有没有生效。
模型投入使用前,把 PyTorch 模型转成 ONNX 是更稳妥的部署方式,能省掉目标机器上的 PyTorch 环境依赖。转 ONNX 时千万要把动态轴标清楚,因为推理时输入序列长度可能变化。转换脚本如下:
import torch.onnx model = W2VTransformerClassifier(...) model.load_state_dict(torch.load('best_model.pt', map_location='cpu')) model.eval() dummy_x = torch.randint(0, 50, (1, 200), dtype=torch.long) dummy_mask = (dummy_x == 0) torch.onnx.export( model, (dummy_x, dummy_mask), 'report_classifier.onnx', input_names=['input_ids', 'attention_mask'], output_names=['logits'], dynamic_axes={ 'input_ids': {0: 'batch', 1: 'seq_len'}, 'attention_mask': {0: 'batch', 1: 'seq_len'}, 'logits': {0: 'batch'} }, opset_version=14 )导出后用onnxruntime验证单条推理:
import numpy as np import onnxruntime as ort ort_session = ort.InferenceSession('report_classifier.onnx') # 假设 token_ids 是长度 200 的 numpy 数组 logits = ort_session.run(None, { 'input_ids': token_ids.reshape(1, -1), 'attention_mask': (token_ids == 0).astype(int).reshape(1, -1) })[0] pred = logits.argmax(axis=1)[0]转 ONNX 遇到最大的坑是src_key_padding_mask的 dtype,PyTorch 里是 bool,ONNX 里有的算子只接受 int,所以上面示例里用astype(int)转换。如果导出时报“Unsupported operator”,常见解法是把opsetset_version降到 12 或升到 17,然后在脚本里加torch.onnx.export(..., dynamo=True)试试新版导出路径。我自己的习惯是:单条测试通过后,随机抽 100 个样本跑一遍 onnxruntime 和 PyTorch 模型的结果比对,两边预测完全一致才认为是部署成功。
这套流程走完,你对 MIMIC-IV 报告分类这件事的基本盘就有数了:数据授权一天能搞定,清洗正则半天,word2vec 基线一晚上,Transformer 加进去多花两天调参,最关键的时刻其实是画混淆矩阵和分病人验证。希望帮到你。
本文还有配套的精品资源,点击获取