
简介一套基于残差网络ResNet的二维图像简单多分类完整项目代码包面向深度学习初学者与图像分类实践者也适合用于课程设计或小规模实验参考。项目完整覆盖数据预处理、图像增强、独热标签生成、模型构建、训练评估与可视化流程共含20个文件12个Python脚本、6个编译生成的pyc文件以及2张示例图片压缩包仅382KB结构紧凑便于快速浏览。各脚本分工明确数据处理与增强脚本负责整理输入核心模型脚本定义残差块和网络结构训练主程序串联整体流程评估与可视化模块帮助检查指标和中间结果从目录整理到划分训练集、验证集与测试集再到验证评估与预测结果后处理均形成了可复用的实验闭环。工具脚本还提供进度显示、日志记录等辅助功能方便观察运行状态和调试排错。已有215人学习下载对于希望掌握二维图像分类完整实现细节的学习者而言这是一份轻量且内容完整的上手资源。1. 基于resnet的2D图像简单多分类什么场景下你会需要这份完整版很多人拿到一批2D图像第一反应是“用resnet跑个多分类应该很轻松”但真动手才发现数据文件夹怎么摆、预训练模型怎么改最后一层、loss为什么一直不降、验证集acc虚高是怎么回事——网上教程只讲片段拼起来却处处对不上。这篇笔记就是围绕“基于resnet的2D图像简单多分类完整版”这个需求写的从数据准备、模型搭建、训练配置到评估避坑给出一套能直接照做的落地流程。适合刚接触深度学习分类任务的新手也适合想快速验证一个图像分类想法、不想在工程细节上反复折腾的从业者。目标只有一个让你在今天下班前跑出一个带混淆矩阵、带模型文件的完整分类结果。2. 数据准备把2D图像文件夹变成resnet能吃到的张量2.1 目录结构约定与label映射先定好游戏规则常见做法是直接把数据集按类别分文件夹torchvision的ImageFolder会自动扫描子目录名作为类别标签。目录结构大致是data/ train/ cat/ # 类别0 dog/ # 类别1 bird/ # 类别2 val/ cat/ dog/ bird/这个结构的好处是零标注文件文件夹名就是labelImageFolder返回的(image, label)对里label是从类别名字典序自动编号的。对应代码from torchvision import datasets train_dataset datasets.ImageFolder( rootdata/train, transformtrain_transform ) val_dataset datasets.ImageFolder( rootdata/val, transformval_transform ) print(train_dataset.classes) # [bird, cat, dog] print(train_dataset.class_to_idx) # {bird: 0, cat: 1, dog: 2}逻辑说明ImageFolder的label编号按类别名的字母序不是按你文件夹的排列顺序。class_to_idx是后续做混淆矩阵和模型推理时最重要的字典建议训练前打印出来存好不然推理阶段你根本不知道模型输出的index对应什么类别。参数说明root填到类别的上一层目录即可不用手动遍历子文件夹。如果训练集和验证集来自同一个文件夹的不同子集建议先用os.listdir按比例划出train/val各一份再交给ImageFolder注意随机划分前先random.shuffle不然按顺序切分会把同一类别的图像全切到验证集。2.2 transforms怎么配别让预处理吃掉你的准确率ResNet系列模型的输入尺寸统一是224x224Normalize必须用ImageNet的mean和std因为加载的预训练权重是在ImageNet上训出来的输入分布要尽量对齐。一个常见的误区是只Resize忘了Normalize或者用了自己的mean/std结果就是模型输出概率分布特别离谱大概率偏向某一个类别。推荐配置from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.8, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transform transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])逻辑说明训练集增强用了RandomResizedCrop在0.8到1.0的尺度范围内随机裁剪再缩放到224比固定Resize(224)少一点过拟合RandomHorizontalFlip在物体分类里几乎是无损增强但如果你的图片存在方向强语义比如车牌、文字这个增强要关掉。验证集不增强只用Resize(256) CenterCrop(224)这是ImageNet评测的标准做法比直接Resize(224)保留更多边缘信息。参数说明scale(0.8, 1.0)控制裁剪面积占原图的比例类别内差异大可以再降到0.6ColorJitter的数值在小数据集上建议开小一点太大反而让模型学不到稳定特征。2.3 训练集/验证集划分和DataLoader参数设置数据量少的时候验证集不能太小一般按类别分层抽出20%做验证。DataLoader的batch_size和num_workers直接影响训练速度和显存占用from torch.utils.data import DataLoader train_loader DataLoader( train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue, drop_lastTrue ) val_loader DataLoader( val_dataset, batch_size64, shuffleFalse, num_workers4, pin_memoryTrue )逻辑说明训练集shuffleTrue让每个epoch的batch顺序都不一样避免模型记住batch顺序验证集shuffleFalse评估时按固定顺序过一遍。drop_lastTrue在训练时丢掉最后不足一个batch的数据这样BN层的统计量更稳定如果是小数据集这个参数可以关掉。参数说明num_workers在Linux上可以设成CPU核心数的一半Windows上建议设为2设太高有时反而因为进程调度开销变慢pin_memoryTrue配合GPU训练能减少CPU到GPU的拷贝时间但会占额外内存内存小就关掉。3. 模型搭建resnet预训练模型怎么改造成自己的分类头3.1 选ResNet哪一档18/34/50怎么权衡torchvision里自带resnet18、resnet34、resnet50等预训练权重。简单场景下大家常问是不是层数越深越好答案是否定的。我在小数据集上做过对比ResNet18在1万张图以内的分类任务效果和ResNet50基本持平但训练速度和显存占用差别很大。模型参数量2D图像224输入显存占用batch32适合场景ResNet18约1100万约2.5GB小数据集、快速验证、线上推理ResNet34约2100万约3.2GB中等数据量、对准确率有要求ResNet50约2500万约4.5GB大数据集、特征区分度要求高参数说明这里的显存占用是训练模式的实测近似值推理模式会明显更低。如果你的显卡只有6GB显存ResNet18是最稳妥的选择。3.2 预训练权重加载和最后一层替换torchvision加载ResNet预训练模型时权重是ImageNet上1000类分类训练出来的最后一层全连接输出维度是1000必须替换成你自己的类别数。但要注意直接替换后加载权重会报size mismatch因为fc层的权重shape变了。处理方式有两种。import torch import torchvision.models as models model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) # 方式一先替换fc层再整体加载推荐 in_features model.fc.in_features # 512 model.fc torch.nn.Linear(in_features, num_classes) # 方式二加载时忽略不匹配的key # model models.resnet18() # state_dict torch.load(resnet18.pth) # state_dict.pop(fc.weight) # state_dict.pop(fc.bias) # model.load_state_dict(state_dict, strictFalse) # model.fc torch.nn.Linear(in_features, num_classes)逻辑说明方式一直接把fc替换成512到num_classes的线性层加载weights时torch会自动跳过shape不匹配的层这是最省事的写法。方式二适合手动下载了pth文件、想自己管理加载过程的情况strictFalse的含义是允许部分key匹配不上但需要注意这样会把没匹配上的层保留随机初始化状态。参数说明weightsmodels.ResNet18_Weights.IMAGENET1K_V1是torchvision新版的推荐写法老教程里常见的pretrainedTrue在最新版本会告警。in_features从model.fc.in_features取不要硬编码512因为换了ResNet34就变成512ResNet50则变成2048。3.3 2D图像不需要堆叠成3D但这个思路什么时候有用近段时间有个讨论热度不低的方向是“把2D图像堆叠为3D图像再分类”比如同一场景多个视角的帧、CT切片或者视频帧序列这类输入确实该用3D卷积。而基于resnet的2D图像多分类我们的输入是单张HxWxC的图像ResNet的卷积核天然就是2D的你把图像堆叠成3D送进ResNet会直接报维度错误。如果你手里恰好是多视角2D图想利用“堆叠为3D”的思路常见做法是换成3D ResNet或者用2D ResNet逐帧提特征再融合。也就是说堆叠3D是给“多帧/多切片”场景准备的不是给单图分类的。在“基于resnet的2D图像简单多分类”这个任务里单张图一步到位不需要中间做堆叠。import torch.nn as nn # 完整模型构建示例 def build_resnet(archresnet18, num_classes3, freeze_backboneFalse): if arch resnet18: model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) in_features model.fc.in_features elif arch resnet50: model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V1) in_features model.fc.in_features if freeze_backbone: for param in model.parameters(): param.requires_grad False model.fc nn.Linear(in_features, num_classes) return model逻辑说明freeze_backboneTrue是迁移学习的标准做法把主干参数冻住只训练新加的fc层适合数据量特别少的情况训练速度快、不容易过拟合。缺点是最终准确率上限往往不如全量微调。参数说明全量微调时requires_grad默认都是True不需要额外设置。新手建议先全量微调跑通流程再尝试freeze_backbone做对比——不要一上来就冻结因为很多人的数据集和ImageNet分布差距大冻结主干反而学不动。4. 训练配置loss、优化器、学习率三个关键旋钮4.1 多分类的loss和标签编码CrossEntropyLoss就够了PyTorch的CrossEntropyLoss已经内置了softmax你不需要在模型最后加nn.Softmax。配合ImageFolder标签本身就是整数索引直接喂给loss就行。常见的翻车点是有人手动把标签转成one-hot再算loss这在PyTorch里属于多此一举还会把维度搞错。criterion nn.CrossEntropyLoss()逻辑说明CrossEntropyLoss接受两个参数模型raw logits和整数标签内部先算softmax再算交叉熵。不建议使用nn.NLLLoss因为NLLLoss要求模型输出已经做过log_softmax多一步手工操作就容易出错。这两个loss函数对于多分类任务数学上是等价的但新手用CrossEntropyLoss踩坑最少。4.2 优化器与学习率策略Adam省心但SGD更耐调两派观点长期存在Adam自适应学习率、收敛快、对学习率不敏感SGD配合momentum最终收敛的泛化能力更好。我的经验是小数据集、快速验证用Adam追求最终精度、有调参时间就用SGD。数据量在几千张这个级别两者差距不会太大关键还是学习率设定。import torch.optim as optim # 方案一Adam默认即可 optimizer optim.Adam(model.parameters(), lr1e-4, weight_decay1e-4) # 方案二SGD momentum 余弦退火 optimizer optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay5e-4) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs)逻辑说明Adam建议从1e-4开始如果你发现loss降得很慢可以调到3e-4但建议不要高于1e-3。SGD的初始学习率在单卡、batch_size32的情况下从0.01起调batch_size越大学习率可以适当放大。参数说明weight_decay是L2正则Adam下1e-4比较中庸SGD下5e-4是常见配置。CosineAnnealingLR把学习率从初始值按余弦曲线降到接近0配合SGD在训练后期提精度很明显如果用了Adam学习率调度可以不用直接用固定学习率也能收敛。4.3 一个能直接跑的train循环含模型保存和早停训练循环是整套流程里最稳定的部分结构基本不变模型切到train模式、遍历数据、前向、算loss、反向、更新参数验证阶段切到eval模式、关梯度、统计acc。下面是完整代码直接复制就能跑import torch from tqdm import tqdm def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, correct, total 0.0, 0, 0 pbar tqdm(loader, desctrain) for images, labels in pbar: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) # raw logits, 不经过softmax loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) pbar.set_postfix(lossloss.item()) return total_loss / total, correct / total def validate(model, loader, criterion, device): model.eval() total_loss, correct, total 0.0, 0, 0 with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() * images.size(0) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total逻辑说明model.train()和model.eval()不是可省的操作——train()开启Dropout和BN的batch统计eval()切换到BN的全局统计并关闭Dropout验证时忘写eval()是准确率虚低的高频原因。outputs.argmax(dim1)取logits里最大值的索引作为预测类别作为分类准确率的计算结果。参数说明preds labels是逐元素比较.sum().item()把它变成数值。loss在求和时乘了images.size(0)目的是按样本数加权平均这样即使最后一个batch不够32也不会拉偏整体loss。best_acc 0.0 epochs 30 device torch.device(cuda if torch.cuda.is_available() else cpu) model build_resnet(archresnet18, num_classes3, freeze_backboneFalse) model model.to(device) for epoch in range(epochs): train_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_acc validate(model, val_loader, criterion, device) print(fEpoch {epoch1:02d}/{epochs} | ftrain_loss{train_loss:.4f} | train_acc{train_acc:.4f} | fval_loss{val_loss:.4f} | val_acc{val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth) print(f - saved best model, val_acc{best_acc:.4f})逻辑说明模型保存用model.state_dict()而不是整个模型对象state_dict占用空间小、加载灵活换Python环境也不容易出兼容问题。判断标准用验证集准确率保存历史最优而不是最后一个epoch这是防止过拟合的“后悔药”。参数说明epochs30是小数据集常见配置搭配早停可以不用太担心过拟合。如果你的训练loss还在明显下降但val_acc已经不再涨可以提前终止如果30轮后val_acc还在缓慢上升把epochs加到50。最佳实践是存下来最优模型训练结束后统一做一次完整测试集评估不要用训练过程中的val_acc决定模型好坏——验证集参与过调参测试集才是最终裁判。4.4 早停什么时候该停止训练5. 常见问题与避坑resnet多分类我翻过的车都在这里5.1 验证集准确率远高于正常水平明显不合常理现象训练几轮后验证集acc直接到98%但心里清楚这批数据没那么简单。原因数据集划分不合理比如train和val来自同一批数据的连续切片或者混淆矩阵显示模型错得很有规律。解决先复查val_dataset里有没有和train_dataset重复的图片再检查划分代码里是否忘了shuffle。常见的坑是直接用glob.glob按文件名排序后按比例切分——许多数据集文件名是拍摄顺序编号前80%是晴天、后20%是雨天这等于让模型作弊。5.2 训练loss为nan或者前几个batch就爆掉现象loss变成nanacc停在某个固定值。原因学习率过大或者Normalize里的std填成0导致除零或者图片里有纯黑/纯白异常样本。解决先用lr1e-5跑5个batch确认loss正常再逐步放大。另外检查transforms.Normalize的std向量里不能有0标准值[0.229, 0.224, 0.225]是从ImageNet统计出来的不要拍脑袋改。如果数据集里混有损坏图片ImageFolder不会报错但会把损坏图当成全零张量排查时先打印几张dataset[i][0]看是不是全黑或者是异常值。5.3 ResNet输出恒定在类别比例训练不起作用现象模型预测结果永远是数据量最大的那个类别。原因三种情况——第一model.fc替换后requires_grad被意外设成False优化器根本更新不了最后一层第二optimizer.zero_grad()漏写梯度一直在累计第三标注顺序恰好是类别0占90%。解决直接打印model.fc.weight.requires_grad和optimizer.param_groups[0][params]逐一确认。如果确认requires_gradFalse执行for p in model.fc.parameters(): p.requires_grad True。另一个排查思路是打印每个epoch的outputs标准差如果std值趋于0说明模型坍缩到单一输出和上面原因基本一致。5.4 用了预训练模型反而比从头训练acc更低现象加载ImageNet预训练权重在自己数据上训完只跑了80%换成随机初始化反而有85%。原因数据集和ImageNet分布差异特别大比如医学影像、红外图、线稿预训练权重提供先验反而限制了模型。解决不要死磕预训练用models.resnet18(weightsNone)从头训练学习率可以放大到1e-3。这种翻车在专用领域数据里不算罕见新手普遍有“预训练一定好”的错觉实际跑个对比实验就清楚了。5.5 训练集acc很高但val_acc极低典型的过拟合信号现象train_acc到95%以上val_acc只有70%中间gap非常大。原因数据量太少、增强不够、模型容量过大。ResNet18在1000张图片的小数据集上全量微调只要几个epoch就会把训练集背下来。解决顺序先加重训练增强——把RandomResizedCrop的scale下限降到0.5加上RandomRotation(10)再把weight_decay从1e-4调到1e-3最后才是换成ResNet18或更小的模型。如果这三种手段都试完gap依然很大就该考虑是不是标注本身有噪声抽查一批验证集看看label和图像内容是否匹配。6. 混淆矩阵代码与多分类模型落地的最后一公里训练完模型准确率只能给你一个数值但你要知道模型在哪些类别之间互相混淆这就需要混淆矩阵。下面的代码用sklearn的confusion_matrix生成矩阵配合seaborn可视化这是多分类任务里最常用的评估手段import numpy as np import torch from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns import matplotlib.pyplot as plt all_preds, all_labels [], [] model.eval() with torch.no_grad(): for images, labels in val_loader: images images.to(device) outputs model(images) preds outputs.argmax(dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) print(classification_report(all_labels, all_preds, target_namesval_dataset.classes)) sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabelsval_dataset.classes, yticklabelsval_dataset.classes) plt.xlabel(Predicted) plt.ylabel(True) plt.savefig(confusion_matrix.png, dpi150, bbox_inchestight)逻辑说明classification_report会输出每个类别的precision、recall、f1-score和support这是比混淆矩阵更量化的评估结果。sns.heatmap里annotTrue让每个格子显示数值fmtd表示显示整数不加会报格式错误。输出图保存成PNG配合best_model.pth一起留档。参数说明混淆矩阵的热力图颜色越深代表数量越多。你看矩阵时第一眼看对角线——对角线够亮说明主要类别都分对了再看非对角线最亮的格子那就是模型最容易混淆的类别对。二分类看准确率就够了多分类不看混淆矩阵等于没评估。进阶方向上“基于resnet的2D图像简单多分类”很多实际需求并不止于单图分类。比如同一个物体有多张不同视角的2D图想利用“2D图像堆叠为3D”的思路做更鲁棒的识别常见做法是把多张图沿通道或Batch维度组织用3D卷积网络处理序列特征但这已经超出resnet单模型的能力边界需要换网络结构了。我先建议你把当前这套流程跑踏实——数据分对、训练不崩、能读出混淆矩阵再去碰多视角融合。我自己的习惯是每个项目都存一份train_log.txt把每次实验的epoch数、学习率、增强参数、最终混淆矩阵图全留底避免两个月后回头问“当时那个80%是怎么跑出来的”却无从查起。这些细节在项目初期看起来像额外负担但做过几次对比实验后就明白它们比模型本身值钱。希望帮到你。本文还有配套的精品资源点击获取