简介:这是一份面向医学图像分类任务的已标注数据集,聚焦肾脏结节和肿瘤的自动识别,包含正常、结节、肿瘤三个分类,整体已按训练集、验证集、测试集拆分,并存放于独立文件夹内,可直接作为分类网络或yolov5分类任务的数据输入。压缩包共2000个文件,其中1998个为JPG格式的肾脏影像,1个Python脚本用于数据集可视化,1个JSON文件记录类别字典与索引映射,压缩包大小约151.5MB。目前已有299人浏览学习。数据集结构清晰,训练、验证、测试三个目录分别存放对应图像,便于直接开展模型训练与效果评估;JSON文件可帮助快速查看类别对应关系,可视化脚本支持批量展示样本,适合在训练前检查数据质量与类别分布。对于医学图像分析学习者、算法工程师以及需要进行肾脏结节、肿瘤识别研究的团队,这是一份开箱即用的标准分类数据集。
1. 拿到一份划分好的肾脏结节数据,最先该做什么
医学图像分类看起来是个标准的深度学习任务,但真拿到一份“肾脏结节、肿瘤数据图集”并开始训练时,很多人才发现卡壳的地方根本不在模型,而在数据组织、类别定义和评估口径上。常见的痛点是:数据是文件夹保存的,但文件夹排序和训练时的类别 id 对不上;验证集准确率高达 99%,到了医院新数据上却完全失效;训练时 Loss 不降,你以为是网络结构问题,其实只是预处理里灰度图复制成了三通道却不配套。这篇笔记按一线工程师的流程来拆:先讲怎么把“划分好的文件夹 + 类别字典文件”这套结构用到位,再讲 ResNet 迁移学习的完整训练配置,最后落在敏感度、特异度和 Grad-CAM 可视化这些医生真正关心的输出上。每一步都写成可以直接照着跑的代码。
2. 数据组织与类别字典:先把训练集、验证集目录和 class_to_idx 定死
2.1 先定类别边界:单标签还是多标签,结节、良性恶性囊肿怎么分
医学图像分类的第一步不是写训练脚本,而是建立一套让影像科医生认可、程序员又能落地的类别体系。肾脏数据常见的有三种划分思路:最简单的二分类是“肾脏肿瘤”和“正常组织”,适合快速做分诊;临床上更常用的是三类,即正常、良性病变(比如血管平滑肌脂肪瘤、单纯囊肿)和恶性病变(如肾细胞癌),这需要病理结果做标注基准;再细一点还可以按病理亚型分,比如透明细胞癌、乳头状细胞癌、嫌色细胞癌,类间形态差异更细微,对数据量和图像分辨率的要求也更高。
这里要特别提醒一件事:“结节”和“肿瘤”不是同一个维度。结节是影像学术语,凡是超声或 CT 上发现的局灶性占位都能叫结节;而肿瘤是病理或临床诊断术语,标注时必须依赖活检或手术病理作为金标准。如果你手上的数据只有影像,没有病理标签,那只能算“检测或者分类”任务,不能声称是“良恶性诊断”。我在做类别字典时,一般会把类别命名写成可读性强的英文短词,比如kidney_normal、kidney_benign、kidney_tumor,而不是用 0、1、2 直接命名文件夹,后续写可视化代码一眼就能看懂。
类别体系确定后,还要考虑数据合规。公开的肾脏影像数据集如 KiTS19、TCGA-KIRC 可以用于学术研究,但下载前要仔细阅读使用许可,注意是否允许重新分发和用于商用;院内自采数据则必须过伦理审批和脱敏流程,训练时只处理脱敏后的 PNG/JPG,不直接碰原始 DICOM 中的患者身份信息。
2.2 数据划分:用分层抽样按类别比例划分,不要随手 shuffle
拿到划分好的数据,第一件事是验证它划分的是否合理。很多人习惯直接把所有图片读完再train_test_split,但医学数据里最常见的错误是随机划分导致类别比例漂移。比如肿瘤样本只占 5%,随机分 80% 进训练集,验证集里可能只剩 1% 的肿瘤样本,模型压根没见到足够多样的病灶形态,评估结果自然不可信。正确做法是分层抽样,也就是按类别比例把数据分到训练、验证、测试三个文件夹。
import os import json import shutil from sklearn.model_selection import train_test_split data_root = "data/all_images" train_target = "data/train" val_target = "data/val" test_target = "data/test" # 读取全部类别,按字母序排序保证可复现 categories = sorted([name for name in os.listdir(data_root) if os.path.isdir(os.path.join(data_root, name))]) # 扫描所有文件,记录文件名与类别 files, labels = [], [] for cls in categories: cls_dir = os.path.join(data_root, cls) for fname in os.listdir(cls_dir): if fname.lower().endswith((".png", ".jpg", ".jpeg")): files.append(os.path.join(cls, fname)) labels.append(cls) # 第一步:按类别比例分出 80% 训练,20% 临时 train_files, tmp_files, train_labels, tmp_labels = train_test_split( files, labels, test_size=0.2, stratify=labels, random_state=42) # 第二步:临时集对半分,得到 val 和 test val_files, test_files, val_labels, test_labels = train_test_split( tmp_files, tmp_labels, test_size=0.5, stratify=tmp_labels, random_state=42) # 第三步:复制文件到目标文件夹 for split_name, split_files in [("train", train_files), ("val", val_files), ("test", test_files)]: for rel_path in split_files: src = os.path.join(data_root, rel_path) dst = os.path.join({"train": train_target, "val": val_target, "test": test_target}[split_name], rel_path) os.makedirs(os.path.dirname(dst), exist_ok=True) shutil.copy2(src, dst) # 第四步:生成类别字典 class_to_idx = {cls: i for i, cls in enumerate(categories)} with open("class_to_idx.json", "w") as f: json.dump(class_to_idx, f, indent=2) print("类别字典:", class_to_idx) print("各划分数量:", len(train_files), len(val_files), len(test_files))这段代码里有几个参数值得说明。stratify=labels是分层抽样的关键,它保证训练集、验证集、测试集中每个类别的占比和原始数据总体保持一致,在肿瘤样本只占 5% 时特别重要。random_state=42固定随机种子,让每次划分结果一致,方便复现实验。test_size=0.2是在总量中预留 20% 给验证和测试,再把这 20% 对半分,得到 10% 验证、10% 测试。如果数据集总量很小(比如每个类别只有几百张),可以考虑把验证和测试各留 5%,剩余 90% 训练,或者用 K 折交叉验证代替单次划分。
生成class_to_idx.json用到了遍历文件夹名称的方式,这里有个易错点:os.listdir返回的顺序不保证稳定,所以我用sorted()强制按字母序排列,保证每次运行生成的 id 一致。这一点看起来小,但一旦你重新生成字典文件时顺序变了,之前保存的模型权重就作废了。这个细节我会在避坑章节再展开。
2.3 类别字典文件:ImageFolder 的天然搭档,推理时必须复用同一份
PyTorch 的torchvision.datasets.ImageFolder天然支持“文件夹保存”的数据组织方式,它会把每个子文件夹当作一个类别,并按字母序给类别排序,自动生成class_to_idx属性。我一般会在数据准备脚本里主动把class_to_idx导出成 JSON 文件,而不是依赖 ImageFolder 每次现场计算。原因在于:训练时用它保证数据加载顺序一致,验证时用它重排标签,推理时直接读取它反查预测类别,全流程用的都是同一份 id 映射,谁也不会错位。
注意class_to_idx.json是训练产物,也是模型的一部分。你要把它和权重文件放在同一个目录下,最好命名带版本号,例如class_to_idx_v2.json对应resnet18_kidney_v2.pt,不要后期手工去改 JSON 内容。如果新增了一个类别,正确的做法是重新生成整个数据目录和字典文件,从头训练,而不是在旧 JSON 里追加一条。因为新增类别会导致模型输出维度变化,旧权重无法用于新模型。
文件命名规范上,我建议直接用英文和数字命名图片,比如kidney_tumor_001.png,避免中文文件名在跨平台传输时出现乱码。同时文件名里不要带空格和特殊符号,否则某些数据加载库解析时可能报错,或者在 torchvision 的DatasetFolder扩展逻辑里触发不可预期的过滤规则。这些是数据版图里最小的坑,但往往是进场第一周就卡住人的地方。
3. 模型与训练配置:ResNet 迁移学习在肾脏影像上的完整动作
3.1 为什么选 ResNet 而不是一上来就上 ViT
肾脏结节和肿瘤图像分类的数据规模通常不大,公开数据集加上自采数据也就几千张到几万张,这个量级对 Vision Transformer 这类大模型并不友好,容易过拟合,而且预训练权重的分布和医学灰度图差异很大,训练起来很玄学。ResNet18 或 ResNet50 是更稳的选择:预训练权重在 ImageNet 上充分训练过,迁移到医学影像可以显著降低对数据量的需求;torchvision 自带实现,没有额外依赖;Grad-CAM 等可解释性工具对 ResNet 的卷积特征图支持成熟。ResNet18 更适合快速验证,ResNet50 在计算资源允许时能带来少量提升。如果数据量很少,建议用 ResNet18 加冻结主干权重,只训练最后的全连接层。
# 检查 GPU 可用性和显存 nvidia-smi # 预期输出里能看到显卡型号和显存占用,CUDNN 版本要跟 PyTorch 匹配3.2 数据增强方法在医学影像上的取舍:先翻转旋转,再考虑对比度扰动
医学图像的数据增强和自然图像不太一样,核心原则是“不能改变病灶的形态学语义”。随机裁剪、缩放这类增强在自然图像里很常见,但在肾脏 CT 中如果裁剪区域偏移过多,等于把肾脏和周边组织混在一起,破坏了解剖结构,模型学到的特征反而是噪声。我一般用全局 Resize 加轻度翻转旋转,以及轻微的亮度和对比度扰动。
from torchvision import transforms # 训练集增强 transform_train = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomVerticalFlip(p=0.5), transforms.RandomRotation(degrees=15), transforms.ColorJitter(brightness=0.1, contrast=0.1), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # 验证集和测试集只做拉伸和归一化,不做任何增强 transform_eval = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])这里有一处医学图像特有的细节:很多 CT 数据是单通道灰度图,但 ResNet 预训练权重要求三通道 RGB 输入。常规做法是在数据读取阶段把灰度图复制三份,拼成三通道。注意ColorJitter里不要动hue和saturation,这两个参数对灰度图或伪彩图没有意义,反而可能引入不存在的色彩分布,干扰模型对纹理特征的判断。如果你手上的数据是从 DICOM 转出来的 PNG,还要确认一下是窗口化后的数值还是原始像素值,尽量统一预处理流程。
3.3 模型微调:替换全连接层、冻结主干、分层设置学习率
import torch import torch.nn as nn from torchvision import models # 加载预训练 ResNet18 model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) # 替换最后一层全连接,输出改为本任务的类别数 num_classes = len(class_to_idx) in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) # 冻结主干参数:在小数据集上先用固定特征训练分类头 for name, param in model.named_parameters(): if "fc" not in name: param.requires_grad = False # 只把 fc 层参数送入优化器,降低显存占用和过拟合风险 optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3) # 选择 GPU 或 CPU device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device)这段代码的关键在于param.requires_grad = False。冻结主干意味着卷积层不参与梯度更新,只训练最后的全连接层,数据量不足时这是最稳妥的方案。如果你数据量超过一万张,可以解冻最后一两个残差块参与微调,让模型适应医学图像的纹理细节,但要小心过拟合。fc的输入维度来自model.fc.in_features,不要硬编码成 512,因为不同版本的 ResNet 输出维度不一样,硬编码会在换模型时翻车。
训练循环本身和通用分类任务没有太大区别,但建议加两样东西:按类别权重加权损失函数,以及早停机制。
from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder train_set = ImageFolder("data/train", transform=transform_train) val_set = ImageFolder("data/val", transform=transform_eval) # 类别不平衡时给损失函数加权重,比如肿瘤样本是 5%,权重就按比例调 labels = [train_set.targets[i] for i in range(len(train_set))] class_counts = torch.bincount(torch.tensor(labels)).float() class_weights = 1.0 / class_counts class_weights = class_weights / class_weights.sum() loss_fn = nn.CrossEntropyLoss(weight=class_weights.to(device)) train_loader = DataLoader(train_set, batch_size=16, shuffle=True, num_workers=4) val_loader = DataLoader(val_set, batch_size=16, shuffle=False, num_workers=4) best_val_acc = 0.0 patience = 10 bad_epochs = 0 for epoch in range(50): model.train() train_loss = 0.0 for images, targets in train_loader: images, targets = images.to(device), targets.to(device) optimizer.zero_grad() outputs = model(images) loss = loss_fn(outputs, targets) loss.backward() optimizer.step() train_loss += loss.item() * images.size(0) model.eval() val_correct = 0 val_total = 0 with torch.no_grad(): for images, targets in val_loader: images, targets = images.to(device), targets.to(device) outputs = model(images) preds = outputs.argmax(dim=1) val_correct += (preds == targets).sum().item() val_total += targets.size(0) val_acc = val_correct / val_total print(f"Epoch {epoch+1:02d}, Train Loss: {train_loss / len(train_set):.4f}, Val Acc: {val_acc:.4f}") if val_acc > best_val_acc: best_val_acc = val_acc torch.save(model.state_dict(), "resnet18_kidney_best.pt") bad_epochs = 0 else: bad_epochs += 1 if bad_epochs >= patience: print("Early stopping at epoch", epoch + 1) breakclass_weights的计算用的是1 / class_counts,再归一化,让少数类样本的损失贡献相对更大。早停的 patience 设为 10,意味着连续 10 个 epoch 验证准确率不提升就停止,避免在训练集上过拟合后验证指标一路恶化。保存模型时只存state_dict()而不是整个模型对象,方便后续迁移到不同定义环境。模型文件名带上任务名和最优验证指标,这是我自己踩过找不到权重对应数据版本的亏之后养成的习惯。
3.4 训练参数参考表:按数据量调整
| 参数 | 小数据集(每类 < 1000) | 中等数据集(每类 1000~5000) | 说明 |
|---|---|---|---|
| 模型 | ResNet18 | ResNet18 / ResNet50 | 数据量太小不要用大模型 |
| 输入尺寸 | 128x128 或 224x224 | 224x224 | 128 提速明显,但细节较差 |
| batch size | 8 / 16 | 16 / 32 | 显存不够先减 batch,别动图像尺寸 |
| 学习率 | 1e-3(仅 fc) | 1e-4(解冻部分层) | 微调阶段用小学习率 |
| 优化器 | Adam | Adam / SGD | SGD+Momentum 收敛更稳但需要调整周期 |
| 训练轮数 | 30~50 | 50~80 | 配合早停 |
| 数据增强 | 轻量(翻转+旋转) | 常规增强 | 增强过重会让小数据集更难拟合 |
4. 评估与可解释性:敏感度、特异度和 Grad-CAM 才是给医生看的证据
4.1 为什么准确率在医学分类里不够用
影像科医生和算法工程师关注点不一样。工程师习惯用准确率衡量模型,但肾脏肿瘤在筛查人群中占比可能不到 5%,一个全预测“正常”的模型也能轻松获得 95% 的准确率,在指标上好看,在临床上却毫无价值。肿瘤被漏掉会造成严重延误,正常肾脏被误报又会带来不必要的穿刺和检查。所以医学评估必须看敏感度和特异度。敏感度等于真阳性除以真阳性加假阴性,衡量的是“肿瘤患者中有多大比例被正确发现”;特异度等于真阴性除以真阴性加假阳性,衡量的是“正常人有 多大比例没有被误报”。只有两个指标一起用,才能判断一个模型是否真正可用。
4.2 评估脚本:混淆矩阵、分类报告和 ROC-AUC
import torch from torchvision.datasets import ImageFolder from torchvision import transforms from sklearn.metrics import classification_report, confusion_matrix, roc_auc_score import numpy as np # 加载测试集 transform_eval = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) test_set = ImageFolder("data/test", transform=transform_eval) # 加载最优权重 model.load_state_dict(torch.load("resnet18_kidney_best.pt", map_location="cpu")) model.eval() all_labels = [] all_probs = [] all_preds = [] with torch.no_grad(): for images, targets in DataLoader(test_set, batch_size=32, shuffle=False): outputs = model(images) probs = torch.softmax(outputs, dim=1) preds = probs.argmax(dim=1) all_probs.extend(probs[:, 1].numpy()) # 假设类别 1 是肿瘤 all_preds.extend(preds.numpy()) all_labels.extend(targets.numpy()) print("混淆矩阵:") print(confusion_matrix(all_labels, all_preds)) print("\n分类报告:") print(classification_report(all_labels, all_preds, target_names=test_set.classes)) print("ROC-AUC:", roc_auc_score(all_labels, all_probs))注意all_probs里取的是probs[:, 1],这是假设类别 1 是肿瘤,适用于二分类。如果你的类别超过两类,AUC 需要做 OvR(一对多)处理,比如roc_auc_score传入multi_class="ovr"。classification_report会输出每一类的精确率、召回率和 F1,其中召回率就是敏感度。医生一般关心肿瘤这一行的召回率,如果这个数字低于 90%,模型基本不能进入辅助诊断流程。
4.3 用 Grad-CAM 让模型说出“模型看的是肾还是肾周脂肪”
模型表现好还不够,医生要看到模型“为什么这样判断”。Grad-CAM 是最常用的可视化手段,思路是对最后一个卷积层的特征图按梯度加权求和,生成反映模型关注区域的热力图。肾周脂肪和肿瘤在 CT 上可能亮度接近,模型有时会把脂肪误学成关键特征,热力图能帮你快速发现这种错误。
import cv2 import numpy as np def grad_cam(model, input_tensor, device, target_layer): """简化版 Grad-CAM,只支持单张图像输入""" grads = {} activations = {} def forward_hook(module, input, output): activations["value"] = output.detach() def backward_hook(module, grad_input, grad_output): grads["value"] = grad_output[0].detach() hook1 = target_layer.register_forward_hook(forward_hook) hook2 = target_layer.register_full_backward_hook(backward_hook) output = model(input_tensor.unsqueeze(0).to(device)) model.zero_grad() class_idx = output.argmax(dim=1).item() output[0, class_idx].backward() act = activations["value"].squeeze(0) # [C, H, W] grad = grads["value"].squeeze(0) # [C, H, W] weights = grad.mean(dim=(1, 2), keepdim=True) # 全局平均池化得到权重 cam = (weights * act).sum(dim=0).cpu().numpy() cam = np.maximum(cam, 0) # ReLU 截断负值 cam = cv2.resize(cam, (224, 224)) # 缩放到与原图一致 cam = (cam - cam.min()) / (cam.max() - cam.min() + 1e-8) return camregister_forward_hook和register_full_backward_hook分别捕获目标层的输入输出和反向传播梯度。weights * act是加权求和的过程,最后归一化到 0~1 区间。这里用的目标层一般是model.layer4[-1],也就是最后一个残差块的输出,特征图空间分辨率 7x7,能保留足够的细节又不过于碎片化。可视化时把热力图叠加到原图上,用半透明效果展示,医生一眼就能看出模型到底是在看病灶还是看旁边的血管。
register_full_backward_hook是 PyTorch 1.8 之后推荐使用的接口,老代码里的register_backward_hook在 1.8 之后会告警,在某些版本里还不生效。如果你用的是旧版本 PyTorch,建议先升级到 2.x。
5. 避坑与排查:肾脏分类项目里最常翻车的五个场景
5.1 推理时预测结果全乱,类别 id 完全错位
现象:训练集上验证准确率正常,但拿到独立测试文件夹做推理时,预测出来的类别名和图像内容完全对不上。明明看起来是肾脏肿瘤的图,预测结果却显示 normal。
原因:训练时用的是ImageFolder自动按字母序生成的class_to_idx,但推理脚本里手工写了一个类别顺序列表,比如把kidney_tumor放在了索引 0 的位置。两个 id 映射不一致,模型输出的 logits 索引和现实类别自然对不上。
解决:把class_to_idx.json当成模型的必要组成部分,和权重文件放在一起。推理脚本启动时用json.load()读入字典,然后用{v: k for k, v in class_to_idx.items()}生成反向映射来显示类别名。顺带养成的检查习惯是:训练结束后打印一次train_set.class_to_idx,推理前打印一次读入的字典,两个必须完全一致。这个排查只需要 30 秒,但能省下半天。
5.2 验证集准确率虚高,一上医院数据就失效
现象:本地验证集准确率 98%,医生拿一批新扫描的图像来测试,准确率掉到 70% 以下,而且错误集中在漏检肿瘤。
原因:数据泄漏。很多肾脏数据集来自 CT 或 MRI 的连续层切,同一个病人的几十张切片之间高度相似。如果只是按图片随机划分,部分层切会同时进入训练集和验证集,模型相当于提前看到了答案。另外有些数据集做过增强,增强版本混进了验证集,也会导致虚高。
解决:划分数据时按病人 ID 分组,保证同一个病人的所有切片只出现在一个划分里,不能既在训练集又在验证集。做法是在文件名里带上病人编号,比如patient_001_slice_023.png,按下划线第二位作为 group key,用GroupShuffleSplit代替train_test_split。如果原始文件名没有病人信息,需要先找数据提供方补齐,这一步没有捷径。
5.3 训练 Loss 不降,先检查预处理而不是调整网络结构
现象:模型训练了十几个 epoch,Loss 几乎不动,准确率徘徊在 30%~40%,和瞎猜差不多。
原因:两个高发问题。一是灰度图复制成三通道后,没有重新统计本数据集的均值和标准差,仍然沿用 ImageNet 的mean=[0.485, 0.456, 0.406]。CT 图像数值分布和自然图像差异极大,归一化后很多信息被压缩掉了,梯度没法有效传播。二是RandomRotation角度设得过大,比如 90 度,导致肾脏本身的解剖方位被翻转,特征失去意义。
解决:先用一小批数据可视化检查送入模型之前的 tensor 长什么样。具体做法是取几张图做transform,然后torchvision.utils.save_image导出,肉眼确认图像没有变成全黑或者细节被抹掉。更严谨的做法是计算自己数据集的 mean 和 std,用全量数据扫描一遍再填进Normalize里。这两步做完再考虑是不是学习率的问题。
5.4 肿瘤一个都抓不到,模型全预测为正常
现象:训练结束后,验证集的整体准确率看着有 90% 以上,但单独看肿瘤那一类的混淆矩阵,真阳性几乎为零,敏感度只有个位数。
原因:类别不平衡。肿瘤样本占比太低,模型发现全预测正常能得到较低 loss,就走上了偷懒路线。更麻烦的是,如果你按照整体准确率来做早停,那些只优化多数类的模型反而被保留下来,越训练越偏。
解决:除了在损失函数里加类别权重,早停的监控指标要从准确率改成少数类的敏感度,或者用 ROC-AUC 这类对类别不平衡更稳健的指标。另外可以在每次 epoch 结束后打印每一类的召回率,肉眼监控少数类的变化趋势,而不是只看一个整体准确率。类别权重调大后,如果训练集 loss 下降到接近 0 但验证集变差,说明模型过拟合了少数类,此时应减少训练轮数并配合增强。
5.5 换了一台电脑预测结果崩溃,图像读取和预处理不一致
现象:训练机上预测正常,把权重、字典和推理脚本拷到同事的 Windows 电脑上,同样的图片预测结果完全不同。
原因:图像读取路径不一致。训练时用 PIL 读取 RGB 图像,推理脚本用 OpenCV 的cv2.imread读取,OpenCV 默认返回 BGR 通道顺序,这个顺序不一致会彻底改变模型输入;还有一种情况是训练时图像后缀是.png,推理时拿到的是同名.jpg,两种格式的压缩损耗不一样,也会带来微小偏差。
解决:训练和推理的transforms用同一段代码,复制粘贴而不是重写;所有图像统一用 PIL 配合Image.open(...).convert("RGB")读取,不混用 OpenCV 和 PIL。这两条做好,跨机器的输入差异基本能消除。再进一步,可以把 transform 序列化到训练权重同目录的 JSON 配置里,推理脚本直接读配置,从根本上避免两边各自维护一份预处理代码。
6. 从训练到落地:推理脚本与下一步怎么升级
6.1 把模型、类别字典和预处理打包成一个推理脚本
训练完成不等于交付。我一般会写一个独立的推理脚本,把模型权重、类别字典、图像预处理全部在脚本内部装配好,确保新机器上也能直接从单张图片得到结果。
import json import torch from PIL import Image from torchvision import transforms from torchvision import models class KidneyPredictor: def __init__(self, weight_path, dict_path, device="cpu"): self.device = device with open(dict_path, "r") as f: self.class_to_idx = json.load(f) self.idx_to_class = {v: k for k, v in self.class_to_idx.items()} num_classes = len(self.class_to_idx) self.model = models.resnet18(weights=None) self.model.fc = torch.nn.Linear(self.model.fc.in_features, num_classes) self.model.load_state_dict(torch.load(weight_path, map_location=device)) self.model.to(device) self.model.eval() def predict(self, image_path): img = Image.open(image_path).convert("RGB") img_tensor = transform_eval(img).unsqueeze(0).to(self.device) with torch.no_grad(): probs = torch.softmax(self.model(img_tensor), dim=1) pred_idx = probs.argmax(dim=1).item() return self.idx_to_class[pred_idx], probs[0, pred_idx].item() transform_eval = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) predictor = KidneyPredictor("resnet18_kidney_best.pt", "class_to_idx.json") cls, conf = predictor.predict("data/test/kidney_tumor/sample.png") print(f"预测类别: {cls}, 置信度: {conf:.3f}")这个脚本有个容易被忽略的点:num_classes是从类别字典长度推导出来的,而不是硬编码。如果未来类别数量变化,脚本依然能跑。transform_eval必须和训练时完全一致,特别是Resize的尺寸和Normalize的参数。如果你想做更严谨的结果报告,可以把输出改成按置信度排序的 Top-3 列表,对医生来说比单一结果更有参考价值。
6.2 从分类走向分割和更细粒度诊断
这个方向的价值不止于二分类和三类分类。我见过不少团队把这套章节流程跑通后又往前迈了一步:先用 YOLOv8 检测肾脏区域,再把检测框裁剪出来送入 ResNet 分类,相当于做了一个两阶段诊断流水线,能有效过滤肾脏周围组织的干扰。更进一步是训练 U-Net 分割模型,把肿瘤区域分割出来,再统计体积、直径等定量特征,这些指标是临床报告里真正被采纳的内容。当前大模型和医学多模态模型也在加速这个领域,与其换一个大模型重新做迁移学习,我更建议把数据质量、标签规范、评估口径这些基础设施先打扎实,这是任何算法都绕不开的地基。我现在拿到任何一份新的分类数据,第一步永远是打印一遍class_to_idx和类别分布,而不是急着跑训练脚本,这个习惯让我在所有项目里都少走了很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取