
简介面向PyTorch迁移学习入门者及图像分类项目开发人员以皮肤病图片二分类为实际病例场景完整提供了基于ResNet50迁移学习的工程代码。工程代码模块划分清晰data_gen.py负责数据划分、增强与批次加载transform.py实现图片预处理main.py串联训练、评估与测试流程args.py集中管理运行参数并支持train/test两种模式models/Res.py重写了ResNet系列网络结构utils.py封装公共工具函数checkpoints目录用于保存模型结构清晰便于二次开发。整个压缩包共228个文件涵盖200张皮肤病原始样张、9个Python源码、12个pyc编译文件、1个已训练好的pth权重、1个结果csv及若干说明文档整体大小136.85MB拿到手即可通过python main.py --modetrain开始训练或执行--modetest加载权重快速验证。目前已有627人浏览学习适合希望借助完整病例项目掌握迁移学习流程、数据组织与模型部署要点的读者也可作为课程设计或技术预研的参考。1. 从ISIC皮肤镜数据到ResNet50为什么迁移学习能救二分类把ISIC_0004020.jpg这样的皮肤镜图像直接塞进随机初始化的ResNet50训练loss能降到很低但验证准确率大概率卡在75%附近——这是典型的小样本过拟合。而换上ImageNet预训练权重同样的数据、同样的epoch验证准确率能冲到85%以上。眼前这套PyTorch项目做的就是这件事用ResNet50迁移学习在皮肤病变图上做良恶性二分类最终把每个测试样本的预测结果写成result.csv。它比较适合正在应付人工智能大作业、需要快速跑通baseline的从业者也适合想搞明白迁移学习边界在哪的人。值得说清楚的是这个项目并不是“套壳”代码它的数据加载、模型改写和训练流程都有值得逐行拆解的部分。2. 先拆代码骨架args.py、data_gen.py、models/Res.py在迁移学习中各管什么2.1 args.py用命令行参数把训练和测试收口一个迁移学习项目的可复现性往往取决于参数层设计。很多初学者把batch_size、学习率、模型保存路径写死在main.py里每次换数据集都要改源码这个项目选择用args.py统一管理这是比较正确的做法。以下是去掉注释后的核心结构# args.py import argparse def get_args(): parser argparse.ArgumentParser() parser.add_argument(--mode, typestr, defaulttrain, choices[train, test], help运行模式train or test) parser.add_argument(--model_path, typestr, default./checkpoints/best_model.pth, help训练好的模型文件路径) parser.add_argument(--batch_size, typeint, default32, help批量大小按显存调整) parser.add_argument(--epochs, typeint, default50, help训练轮数) parser.add_argument(--lr, typefloat, default0.001, help学习率) parser.add_argument(--num_classes, typeint, default2, help二分类任务类别数) parser.add_argument(--img_size, typeint, default224, helpResNet50标准输入尺寸224x224) parser.add_argument(--freeze_layers, typebool, defaultTrue, help迁移学习是否冻结卷积基) return parser.parse_args()几个关键字段值得展开。--mode在一个入口文件里同时调度训练和测试能少写两个脚本--model_path在训练模式下是保存路径在测试模式下是加载路径这个“双向使用”设计直接影响到后续main.py的流程。--img_size固定为224是为了匹配ResNet50的全局池化层输入如果你换用ResNet101或EfficientNet需要同步改。--freeze_layers是这个项目作为迁移学习demo最重要的开关。当它为True时模型除最后一层分类头以外全部冻结只训练新增的全连接层训练速度快很多为False时则全网络参与微调效果上限更高但更容易过拟合。在皮肤镜这类小数据集上我一般倾向于先开True跑通再用False微调几个epoch。2.2 data_gen.py划分数据集与数据增强的落地写法这个脚本负责把ISIC_0004020.jpg这类图像转成可训练的DataLoader。皮肤镜图像最麻烦的点在于类内差异大且样本少直接原图训练模型很容易记住背景色而不是病灶纹理。因此data_gen.py里通常同时做了两件事随机划分数据集和定义训练/验证两套不同的transform。# data_gen.py 中的Dataset骨架 from torch.utils.data import Dataset, DataLoader from torchvision import transforms from PIL import Image import os class ISICDataset(Dataset): def __init__(self, image_paths, labels, transformNone): self.image_paths image_paths self.labels labels self.transform transform def __len__(self): return len(self.image_paths) def __getitem__(self, idx): img Image.open(self.image_paths[idx]).convert(RGB) if self.transform: img self.transform(img) return img, self.labels[idx]这里有一个很难察觉但特别致命的细节图像必须是RGB三通道。ISIC原始数据里有部分灰度或带透明度通道的PNG如果直接Image.open(...).convert(RGB)能把这部分数据统一成三通道否则ResNet50的BatchNorm层会在前向传播时报通道数不匹配。另一个细节是读取图像后不要用np.asarray转成numpy再转Tensor否则不仅多占内存还会丢失与transform的兼容性。完整的data_gen.py还会实现一个split_dataset函数通常用sklearn.model_selection.train_test_split按照固定随机种子划分。为什么强调固定种子因为皮肤病数据集的标签分布不均衡不固定种子每次跑出来的验证集都不同实验对比就没有意义。我建议在训练和验证之间采用分层抽样尽量让两个类别在划分后的比例与全量一致这一点比简单随机划分更稳。训练集transform一般包括RandomResizedCrop、RandomHorizontalFlip、RandomRotation和ColorJitter验证集只需要Resize和CenterCrop。注意增强不是越强越好皮肤病的旋转和缩放是有临床边界感的旋转超过20度或翻转可能让病灶形态失去原本的医学意义。这个项目的数据增强默认值如果有困惑优先按小幅度参数调。2.3 models/Res.pyResNet50的“换头术”如摘要描述所说models/Res.py是“重写的ResNet各种类型的网络”。从迁移学习的角度这里的核心操作是把torchvision里的ResNet50拿过来替换最后一层全连接层同时提供是否冻结卷积基的选择。下面是一段非常接近项目本质的实现# models/Res.py 迁移学习模型构建 import torch.nn as nn from torchvision import models def get_resnet50(num_classes2, freeze_backboneTrue): model models.resnet50(pretrainedTrue) if freeze_backbone: for param in model.parameters(): param.requires_grad False in_features model.fc.in_features model.fc nn.Linear(in_features, num_classes) return model这段代码只需要解释两行。pretrainedTrue表示加载在ImageNet上训练好的权重此时模型最后一层fc是1000类model.fc.in_features取出原分类头输入的维度一般是2048然后替换成一个新的Linear层让输出变成2类。requires_gradFalse是“冻结”的本质PyTorch的优化器在计算参数更新时只关心requires_gradTrue的参数因此被冻结的层参数在反向传播后不会更新。如果只是想把整套代码跑通用这个结构就够了。但要理解什么是真正的迁移学习还需要知道冻结卷积基其实等于把ImageNet训练好的特征提取能力原封不动搬过来新分类头只负责在皮肤病的特征分布上做一次二分类线性划分。这样训练速度快且不容易在小数据集上翻车。反过来如果freeze_backboneFalse所有层都会微调模型会花费较多时间在低层特征上“打转”在数据量不够时反而容易退化。3. main.py训练主流程损失函数、优化器与评估策略3.1 损失函数与优化器的配置细节二分类问题通常有两种标签编码方式第一种是单通道标签类别为0/1配合交叉熵损失第二种是独热编码配合BCEWithLogitsLoss。这个项目用的是CrossEntropyLoss意味着标签是整数索引。下面这段是main.py里最核心的训练配置逻辑# main.py 训练配置 import torch import torch.nn as nn import torch.optim as optim from models.Res import get_resnet50 model get_resnet50(num_classesargs.num_classes, freeze_backboneargs.freeze_layers) criterion nn.CrossEntropyLoss() optimizer optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lrargs.lr) scheduler optim.lr_scheduler.ReduceLROnPlateau(optimizer, modemin, factor0.1, patience3)这里值得说清楚的是filter(lambda p: p.requires_grad, model.parameters())这个写法。在迁移学习场景里冻结层后如果直接optimizer optim.Adam(model.parameters(), lrargs.lr)Adam仍会记录冻结层的参数并计算其梯度虽然实际不会更新但会浪费显存且打印日志时信息混乱。加上filter后优化器只观察可训练参数。在freeze_backboneTrue时这会让参数量从约2500万骤降到约200万训练速度提升非常明显。为什么不用SGD而用Adam对于皮肤病二分类这类中小规模数据集Adam的收敛速度和对学习率的鲁棒性都更友好。SGD需要精细调整momentum和weight decay往往要在迁移学习场景下配多个schedule才能接近Adam的效果。如果项目目标是快速出一个可靠baselineAdam就是合理的起点。ReduceLROnPlateau会在验证loss连续3个epoch不下降时把学习率乘上0.1这是一个稳妥的学习率调度方式比固定值训练更容易逼近收敛点。3.2 一个完整epoch里的训练与验证逻辑训练脚本不会每张图单独更新权重而是按batch循环。下面这段代码描述的是每个epoch内的通用结构实际使用时只需要把它放进for循环即可# main.py 单epoch训练与验证 model.train() for images, labels in train_loader: images, labels images.cuda(), labels.cuda() optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) model.eval() with torch.no_grad(): correct 0 total 0 for images, labels in val_loader: images, labels images.cuda(), labels.cuda() outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item()关键细节在model.eval()。很多初版代码只写with torch.no_grad()却忘了model.eval()导致dropout和BatchNorm仍然按训练模式运行验证loss会忽高忽低。在ResNet50里BatchNorm有累积的running_mean和running_vareval模式下会使用这些累积统计量训练模式则用当前batch统计量。两者混用迁移学习的验证阶段就会失真。所以这个模式切换不是可选项而是必选项。torch.max(outputs, 1)取的是每个样本在2个类别上的最大得分索引也就是预测标签。注意这里没有用softmax因为CrossEntropyLoss内部已经做了log_softmax对于argmax结果没有任何影响。如果下游想输出置信度再用torch.softmax(outputs, dim1)。3.3 类别不平衡二分类里的“隐藏陷阱”ISIC数据集中恶性黑色素瘤通常是少数类如果直接按原始比例训练模型会把多数类准确率拉得很高整体准确率看似有90%但对少数类的召回率可能只有20%。这在皮肤病变筛查里是完全不可用的。一个简单的做法是在训练集上计算类别权重然后把权重传给CrossEntropyLoss# 类别权重缓解不平衡 class_weights torch.tensor([0.4, 0.9]).cuda() criterion nn.CrossEntropyLoss(weightclass_weights)其中0.4和0.9是根据类别频率反比调整的示例值。给少数类更大的权重让它在loss中的贡献变大从而避免模型忽略少数类。更正式的做法是用sklearn.utils.class_weight.compute_class_weight但在这个项目里手动调整更直观。需要注意加了类别权重后训练loss的绝对值会变大这属于正常现象不要认为模型没收敛。WeightedRandomSampler也是一种处理不平衡的常用方式它会根据样本权重多采样少数类样本。两者的区别是loss加权不改变数据分布更稳定采样式则让每个batch中的类别比例近似均衡训练过程对minority class的反应更直接。对于这个项目先做loss加权如果验证集少数类性能仍不够再换WeightedRandomSampler。4. 从ISIC原图到result.csv完整复现训练与测试流程4.1 环境准备与目录结构假设你是在新机器上复现第一步是安装PyTorch环境和相关依赖。这里给出一个可信任的配置顺序在Ubuntu或Windows下都适用conda create -n skin python3.9 conda activate skin pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 pip install pandas numpy tqdm scikit-learn matplotlib参数说明PyTorch版本需要和CUDA版本配套如果机器没有NVIDIA GPU把--index-url换为CPU版本即可训练速度会慢很多但代码逻辑一致。torchvision需要单独安装因为models.resnet50是从torchvision里加载的不装torchvision就无法使用预训练模型。tqdm用来显示训练进度在长迭代时能节省很多等待时的焦虑感。目录方面这个项目天然包含checkpoints用来保存模型文件。把解压后的ISIC图片统一放到data/目录并在data/下建train/和test/两个子目录或者沿用代码自带的根目录结构否则data_gen.py读取路径会报错。image文件名保持ISIC_0004020.jpg这种格式即可不用额外重命名。4.2 训练命令与观测指标环境就绪后直接运行项目入口脚本cd 项目根目录 python main.py --modetrain --freeze_layersTrue --epochs50 --batch_size32 --lr0.001这条命令对应第2章和第3章讲到的所有配置。--freeze_layersTrue启动后模型会先加载ImageNet权重然后把除fc层以外的层冻结所以前几个epoch的训练非常快。--epochs50并不代表一定需要50轮配合ReduceLROnPlateauloss可能在20轮附近就收敛。训练过程中你会注意到几个现象。第一训练loss下降幅度明显小于从头训练因为只有分类头在更新它只是在一个高质量特征空间里做线性拟合第二验证准确率可能在第10轮左右就进入平台期此时不要停下来因为冻结训练后的模型尚未微调整体泛化边界还有提升空间第三checkpoints目录下会生成多个模型文件最好记录下每个epoch对应的验证准确率。如果训练过程中出现loss为nan优先检查学习率是否过大然后检查图像预处理是否出现全黑或全白输入。ISIC图像里偶尔有极端暗场景归一化前如果存在除零问题确实可能产生nan。把训练集里的极端像素分布打印出来是比较快的诊断方法。4.3 测试阶段从模型文件到result.csv训练完成后进入测试模式这里的命令与训练只差一个mode和model_pathpython main.py --modetest --model_path./checkpoints/best_model.pth测试脚本会加载指定模型权重遍历测试集生成两列CSV文件。常见的格式如下image_id, target ISIC_0004020.jpg, 0 ISIC_0003992.jpg, 1 ISIC_0004064.jpg, 0其中target是0/1整数0通常表示良性1表示恶性。这取决于项目在构建标签时怎么映射目录名。如果要在自己的数据集上复现需要在create_dataset.py里确认标签字典否则CSV里的数字和临床含义会对不上。测试阶段有一个容易踩的坑测试集如果也做数据增强结果会不稳定。正常做法是只使用transform.py里验证集的预处理流程即Resize到256、CenterCrop到224、归一化。不要用RandomRotation或RandomHorizontalFlip。因为测试是对单张图像做确定性预测增强会让同一样本在多次预测时得到不同概率。若你的业务场景需要更稳定的置信度可以改用TTATest-Time Augmentation但那是更进阶的做法后面第5章会提到。5. 冻结层、阈值移动与混淆矩阵皮肤二分类的进阶验证技巧5.1 从“只训分类头”到“全层微调”的过渡策略基线模型跑完后想要继续提升效果最推荐的做法是解冻高层卷积做短周期微调。ResNet50的layer4负责比较语义化的纹理特征比如皮肤病灶的边界、颜色分布和结构这些特征跟皮肤病诊断高度相关。把layer4解冻写成# 解冻layer4冻结layer1~layer3 for name, param in model.named_parameters(): if layer4 in name or fc in name: param.requires_grad True else: param.requires_grad False这时把学习率调低到0.0001训练5到10个epoch。为什么要单独解冻layer4而不是解冻全部因为layer1到layer3学的是边缘、颜色和纹理基元ImageNet提供的这部分通用特征在皮肤镜图像上已经足够可靠layer4与ImageNet的语义更接近微调它收益最大风险最小。在皮肤数据集上这套策略通常能把验证准确率再提升一到两个百分点。5.2 二分类阈值不是固定0.5模型默认输出两个类别的概率取0.5作为阈值意味着默认良性与恶性样本的代价相等。但筛查场景里漏诊恶性病例的代价远高于把良性误判为可疑因此可以在验证集上搜索更合理的概率阈值。比如观察测试集置信度分布python analyze_probs.py # 或者用jupyter notebook读取result.csv实际操作方法很简单打印出每个测试样本的恶性概率然后在验证集上计算不同阈值下的F1分数选出F1最大的阈值。假设验证集上0.42能取得更高F1那么测试时就把预测边界从0.5移动到0.42。之所以能这样调是因为二分类的CrossEntropy输出经过softmax后0.5只是“两个类别概率相等”的默认点而迁移学习模型经过分类头变换后概率分布往往会偏移0.5。这在类别不平衡时尤其常见。5.3 用混淆矩阵定位模型的系统性偏置准确率是迁移学习项目中最容易骗人的指标。在皮肤病二分类中我建议最后一步做混淆矩阵可视化看false positive和false negative分别集中在哪些图像。简单的思路是借用sklearn的confusion_matrix生成四格数据配合matplotlib输出重点关注两类错误把恶性判成良性的漏诊以及把良性判成恶性的误报。如果漏诊集中在有毛发遮挡的皮肤镜图像上说明增强里缺少对遮挡的模拟如果误报集中在炎症明显的良性样本上说明模型还没学会区分炎症和恶性病灶的细微特征。把这些样本挑出来回看往往比盲目调参更有收获。这个项目的最终成果代码已经包含了必要的工具函数把这个验证流程接进去整个迁移学习项目才算真正收口。本文还有配套的精品资源点击获取