CIFAR-10图像分类实战:轻量CNN训练与调参全记录

发布时间:2026/9/9 22:59:01
CIFAR-10图像分类实战:轻量CNN训练与调参全记录 上个月刚在MNIST上跑完第一个CNN项目这个月我就直接把目标换成了CIFAR-10。选择CIFAR-10作为第二个深度学习项目其实是个很经典的进阶路径它比MNIST难了一个档次又没有难到必须上ResNet这种大网络才能跑动的地步。CIFAR-10配合自己手搭的轻量CNN几乎是每个想从“会跑代码”走向“理解CNN到底在做什么”的人都会经过的一站。这篇文章记录的是我这次从环境配置、数据加载、网络设计、训练调参到踩坑解决的完整过程尤其是那些教程里很少写、但上手一定会碰到的细节。如果你也刚跑完手写数字识别想试试真正的彩色图片分类这篇文章应该能帮你省下不少折腾的时间。1. 从MNIST到CIFAR-10第二个项目跨过的那道坎1.1 两个数据集到底差在哪很多初学者对CIFAR-10的理解就是“图片稍微复杂一点的MNIST”实际动手才发现根本不是一回事。我在项目开始前把两个数据集放在一起做了个对比这个对比决定了后续所有设计思路。对比项MNISTCIFAR-10图片尺寸28×2832×32通道数1灰度3RGB彩色类别数1010训练集图片数6000050000测试集图片数1000010000图片内容手写数字飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车背景复杂度背景干净背景复杂物体位置和大小不固定类间差异差异很大存在猫和狗、鹿和马这类相似对注意看通道数从1变成3意味着第一个卷积层的输入从单通道矩阵变成了三通道张量卷积核的数量和计算方式都要跟着变。图片尺寸从28×28变成32×32只多了4个像素但物体不再居中同一类别的样本可能出现在画面的任何位置还可能带有复杂背景干扰。CIFAR-10真正难的地方不是图片分辨率而是类间相似性。“猫”和“狗”在很多样本里形态接近“汽车”和“卡车”轮廓也像这种细粒度差异在MNIST里几乎不存在。手写数字1和7虽然也有相似之处但相比猫狗、鹿马这些现实物体区别度还是高多了。所以MNIST上随便一个简单网络都能轻松跑出99%的准确率而CIFAR-10用轻量模型想上80%都需要认真调一轮。1.2 第二个项目要定什么目标别一上来就追高精度CIFAR-10的SOTA准确率早就超过99%了但第二个项目如果直接把目标定成“追上SOTA”方向就偏了。想刷高精度最简单的方式是加载EfficientNet或Vision Transformer的预训练模型但那等于跳过了所有该学的底层知识。我给这次项目定的目标非常朴素拆成四条跑通完整流程数据下载、预处理、网络定义、训练、评估、保存权重一个环节都不能少。搞清楚每个模块为什么存在包括BatchNorm、Dropout、池化、激活函数各自在干什么。在轻量CNN上打到一个合理基线80%上下是一个比较现实的目标。学会看训练曲线定位问题能判断欠拟合、过拟合、学习率不合适等情况。这里有个很实际的建议第一次跑CIFAR-10不要一上来就堆数据增强、学习率调度、预训练权重这些高级手段。全加上的话最后模型是能到85%甚至更高但中间出了问题你完全不知道是哪一环引起的。先做一个不加任何trick的baseline后续再逐步加东西每一步的效果变化都清清楚楚。2. 环境配置与数据集准备两个容易被低估的环节2.1 版本对齐比安装本身更麻烦我在做第一个项目时用的是CPU版本PyTorch这个项目开始前先升级到了CUDA版本。环境配置这块最大的坑不是“不会装”而是“装完之后发现版不齐”各种莫名其妙的CUDA报错基本都是这个原因。我这次用的是这套组合组件版本说明Python3.9.18兼容性比较稳PyTorch2.1.2当前主力版本torchvision0.16.2自带CIFAR-10数据集和常用预处理方法CUDA12.1和PyTorch的cu121构建对应numpy / matplotlib最新即可数据处理和画图安装PyTorch时最容易踩的坑是明明显卡驱动支持CUDA但用默认命令pip install torch torchvision装到的是CPU版本。装了CPU版之后代码也能跑只是慢到怀疑人生而且torch.cuda.is_available()会返回False。正确的做法是在PyTorch官网找到对应CUDA版本的安装命令指定index-url安装比如pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121装完之后做一步验证确认环境没问题再继续import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))如果打印结果是True和你的显卡型号环境就算齐了。如果返回False说明CUDA版本不匹配或者装成了CPU版建议直接重装。2.2 CIFAR-10下载的两种姿势与DataLoader细节torchvision里直接封装了CIFAR-10的下载和读取代码非常简洁from torchvision import datasets train_dataset datasets.CIFAR10( root./data, trainTrue, downloadTrue, transformtransforms.ToTensor() ) test_dataset datasets.CIFAR10( root./data, trainFalse, downloadTrue, transformtransforms.ToTensor() )但第一次运行的时候downloadTrue会从官方服务器拉取约170MB的压缩包。国内网络拉到一半断掉是常有的事下载失败后程序会报错不会自动断点续传。我当时的处理办法是先用浏览器手动下载cifar-10-python.tar.gz然后放到./data目录下再运行上面的代码。torchvision检测到本地已经有这个文件就会直接解压而不会重新下载。如果手动下载也慢就去网上找一个可用的镜像地址自己下载好之后放到正确位置即可。核心逻辑就是让本地存在一个完好的tar.gz文件torchvision会自己校验。数据集准备好之后封装DataLoader时有个细节值得单独说from torch.utils.data import DataLoader train_loader DataLoader( train_dataset, batch_size128, shuffleTrue, num_workers0, pin_memoryTrue ) test_loader DataLoader( test_dataset, batch_size128, shuffleFalse, num_workers0, pin_memoryTrue )num_workers这个参数在Windows上非常坑。如果设置大于0经常会出现An attempt has been made to start a new process before the current process has finished its bootstrapping phase之类的报错。我的做法是先老老实实设成0等流程全部跑通之后再去研究多进程加速。pin_memoryTrue在GPU训练时能加快数据传输但如果你用CPU训练这个参数影响不大。2.3 归一化与数据增强先搞清楚为什么再决定做不做CIFAR-10的像素值范围是0到255直接用原始数值喂给网络会让梯度更新变得很不稳定。常见的做法是把像素缩放到接近0的范围内这就是Normalize做的事。CIFAR-10有一套公开的统计值训任何网络都可以直接用transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize( mean(0.4914, 0.4822, 0.4465), std(0.2470, 0.2435, 0.2616) ) ])这组mean和std是CIFAR-10训练集每个RGB通道的均值和标准差数值是官方社区统计出来的。使用它们之后三通道数据会被标准化成近似标准正态分布模型的收敛速度和稳定性都会有明显改善。需要提醒的是测试集也必须做完全相同的Normalize我见过有人训练集做了、测试集忘了做结果测试准确率直接掉了好几个百分点。数据增强是我在第二个项目里刻意没做的东西。不是因为它没用而是我想先把“一张原图直接进网络”的baseline跑出来。如果一开始就加RandomCrop和RandomFlip模型精度确实会提高但你会分不清提高来自数据增强还是网络结构的变化。等baseline稳定之后再加增强效果对比会更明确。后续想加时可以参考这套配置注意增强只加在训练集train_transform transforms.Compose([ transforms.RandomCrop(32, padding4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize( mean(0.4914, 0.4822, 0.4465), std(0.2470, 0.2435, 0.2616) ) ]) test_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize( mean(0.4914, 0.4822, 0.4465), std(0.2470, 0.2435, 0.2616) ) ])RandomCrop的padding4表示先把32×32的图扩到40×40再从里面随机裁剪出32×32的区域相当于给图片增加了随机平移。RandomHorizontalFlip按50%概率水平翻转。这两个操作对CIFAR-10效果很好因为图片里的物体没有固定的方向性翻转和微小平移不改变语义却能让网络看到更多样的样本缓解过拟合。3. 轻量CNN的结构设计与参数量核算3.1 为什么选轻量网络很多人第二步项目就急着上ResNet但我这次坚持用了轻量CNN。原因有几个一是训练速度快迭代试错效率高一个网络从零开始训练30轮在GPU上只要几分钟省下来的时间都用来分析问题了二是轻量网络参数少一些相比大网络更不容易严重过拟合更适合观察真实的训练曲线三是对初学者来说轻量网络的结构一目了然每一层的作用、每一个张量的形状变化都能手动算出来真正建立起对CNN的直觉。重型网络当然精度更高但代价是训练时间长、参数多、调试困难。如果你连CIFAR-10在轻量模型上的过拟合现象都没亲眼见过直接上手大网络只会更迷茫。3.2 网络结构三层卷积加两层全连接我参考了VGG的思路全部使用3×3卷积和2×2最大池化结构如下输入3×32×32Conv13→32通道3×3卷积padding1后接BatchNorm、ReLU、MaxPoolConv232→64通道3×3卷积padding1后接BatchNorm、ReLU、MaxPoolConv364→128通道3×3卷积padding1后接BatchNorm、ReLU、MaxPool展平后维度128×4×4 2048FC12048→512后接ReLU和Dropout(0.5)FC2512→10对应代码import torch.nn as nn class LightCNN(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Linear(128 * 4 * 4, 512), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(512, num_classes) ) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) x self.classifier(x) return x这里用padding1是为了让卷积不改变特征图的宽高只有MaxPool负责缩小尺寸。32×32经过三次MaxPool后变成4×4特征图数量从32翻到128。这个“宽高减半、通道数翻倍”的设计思路很经典既控制了计算量又保留了足够的特征表达空间。3.3 参数量的手算过程我当时手动算了一遍参数量这一步对理解CNN帮助极大。计算规则是卷积层参数量 输入通道数 × 输出通道数 × 卷积核宽 × 卷积核高 输出偏置数全连接层参数量 输入维度 × 输出维度 输出偏置数。模块输入形状输出形状参数量Conv1(3, 32, 32)(32, 32, 32)3×32×3×3 32 896Conv2(32, 16, 16)(64, 16, 16)32×64×3×3 64 18496Conv3(64, 8, 8)(128, 8, 8)64×128×3×3 128 73856BN1/2/3--2×(3264128) 448FC120485122048×512 512 1049088FC251210512×10 10 5130合计--约1.15M加起来大约115万个可训练参数。对比一下VGG16的参数规模约1.38亿ResNet18约1120万。1.15M的参数量确实是“轻量”在CPU上都能比较快地跑完一轮训练。拉低参数量的主要功臣是卷积的权值共享。同一个3×3卷积核在整张图上滑动参数不会随图片变大而增加这和全连接层完全不同。FC1的参数量之所以占了大头就是因为2048×512的权重矩阵需要约100万个参数可见全连接层在高维特征上非常“吃参数”这也是为什么很多现代网络会倾向使用全局平均池化替代全连接。3.4 激活函数、BatchNorm、Dropout各自干了什么激活函数我选的是ReLU。原因很实际Sigmoid在深层网络里容易造成梯度消失靠近两端的区域导数趋近于0反向传播时梯度乘不了几层就没了Tanh虽然零中心化但同样存在两端饱和问题。ReLU计算量极小正区间梯度恒为1收敛速度快得多。BatchNorm解决的是中间层数据分布漂移问题。没有BN时每一层的输入分布会随着前层参数变化而不断变化训练容易不稳定。加了BN之后每个batch会先做归一化再送进下一层模型对学习率的敏感度明显下降。BN还有一个副产物它的可训练参数缩放和偏移每个通道只有两个整个网络加起来才448个可以忽略不计。Dropout放在全连接层之前训练时每次前向传播随机让一半神经元失活相当于强迫网络学到更多冗余特征不依赖某几个特定神经元。这个策略在全连接层这种参数密集的地方尤其有效。但要记得Dropout在推理时必须关闭推理时如果还开着输出会被随机性干扰导致同样的输入每次预测结果都不一样。4. 训练配置与过程观察学习率、优化器与Loss曲线4.1 超参数选型Adam先验证SGD再精细训练超参数的选取要讲策略。我的做法是分两个阶段第一个阶段用Adam学习率1e-3目标是快速验证网络结构能不能收敛。Adam对学习率的敏感度低自带自适应调整非常适合做结构验证。如果Adam跑了好几轮loss都不降那问题多半出在网络结构或数据预处理上而不是优化器。第二个阶段换SGD加momentum和weight_decay做最终训练。SGDmomentum在CIFAR-10这类任务上往往能比Adam拿到更高的精度上限代价是对学习率更敏感需要多试几个值。我最终的训练配置大概是这样的超参数第一阶段验证第二阶段最终优化器AdamSGD学习率0.0010.01momentum-0.9weight_decay-5e-4batch_size128128epochs1030损失函数CrossEntropyLossCrossEntropyLoss损失函数直接用了nn.CrossEntropyLoss()。这里有一个必须注意的点CrossEntropyLoss内部已经完成了LogSoftmax和NLLLoss的结合所以网络最后一层输出的是原始logits不能再手动加一层Softmax。有人在最后一层用了torch.softmax(x, dim1)再传给CrossEntropyLoss损失值看起来很高准确率也上不去因为梯度被Softmax和Loss里的Softmax重复计算了数值稳定性反而变差。4.2 训练与验证循环参考实现这里给出完整的训练代码我建议直接把它保存成一个train.py后面的实验都基于这个框架改import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, transforms device torch.device(cuda if torch.cuda.is_available() else cpu) transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)) ]) train_dataset datasets.CIFAR10(root./data, trainTrue, downloadTrue, transformtransform) test_dataset datasets.CIFAR10(root./data, trainFalse, downloadTrue, transformtransform) train_loader DataLoader(train_dataset, batch_size128, shuffleTrue, num_workers0, pin_memoryTrue) test_loader DataLoader(test_dataset, batch_size128, shuffleFalse, num_workers0, pin_memoryTrue) model LightCNN().to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay5e-4) def train_one_epoch(model, loader, optimizer, criterion, device): model.train() running_loss 0.0 correct 0 total 0 for inputs, labels in 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 outputs.max(1) total labels.size(0) correct predicted.eq(labels).sum().item() return running_loss / total, 100.0 * correct / total def evaluate(model, loader, criterion, device): model.eval() running_loss 0.0 correct 0 total 0 with torch.no_grad(): for inputs, labels in loader: inputs, labels inputs.to(device), labels.to(device) outputs model(inputs) loss criterion(outputs, labels) running_loss loss.item() * inputs.size(0) _, predicted outputs.max(1) total labels.size(0) correct predicted.eq(labels).sum().item() return running_loss / total, 100.0 * correct / total best_acc 0.0 epochs 30 for epoch in range(1, epochs 1): train_loss, train_acc train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc evaluate(model, test_loader, criterion, device) print(fEpoch {epoch:02d} | Train Loss {train_loss:.4f} | Train Acc {train_acc:.2f}% | Val Loss {val_loss:.4f} | Val Acc {val_acc:.2f}%) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth)在训练循环里outputs.max(1)返回每个样本的最大logit以及对应的索引索引就是预测类别。predicted.eq(labels).sum()统计预测正确的数量。这段代码里的evaluate和train_one_epoch必须区分开evaluate函数内不能用optimizer.step()而且要在torch.no_grad()下执行否则会白白占用显存还可能在验证集上计算出错误的梯度。4.3 看曲线判断训练状态我用上面这套配置实际跑了一轮记录了每个阶段的分界点大致是这个表现EpochTrain AccVal Acc状态判断565%58%正常上升尚未收敛1078%70%正常上升仍有欠拟合2088%76%速度放缓开始出现过拟合苗头3094%78%明显过拟合train和val差距拉大从这个曲线能学到很关键的一点只看测试准确率是不够的一定要同时看训练集表现。训练集94%而测试集78%这中间16个百分点的差距就是模型在“背诵”训练数据的具体表现。它虽然对训练集学得很好但泛化到新数据的能力停滞了。如果train和val都低比如train只有60%、val只有55%那是欠拟合说明模型容量不够或者训练不够充分应该加深网络或增加epoch数。如果train已经90%以上但val只有70%左右那就是过拟合应该加数据增强、加Dropout、加weight_decay或者减小模型规模。只有train和val同步上升、差距不大的阶段才是真正在学“通用特征”的阶段。4.4 别忘了model.train()和model.eval()我在训练循环里反复调用了model.train()和model.eval()这两个方法不是摆设。它们改变的是BatchNorm和Dropout的行为。model.train()模式下BatchNorm会使用当前batch的均值和方差来归一化Dropout会随机丢弃神经元。model.eval()模式下BatchNorm改用训练阶段累计的全局均值和方差Dropout完全不丢神经元。如果验证时忘了切回eval模式BatchNorm对每个batch独立归一化测试结果会不稳定同一个模型跑两次能得到不同的准确率。这个坑在第一次写训练循环时特别容易踩我当时就因为这个白折腾了半个多小时。5. 踩坑记录与后续优化方向5.1 这次实际踩过的五个坑第一个坑是归一化参数写错。我一开始为了省事直接在网上复制了一组Normalize参数没仔细看是mean还是std的数值训练时发现loss下降特别慢。后来排查到是std写反了修正之后收敛速度立刻正常。建议手写这组参数时专门注释一下并且在训练前打印一小批预处理后的数据检查均值是否接近0、标准差是否接近1。第二个坑是验证阶段忘了调用model.eval()。症状很迷惑前一轮val acc是76%下一轮同样的代码变成72%再下一轮又变回75%来回跳。后来才意识到是Dropout在验证时还在生效输出带随机性。加了一行model.eval()之后曲线立刻稳定了。训练前和验证前各检查一遍train/eval状态应该是每个训练循环的默认动作。第三个坑和Softmax有关。我第一次用CrossEntropyLoss时觉得“分类任务最后一层当然要加Softmax”结果在classifier的最后加了softmax训练时loss一直偏高。原因是CrossEntropyLoss内部已经做了LogSoftmax再把softmax后的概率喂进去相当于计算了两次数值分布完全变了。这个知识点理论课上都讲过但自己动手真踩一遍才会记住。第四个坑是Windows下DataLoader多进程报错。只要num_workers设为大于0就会在加载数据时直接崩掉。解决办法是把训练代码包在if __name__ __main__:里更省事的是直接把num_workers设为0。Linux下可以设成2或4Windows下就老老实实用0。第五个坑相对隐蔽装环境时装成了CPU版本PyTorch。当时跑MNIST项目没觉得慢换了CIFAR-10之后发现每次迭代都要等很久。排查后才发现torch.cuda.is_available()返回FalseCUDA和PyTorch版本根本对不上。重装成cu121版本之后训练时间缩短了一个数量级。任何深度学习项目开始前先打印一下CUDA可用性这个习惯能省掉很多不必要的等待。5.2 从78%到85%的低门槛改进路线在baseline跑出78%之后我按“一次只改一个变量”的原则做了几轮改进效果都记录下来了。如果你也想在轻量CNN基础上提升建议按这个顺序来加数据增强RandomCrop(32, padding4) RandomHorizontalFlipval acc能到81%左右。这一步解决的是过拟合问题不用改模型结构。优化器从Adam换成SGD加momentum0.9和weight_decay5e-4学习率调到0.01val acc约83%。SGD配合weight_decay的正则效果在CIFAR-10上比Adam更稳。使用CosineAnnealingLR余弦退火学习率调度让学习率在训练后期逐步下降val acc约84%。这一步能让模型进入更精细的参数区间。把网络加宽通道从32-64-128改成64-128-256参数量增加到约4.6Mval acc约85%。加宽比加深在这个数据规模下更划算。再往上走可以用Cutout随机遮挡、Mixup混合样本等更强的增强策略配合更长训练周期能摸到87%左右。但这些trick的调参成本会明显增加而且不同随机种子下的波动也会变大。5.3 完成第二个项目之后可以怎么走CIFAR-10的轻量CNN项目做完之后下一步的路径其实是开放的。比较自然的方向是换成ResNet18从头训练加深网络深度观察深度带来的精度提升和训练难度加载ResNet18的预训练权重做迁移学习感受大规模数据集预训练给小数据集带来的巨大优势用Grad-CAM可视化模型对样本的注意区域看看分类决策的依据是否合理或者用t-SNE对全连接层特征降维可视化观察不同类别在特征空间中的分布。每一条路都能从CIFAR-10这个起点延伸出去但建议不要同时做一次只挑一个方向深入。这次项目做完我自己最大的一个体会是深度学习入门阶段模型复杂度从来不是越高越好。CIFAR-10在轻量CNN上虽然只有八成出头的准确率但正因为网络足够简单我才有机会把数据流、梯度行为、训练和推理差异这些底层的细节看得清清楚楚。第二个项目的核心价值不在于把准确率刷到多高而在于第一次拥有“自己设计一个网络并完整验证它”的经验。以后你再换ResNet、换YOLO、换Transformer很多思路其实都是一脉相承的。最后再给一个小建议做实验时保持“一次只改一个变量”的习惯把每次实验的配置和结果记在笔记里。不然过两个星期再回来看你根本想不起是哪个改动让准确率涨了0.5个百分点。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询