拓冰建站拓冰建站
首页 / 资讯中心 / 正文

CNN图片分类实战:李宏毅hw3完整攻略与迁移学习提升

不需要主标题直接从二级标题开始。以下是根据“李宏毅机器学习hw3”定制的博文正文1. 作业拆解与整体思路1.1 李宏毅hw3到底要求我们做什么李宏毅老师的机器学习课程每学期都会布置一系列编程作业hw3是最经典也最考验基本功的一次用卷积神经网络CNN做图片分类。具体任务在不同学期会换汤不换药最常见的是对食物图片进行分类Food-11数据集共11类也有学期换成对人类表情、场景或交通标志进行分类。不过核心目标一直很固定——你需要在没有现成模型直接可用的前提下独立完成“数据处理 → 模型建立 → 训练调参 → 结果评估 → 输出预测”的完整流程。这个作业之所以被大家公认为“分水岭”是因为它不像hw1线性回归和hw2分类问题那样用几层全连接网络就能轻松拿到不错的分数。图像数据的维度高、冗余信息多全连接网络在图像任务上会迅速碰到参数量爆炸和过拟合问题逼迫你去理解什么是卷积、什么是池化、感受野如何变化、通道数为什么要逐层增加而不是简单调用一个现成的API就完事。对初学者来说我的建议很明确这个作业的核心目标不是把准确率刷到99%而是真正搞懂CNN每个组件存在的意义。你能说清楚为什么3×3卷积堆两层比一个5×5卷积更常用为什么池化层不是必须的为什么Batch Normalization能加速收敛——这比最终榜单上的数字重要得多。1.2 模型选型为什么CNN是主线什么时候换预训练模型hw3的评分通常会设置一个baseline一般准确率在0.6~0.7左右和一个strong baseline0.85以上。要过baseline自己动手搭一个浅层CNN完全够了要冲strong baseline大多数人会转向迁移学习用ImageNet上预训练好的ResNet或EfficientNet。这里有一个很多新手会犯的错误一上来就直接套预训练模型跳过了自己搭建CNN的过程。结果就是作业交上去了分数也还行但你问自己“卷积层参数量怎么算”“为什么我的网络收敛这么慢”完全答不上来。我强烈建议至少先用自己手写的CNN跑通整个流程拿到一个稳定可复现的结果再去考虑用预训练模型刷分。选择预训练模型时也需要注意不是模型越大越好。Food-11这个任务只有11个类别图像分辨率普遍不高原始图片尺寸不一通常会被缩放到128×128或224×224用ResNet-152这种超深网络不仅训练速度慢而且在小数据集上很容易过拟合。我在实际测试中发现ResNet-18和ResNet-50在这个任务上的差距往往不超过1%~2%但训练时间差了一倍以上。先选小的跑通流程再逐步加大模型这是最稳妥的路线。1.3 完整技术栈与开发环境hw3的标配环境是PyTorch配合Google Colab的免费GPU。我在实际做这个作业时用到的核心依赖如下# Python 3.8 torch1.13.0 # 或更高版本 torchvision0.14.0 numpy1.21.0 pandas1.3.0 opencv-python4.5.5 pillow9.0.0 matplotlib3.5.0 tqdm4.62.0为什么选PyTorch而不是TensorFlow对于课程作业来说PyTorch的调试体验更好print中间张量的shape非常直观而且torchvision内置了大量预训练模型和标准数据集处理工具省去很多造轮子的时间。另外有一点值得提醒如果你的电脑有NVIDIA显卡务必备好CUDA环境如果没有直接用Google Colab的免费T4 GPU。一个224×224的ResNet-18训练30个epoch在Colab上大约需要20~30分钟在自己电脑CPU上可能跑三四个小时都打不住。别在硬件上跟自己过不去。2. 数据准备与图像预处理2.1 数据集结构分析与读取方式Food-11数据集的结构非常典型训练集、验证集、测试集分别放在不同的文件夹里每个文件夹下按类别再分子文件夹。这种结构其实是最友好的用torchvision的ImageFolder接口可以直接读取不需要手动写路径映射。from torchvision import datasets, transforms train_dataset datasets.ImageFolder( root./Food-11/training, transformtrain_transform ) val_dataset datasets.ImageFolder( root./Food-11/validation, transformtest_transform )这里有一个数据处理上的细节训练集和验证集所用的变换transform是不一样的。训练集需要数据增强验证集只需要缩放到固定尺寸和归一化。如果把数据增强也用到验证集上你会看到验证准确率明显波动因为同一个样本每次评估时都被随机裁剪或翻转了结果不可复现。数据读取还有一个容易被忽略的点——类别不平衡问题。Food-11原始数据集的类别分布基本均匀但如果你做的是其他分类任务务必要先统计一下每个类别的样本数量。类别不平衡会直接影响模型训练后面我会在常见问题部分专门展开讲。2.2 Transform设计数据增强不是为了炫技而是为了泛化在这个作业中transform的设计是整个流程的第一个关键决策点。我见过不少同学用非常激进的数据增强策略——随机旋转90度、随机颜色抖动、RandomPosterize、RandomEqualize全上结果训练损失死活降不下去模型欠拟合了。数据增强的核心逻辑是在保持语义不变的前提下扩大训练样本的分布覆盖范围。比如食物图片你把图片水平翻转它还是一盘菜但如果你旋转90度某些食物可能需要“倒过来吃”这就不符合常理了。所以对于Food-11我最终采用的增强策略如下train_transform transforms.Compose([ transforms.Resize((128, 128)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(15), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2, hue0.1), transforms.RandomAffine(degrees0, translate(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) test_transform transforms.Compose([ transforms.Resize((128, 128)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])需要特别解释一下Normalize这一步。很多人不明白为什么要把图片像素值从[0,1]归一化到[-1,1]甚至更广的范围。原因在于深度网络在训练时对输入的尺度非常敏感未归一化的数据会导致梯度更新不稳定尤其在网络较深时更容易出现梯度爆炸或梯度消失。这里的mean和std用的是ImageNet数据集的统计值对于Food-11这种自然图像数据集来说直接复用是可行的不必自己重新统计。2.3 Dataset与DataLoader的踩坑点当数据准备好之后DataLoader的配置也有一些实用技巧。我第一个踩的坑是num_workers的设置。在Colab上num_workers2就够用了设成8反而会因为进程频繁切换而变慢在自己电脑上建议设为CPU核心数减1。第二个坑是batch_size的选择。图片尺寸128×128、batch_size设为64时显存占用大约在2~3GB之间T4显卡跑起来毫无压力但如果把图片放大到224×224且batch_size设为128显存就会爆掉。显存溢出时优先减小batch_size而不是强行调小图片尺寸。图片尺寸太小会丢失细节信息导致准确率天花板变低。最后是shuffle的设置。训练集必须shuffleTrue否则模型会按照类别顺序批量学习导致每个batch内全是同一类样本梯度更新方向严重偏斜。验证集和测试集一般设置shuffleFalse方便最后输出预测结果时与文件名对应。train_loader torch.utils.data.DataLoader( train_dataset, batch_size64, shuffleTrue, num_workers2, pin_memoryTrue ) val_loader torch.utils.data.DataLoader( val_dataset, batch_size64, shuffleFalse, num_workers2, pin_memoryTrue )pin_memoryTrue这个参数在GPU训练时能减少CPU到GPU的数据传输时间虽然提升不算巨大但白给的速度不要白不要。3. 模型搭建与关键技术选择3.1 从零搭建CNN的完整结构先给出我自己在这个作业中使用并验证过的CNN结构这个结构在Food-11上能达到约72%~75%的验证准确率足以稳稳通过baselineimport torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes11): super(SimpleCNN, self).__init__() self.features nn.Sequential( # Block 1: 128x128 - 64x64 nn.Conv2d(3, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.Conv2d(64, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # Block 2: 64x64 - 32x32 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.Conv2d(128, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # Block 3: 32x32 - 16x16 nn.Conv2d(64, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.Conv2d(256, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), ) self.classifier nn.Sequential( nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Linear(256, 512), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(512, num_classes) ) def forward(self, x): x self.features(x) x self.classifier(x) return x注意Block 3的输入通道我写的是64这是故意的——为了演示一个设计逻辑随着网络加深特征图尺寸减半通道数翻倍。这样参数量增长可控同时每个特征图能表达更丰富的语义信息。这个结构参考了VGG的设计思路全部使用3×3小卷积核堆叠两层后再降采样。为什么不用5×5或7×7的大卷积核因为两个3×3卷积的堆叠拥有与5×5卷积相同的感受野但参数量只有后者的18/25而且中间多了一次非线性变换表达能力反而更强。这就是最经典的“小卷积核替代大卷积核”的思想。3.2 为什么选CrossEntropyLoss和AdamW分类任务最常用的损失函数就是交叉熵损失CrossEntropyLoss这一点没什么悬念。它内部已经包含了Softmax操作所以模型最后一层不需要额外加Softmax直接输出原始的logits即可。criterion nn.CrossEntropyLoss()但优化器的选择值得多说两句。很多教程一开始会让你用Adam默认学习率1e-3这个配置确实普适但并不是最优的。我在hw3上对比过SGD带动量和Adam的实验结果发现一个有意思的现象Adam收敛快但最终准确率往往略低于调好学习率的SGDmomentum0.9。原因是Adam的自适应学习率在训练后期会让参数在最优解附近来回震荡不容易收敛到更平坦的极小值。如果你用SGD千万别用默认学习率1e-3这太大了。SGD(lr0.01, momentum0.9, weight_decay1e-4)配合CosineAnnealing学习率调度器效果非常稳健。现在PyTorch官方推荐的是AdamW它修正了Adam中权重衰减的实现方式。如果你不打算手动精细调学习率用AdamW(lr1e-3, weight_decay1e-4)是一个更省心的选择这也是我在最终方案里采用的组合。optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30)3.3 学习率调整策略和早停机制学习率调度是我认为整个训练过程中最有性价比的一个环节。没有调度策略的模型就像一个固定速度跑步的运动员——要么前期跑得太慢迟迟进入不了状态要么后期冲刺时体力耗尽。CosineAnnealing让学习率从初始值按照余弦曲线逐渐衰减到接近0前期保持较大的学习率快速收敛后期用小学习率微调参数非常丝滑。scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30)如果你希望更灵活一些也可以用ReduceLROnPlateau当验证集loss连续多个epoch不再下降时学习率自动乘以一个衰减系数如0.1。这个策略的好处是不用预先设定总训练轮数适合验证集波动较大的场景。早停机制Early Stopping的目的是防止过拟合。核心逻辑很简单监控验证集准确率如果连续N个epoch没有刷新最好成绩就停止训练并回滚到历史最优模型。我在hw3中设定的是patience5也就是连续5个epoch验证准确率没有创新高就提前结束训练。这在用预训练模型微调时尤其重要因为迁移学习通常在10~15个epoch内就能达到峰值再继续训练纯属浪费计算资源。4. 训练过程与调参实测4.1 关键超参数配置参考以下是我在hw3中最终使用的完整超参数配置实测在Food-11验证集上达到约74%的准确率参数名取值说明输入图片尺寸128×128平衡信息量与计算开销Batch Size64T4显卡可稳定运行初始学习率1e-3AdamW适配的学习率权重衰减系数1e-4抑制过拟合优化器AdamW收敛快且稳定学习率调度CosineAnnealingLRT_max30Epoch数量30配合早停策略实际约22轮停止数据增强水平翻转、小角度旋转、颜色抖动、平移提升泛化能力训练日志每轮打印训练loss、训练准确率、验证loss和验证准确率。从训练曲线可以直观看出前5个epoch验证准确率从35%快速攀升到60%以上之后增长放缓到第15轮左右开始出现训练准确率持续上升但验证准确率徘徊不前的迹象这正是过拟合开始发生的信号。4.2 手写CNN与预训练模型的对比很多同学问过我一个问题“手写CNN和预训练模型到底差别有多大”我直接在hw3上做了对比实验结果如下模型参数量验证准确率单epoch训练时间T4SimpleCNN3层Block约220万74%约40秒ResNet-18预训练约1100万85%约80秒ResNet-50预训练约2500万86%约150秒EfficientNet-B0预训练约500万84%约90秒这个表格说明几件事。一是自己搭的CNN虽然参数量小但在小数据集上也能达到70%以上证明CNN结构本身的有效性二是预训练模型带来的提升非常显著直接跨过strong baseline原因是ImageNet预训练权重已经让模型学会了通用的边缘、纹理、形状等低级特征迁移到食物分类时只需微调高层语义部分。如果你要使用预训练模型代码非常简单import torchvision.models as models model models.resnet18(pretrainedTrue) num_features model.fc.in_features model.fc nn.Linear(num_features, 11)这里唯一需要改的就是最后一层全连接把输出类别数从1000改成11。注意修改后的最后一层是随机初始化的而前面的层保留了预训练权重。在训练时有两种选择一是对所有层都进行微调full fine-tuning二是冻结前面的层只训练最后一层linear probing。对于Food-11这种和ImageNet领域差距较大的数据集我建议全量微调但初始学习率要调小一些比如5e-4或1e-4避免破坏预训练好的特征提取器。4.3 一次完整的训练循环代码把训练和验证的逻辑封装成一个函数是保证代码整洁、可复现的关键。下面是我在hw3中实际使用的训练循环模板你可以直接复制修改def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() running_loss 0.0 correct 0 total 0 for inputs, labels in train_loader: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * inputs.size(0) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() epoch_loss running_loss / total epoch_acc correct / total return epoch_loss, epoch_acc def validate(model, val_loader, criterion, device): model.eval() running_loss 0.0 correct 0 total 0 with torch.no_grad(): for inputs, labels in val_loader: inputs, labels inputs.to(device), labels.to(device) outputs model(inputs) loss criterion(outputs, labels) running_loss loss.item() * inputs.size(0) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() epoch_loss running_loss / total epoch_acc correct / total return epoch_loss, epoch_acc用with torch.no_grad()包裹验证流程非常重要。它告诉PyTorch不需要计算梯度这会大幅度减少内存占用并加速验证过程。同时验证阶段一定要调用model.eval()这会关闭Dropout和BatchNorm的训练模式行为BatchNorm会使用累积的全局统计量而非当前batch的统计量。这个细节如果漏了验证准确率会莫名其妙地偏低且极不稳定。5. 常见问题与排查技巧实录5.1 训练准确率高但验证准确率低——过拟合的经典信号这是hw3中出现频率最高的问题没有之一。训练准确率已经到95%以上验证准确率却卡在60%左右。本质上就是模型把训练集的特征“背”下来了却没有学到可泛化的模式。我的排查顺序是先看数据增强是否太弱再看模型是否过大最后检查Dropout和权重衰减。在SimpleCNN中我在全连接层之前加了一个Dropout(0.5)同时在优化器中设置了weight_decay1e-4这两者组合后过拟合现象得到明显缓解。训练准确率会在85%~90%左右封顶而验证准确率能稳定在72%~74%。如果用了很夸张的数据增强后训练准确率依然接近100%那就要考虑减小模型容量了。比如把每层的通道数从[64,128,256]缩小为[32,64,128]参数量能减少约75%过拟合的程度也会随之下降。5.2 验证集loss震荡幅度很大训练loss平滑下降验证loss却像心电图一样上下剧烈波动。出现这种情况我在实战中总结有三个主要原因一是验证集的采样不够随机某些batch恰好都是难分类的样本二是batch_size太小导致单个batch的loss方差大三是学习率偏高。解决办法按优先级排序把batch_size调到64或128、把验证集固定下来例如固定随机种子、降低初始学习率或换用余弦退火调度。此外验证集出现轻微的loss波动其实是正常现象不要一看到曲线抖动就慌重点看整体趋势。5.3 样本类别不平衡问题如果使用的是自建数据集你很容易遇到某些类别样本量是其他类别的十倍这种失衡情况。在hw3的Food-11数据集中整体分布较均匀但我仍然建议在读取数据后第一时间验证一下类别分布from collections import Counter class_counts Counter([label for _, label in train_dataset.samples]) print(class_counts)遇到类别不平衡时最简单的处理方式是使用WeightedRandomSampler让每个类别在采样时被抽中的概率大致相同from torch.utils.data import WeightedRandomSampler labels [label for _, label in train_dataset.samples] class_counts torch.bincount(torch.tensor(labels)) weights 1.0 / class_counts.float() sample_weights weights[labels] sampler WeightedRandomSampler(sample_weights, num_sampleslen(sample_weights), replacementTrue) train_loader torch.utils.data.DataLoader( train_dataset, batch_size64, samplersampler, num_workers2 )这里replacementTrue意味着同一个样本可能在一个epoch内被多次抽到从而保证了小类别的样本有更多参与训练的机会。5.4 显存不足CUDA Out of Memory这个问题在Colab免费版上非常常见。T4只有16GB显存如果图片尺寸调到224且batch_size设到64运行到中途很可能直接爆显存。发生OOM后最快的解决方案是将batch_size减半同时把图片尺寸降到160或128。实测下来128×128的输入尺寸在Food-11上已经足够。另一个容易忽略的技巧是在验证阶段使用with torch.no_grad()并把验证的batch_size调大一倍。因为验证阶段不需要存储梯度可以使用更大的batch来加速同时缓解显存压力。如果在多GPU环境中训练还可以通过model nn.DataParallel(model)将模型并行到多张显卡上但hw3这个级别的任务完全不需要。6. 从75%到85%为什么迁移学习是通吃方案前面提到过自己搭CNN能到74%左右但要冲strong baseline85%以上最直接有效的路径就是使用预训练模型。这里我展开讲一下迁移学习的具体实操流程和核心原理。迁移学习的原理可以这样理解ImageNet预训练模型已经见过上千万张图片它的浅层卷积核学会了识别边缘、颜色块、纹理等通用特征这些特征对所有图像任务都是有用的。你做的事情相当于请了一位见多识广的实习生他不需要从零学习怎么识别线条和形状只需在食物数据上重新学习“什么样的纹理组合代表面包、什么样的颜色分布代表沙拉”这种高层语义。在hw3的实操中我推荐用torchvision.models里带有pretrainedTrue参数的模型。现在torchvision新版本中部分模型改用weights参数效果是一样的from torchvision.models import resnet18, ResNet18_Weights model resnet18(weightsResNet18_Weights.IMAGENET1K_V1) num_ftrs model.fc.in_features model.fc nn.Linear(num_ftrs, 11)微调的代码结构与前面训练SimpleCNN一模一样但有两个关键差异学习率需要降低。预训练权重已经是很好的初始点了用太大的学习率容易一步迈过最优点。我推荐1e-4作为一个安全起点配合余弦退火调度进一步衰减。可以分层设置学习率。如果你想保留预训练特征同时希望新加的最后一层快速收敛可以对不同层设置不同学习率optimizer torch.optim.AdamW([ {params: model.conv1.parameters(), lr: 1e-5}, {params: model.layer1.parameters(), lr: 1e-5}, {params: model.layer2.parameters(), lr: 1e-5}, {params: model.layer3.parameters(), lr: 1e-5}, {params: model.layer4.parameters(), lr: 1e-4}, {params: model.fc.parameters(), lr: 1e-3} ], weight_decay1e-4)层次靠后的层更接近具体任务需要更大的学习率来适应新数据靠前的层提取的是通用特征用很小的学习率微调即可。这种策略能有效避免灾难性遗忘在数据集规模较小时效果特别明显。用ResNet-18做迁移学习我在Food-11上验证准确率直接来到85%左右。整个训练过程只需15个epoch左右因为模型并不需要从头学习特征提取。这也是为什么在竞赛和实际工程项目中只要数据量没过万预训练模型都应该是首选方案。7. 提高分数的最后一块拼图模型集成与技巧性优化到了这一步如果你还想把分数再往上推一推可以尝试以下几个性价比极高的技巧。这些技巧在实际竞赛中几乎人人都在用但课程作业里很少有同学会刻意去做。第一个是测试时数据增强Test-Time Augmentation, TTA。简单说就是在推理阶段对同一张测试图片做多次不同的变换比如水平翻转、轻微平移分别输入模型得到预测概率向量再取平均作为最终预测结果。这样做能有效平滑模型的预测噪声通常能带来1%~2%的准确率提升。def predict_with_tta(model, image, device, tta_transforms): model.eval() probabilities [] with torch.no_grad(): for transform in tta_transforms: augmented transform(image).unsqueeze(0).to(device) output torch.softmax(model(augmented), dim1) probabilities.append(output) avg_prob torch.mean(torch.stack(probabilities), dim0) return torch.argmax(avg_prob, dim1).item()第二个是多模型集成。训练两个结构不同的模型比如ResNet-18和EfficientNet-B0在推理时对它们的预测概率取平均。由于不同模型的误差模式不同集成后往往能抵消一部分错误预测。但这个技巧对训练时间的要求更高适合追求极致分数的同学在时间宽裕时尝试。第三个技巧相对冷门但很实用——把图片缩放到更大的尺寸再预测。比如训练时用的是128×128但推理时缩放到192×192或224×224。因为模型内部有AdaptiveAvgPool2d全连接层的输入是固定维度所以输入尺寸可以灵活变化。更大的分辨率保留了更多细节通常也能稍微提升准确率。不过我得提醒一句技巧用再多都是在验证集上看到的提升。真正决定作业质量的依然是你对模型每个设计的理解程度。别为了刷分而刷分把每个技巧背后的原理弄明白才算真正吃透了hw3。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门