1. 图像分类实战的选题思路:为什么是这一节最该动手
李哥深度学习系列走到第五节,前面几节基本都在讲卷积网络的结构、卷积层池化层的原理、激活函数怎么选。说实话,光看理论容易越看越虚,尤其是当你发现自己能背诵残差连接的结构图,却连一个完整的训练脚本都写不利索的时候,就该意识到动手实战的时间到了。图像分类是深度学习里最成熟、最标准化的任务,数据好找、评价指标清晰、模型结构公开,入门阶段拿它开刀,性价比最高。
这一节内容对新手特别友好,但也绝不是“跑个demo就完事”的水平。我们用的是公开数据集,模型从零开始搭建和训练,走完数据加载、预处理、模型定义、训练循环、评估曲线、调参优化的完整闭环。目标不是追求测试集上的SOTA数字,而是让你理解一条数据从硬盘上的图片变成loss数字再到最终分类结果,中间到底发生了哪些运算,哪些环节容易出问题,出了问题该怎么排查。
我自己带过不少零基础的学员,发现多数人卡住的点不是看不懂网络结构,而是不知道训练代码怎么写、为什么这么写。很多教程一上来就甩一个完全封装好的训练函数,学员跑了半天也不知道每行代码在干嘛。这个第五节的做法刚好反过来:不用高级封装库,尽量用基础PyTorch接口手写训练循环,让每一步都可见、可改、可调试。等你跑完这遍流程,再回头去看封装好的训练器,一下就通了。
提示:这个系列假定你已经掌握了Python基础语法、NumPy基本操作,能理解二维数组和矩阵乘法。如果这些还不太熟,建议先花一周补齐基础,不然调试代码时容易被语言层面的问题干扰。
2. 数据集选择与预处理:成也数据,败也数据
2.1 为什么选CIFAR-10而不是其他数据集
图像分类的入门数据集不外乎几个选择:MNIST、Fashion-MNIST、CIFAR-10、ImageNet的子集。MNIST太简单,28x28的单通道灰度图,用全连接网络硬怼都能到98%以上,练不出卷积网络的手感。ImageNet规模太大,单机训练一轮要很久,对新手来说更多是看别人复现的结果。CIFAR-10是32x32的三通道彩色图,共10个类别,每类6000张,总共60000张,尺寸小、加载快,在普通消费级显卡上几分钟就能跑完一个完整实验周期,是验证模型想法的最佳沙盘。
CIFAR-10的难点在于32x32分辨率太低,很多类别之间差异不明显,比如猫和狗、鸟和鹿,人眼都不容易分辨。这意味着模型必须学到更有区分力的特征,而不是依赖简单的颜色分布和纹理。这恰恰能训练你对数据增强的敏感度:同样一组超参数,加了数据增强和没加数据增强,测试集准确率能差好几个点。
可能有人会问,为什么不直接用更高分辨率的真实业务数据?因为现阶段的核心矛盾不是模型能力不够,而是你对整个训练流程不熟悉。用一个中等难度、指标可控的数据集把流程跑通,比一上来就处理几百GB的脏数据要高效得多。流程通了,换数据集只是换加载函数和类别数的事。
2.2 预处理的三个关键操作:归一化、增广、批次化
数据预处理不是随便resize一下就行。第一个关键操作是归一化,CIFAR-10每个像素值范围是0到255,如果不做归一化,网络初始阶段的梯度会受数值范围影响,导致训练不稳定。常见做法是计算每个通道的均值和标准差,然后做标准化。CIFAR-10的标准用法是用全局均值(0.4914, 0.4822, 0.4465)和全局标准差(0.2470, 0.2435, 0.2616)。有些初学者图省事直接除以255,也不是不能训,但收敛速度和最终精度通常不如标准化。
第二个关键操作是数据增强。CIFAR-10的训练集只有50000张,如果模型容量稍大,很容易过拟合。常用增强方式包括随机水平翻转、随机裁剪、Cutout等。随机水平翻转是成本最低效果最好的增强,猫的照片翻过来还是猫,类别语义不变。随机裁剪相当于让模型看物体的不同位置,增强平移鲁棒性。这里要注意一个细节:增强只应用于训练集,验证集和测试集只做标准化,不做增强,否则评估结果会被数据本身的随机扰动污染。
第三个关键操作是批次化与数据加载乱序化。PyTorch的DataLoader里设置shuffle=True,可以让每个epoch的样本顺序不同,避免模型学到批次间的顺序依赖。num_workers的设置也很讲究,Windows环境建议设置为0或2,Linux可以拉到4甚至8,配合pin_memory=True能减少数据从CPU传到GPU的时间。这些细节单独看都不起眼,合在一起就是训练效率的差距来源。
transform_train = Compose([ RandomCrop(32, padding=4), # 先四周补4像素,再随机裁剪回32x32 RandomHorizontalFlip(), # 50%概率水平翻转 ToTensor(), # HWC转为CHW并归一化到0~1 Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)) ]) transform_test = Compose([ ToTensor(), Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)) ])3. 模型搭建与训练循环:从零手写,把黑盒变成白盒
3.1 网络结构设计:从浅到深递进,别一上来就ResNet
这一节实战里,我建议先用一个轻量级的自定义卷积网络把流程跑通,再切换到ResNet-18看差距。上来直接搬ResNet-18的问题在于,跳连接、BN层、残差块这些机制叠加在一起,一旦训练出问题,你很难定位是哪个环节的锅。
我用的第一个模型是一个简化版VGG风格网络:卷积层加最大池化交替堆叠,最后接全局平均池化和全连接层。结构大致是:第一个卷积块用64个3x3卷积核,激活用ReLU,加BN层,2x2最大池化;第二个卷积块用128个3x3卷积核,同样激活加BN加池化;第三个卷积块用256个3x3卷积核,池化后特征图尺寸变成4x4,展平后接一个512维的全连接层,最后输出10维logits。
这个结构的参数量在百万级别,CIFAR-10上比较容易训练,也不容易过拟合到发疯。BN层在CIFAR-10上基本是必须的,它能显著加速收敛,还起到一定的正则化作用。注意BN层的默认行为:训练时使用当前批次的均值和方差做归一化,评估时使用滑动平均累积的全局统计量。所以模型定义里要正确切换model.train()和model.eval(),漏掉这个步骤,你会看到训练集正常、验证集指标波动大得离谱。
3.2 损失函数与优化器:交叉熵和Adam/W-SGD的取舍
图像分类的标准损失函数是交叉熵。PyTorch里nn.CrossEntropyLoss()已经帮你把Softmax和NLLLoss合并了,所以网络最后一层不要自己额外加Softmax,直接用logits算loss即可。如果手误在最后一层加了Softmax,训练时梯度会不稳定,预测时数值也可能异常。
优化器方面,这一节实战用Adam做快速验证,用带动量SGD做最终精调。Adam收敛快,对学习率的敏感度低,适合先跑通流程看模型是否正常工作。但Adam在某些任务上泛化性能不如带动量SGD,所以固定实验时会换成SGD加动量0.9、权重衰减5e-4,配合余弦退火学习率调度。很多新手不理解为什么同一个任务不同优化器效果不一样,简单说:Adam相当于每个参数自适应学习率,对稀疏梯度友好;SGD加动量的每一步更“稳”,在凸面附近能冲进更平缓的极小值点。实际项目中两个都试试,用数据说话。
3.3 训练循环的完整结构:五步固定套路
手写训练循环是这个实战的核心,结构固定为五步:取批次数据、前向传播、计算loss、反向传播、梯度更新。代码写出来不难,难的是每一步都要理解为什么这么写。以PyTorch为例,典型写法如下:
for epoch in range(epochs): model.train() running_loss = 0.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 loss.backward() # 第四步:反向传播,填充梯度 optimizer.step() # 第五步:用梯度更新参数 running_loss += loss.item() * inputs.size(0) epoch_loss = running_loss / len(train_loader.dataset)optimizer.zero_grad()这一步特别容易忘。PyTorch的梯度是累加机制,如果你不清空,下一批数据的梯度会和上一批的梯度叠加,相当于把batch size翻了好几倍,loss曲线会抖动得根本没法看。有人为了省事把zero_grad()放在step()之后写,逻辑上也没什么大问题,但建议保持标准顺序,减少心智负担。
每轮epoch结束后,在验证集上算一次准确率和loss,记录到日志里。这里注意验证过程要用torch.no_grad()包裹,并且设置model.eval()。no_grad()会阻止自动求导图的构建,大幅减少内存占用,验证阶段我们只是前向推理,完全不需要梯度。如果不加,跑几轮后显存就被撑爆了。
3.4 训练过程观察:loss下降慢、震荡、不降分别说明什么
训练过程中最常见的三种现象需要学会判断。第一种是loss下降慢但稳定,多半是学习率太小,或者数据预处理没做好。第二种是loss大幅震荡,可能原因是学习率偏大、batch size偏小、或者数据加载顺序没有打乱。第三种是loss完全不动,这种问题最严重,优先检查是否有梯度爆炸或梯度消失:看一下每一层的梯度范数,如果某一层梯度是NaN或者全部为零,通常问题出在网络定义中,比如激活函数选择不当、权重初始化异常、或者分类层输出维度数错了。
我跑这一节实战时踩过一个印象深刻的坑:验证集准确率一直卡在10%左右,和随机猜测一样。排查了半天,发现是类别标签从0到9,而数据加载器里忘了做标签映射,导致所有样本的标签错位。这种错误训练loss反而会正常下降,因为模型在拟合错误的映射关系,只有验证集准确率会诡异地在10%水平徘徊。遇到这种情况,先打印一批inputs.shape、labels.unique(),确认数据形态符合预期。
4. 调参与优化:同样的代码,不同参数的差距能到十个点
4.1 学习率策略:不是固定值,而是衰减曲线
CIFAR-10实战中,学习率用固定值不是不行,只是效率低。前期用稍大的学习率快速靠近最优区域,后期用小学习率精细搜底,这是通用做法。具体到这一节,我习惯先用lr=0.1配合SGD跑30到50轮,在第30轮和第40轮各衰减一次到原来的十分之一。这种方法叫阶梯式衰减,实现简单,效果稳定。
如果想省事,PyTorch里可以直接用torch.optim.lr_scheduler.CosineAnnealingLR,设置T_max为总轮数,学习率会从初始值余弦式降到接近零。相比阶梯衰减,余弦退火在CIFAR-10上往往能多涨一到两个点,而且不需要手动设置衰减节点。需要注意的是,调度器要记得在每个epoch结束后调用scheduler.step(),和训练循环放在一起,否则学习率不会自动更新。
学习率的选择区间建议这样判断:以0.1为起点,如果loss曲线在前500步内完全发散,降到0.01;如果loss下降极其缓慢,提到0.2试试。实际操作中可以用一个小工具:每训练一个batch打印一次当前学习率和loss,前几百个batch的走势就能告诉你这个学习率是否合理。
4.2 过拟合的判断与应对:验证集准确率不涨反降
过拟合的判断不能只看训练集准确率,必须对比训练集和验证集的走势。前期训练集和验证集准确率同步上涨,随后验证集曲线开始变平甚至下降,而训练集还在继续涨,这时候就是过拟合的明确信号。应对方法按优先级排列:第一,增强数据增强的强度;第二,加Dropout或提高权重衰减;第三,降低模型容量。三者可以组合使用,但不要同时全上,否则模型又变成了欠拟合。
Dropout层在卷积网络中的应用要讲究位置,通常加在全连接层之前,设为0.5。卷积层之间的Dropout效果不如BN加增强明显,有时候反而掉点。权重衰减的作用是限制参数不要长得过大,对全连接层效果显著,对卷积层效果弱一些,但直接加上也没什么坏处。
4.3 不同优化器与增强组合的横向对比
这节实战里我专门做了对照实验,同一网络结构,测试了三种组合:Adam不增强、SGD增强、SGD增强加余弦退火。结果很能说明问题。
| 组合 | 第10轮验证准确率 | 最终验证准确率 | 备注 |
|---|---|---|---|
| Adam,不做增强 | 约68% | 约76% | 收敛快,后期涨得慢 |
| SGD+增强 | 约62% | 约79% | 前期慢,后期反超 |
| SGD+增强+余弦退火 | 约63% | 约82% | 综合最高 |
这个结果启发是:如果你只是验证想法能不能跑通,用Adam加少量增强就够了;如果你想刷高分类精度,还是要回到SGD加数据增强加学习率调度这条经典路径上。别迷信任何一个优化器,任务不同、数据不同,最优组合也不同。
4.4 类别不均衡与评估指标:准确率之外还要看什么
CIFAR-10类别是均衡的,但真实业务数据很少这么理想。这里顺带讲一个通用的做法:除了整体准确率,还要看每一类的精确率和召回率,尤其是样本少的类别。如果某些类别的准确率明显低于其他类别,优先检查该类别的增强策略是否过强导致样本失真,或者该类别特征本身容易混淆。分类任务里,混淆矩阵比准确率数字信息量大得多。
5. 常见错误与排查技巧:那些只看书永远学不到的坑
5.1 显存溢出:不是换大显卡就能解决的
训练CIFAR-10时显存溢出经常发生,尤其是跑ResNet这类深层网络。新手最容易犯的错误是验证阶段忘记用no_grad(),导致验证集每批数据都构建计算图,几十批之后显存持续增长直到爆炸。另一个常见原因是数据集加载时num_workers开太多,内存爆掉。显存问题排查可以按顺序来:先检查训练循环里是否有detach()遗漏,再检查验证集是否用了no_grad(),最后看batch size是否过大。
5.2 结果不可复现:随机种子一定要固定
深度学习训练充满随机性:权重初始化、数据增强、Dropout、GPU浮点计算顺序都会引入随机因素。如果实验不固定随机种子,同一个脚本跑两次得到不同结果,你根本无法判断代码改动到底有没有效果。PyTorch里固定随机种子的标准姿势是在脚本开头设置三个种子:
import random import numpy as np import torch def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)不过这里也要说明,即使固定了种子,如果GPU上的操作是完全确定性的,不同CUDA版本之间结果仍会有细微差异。所以复现实验时,最好固定环境版本,把运行环境的包版本记录下来。这一点在写论文、做开源项目时尤其重要,自己私下调试反而可以放松一些。
5.3 中断恢复与断点续训:训练到一半崩了怎么办
训练到第30轮时电脑死机,如果从头再来,一个下午的算力就白费了。成熟的训练脚本应该具备断点恢复功能。PyTorch里保存检查点的方式很简单:每个epoch结束后保存模型的state_dict、优化器的state_dict、当前epoch数、最优验证准确率到同一个字典文件里。恢复训练时,把这三个状态都加载回来,再把调度器的状态也恢复,训练就能从保存位置继续走。
检查点还有另一个用途:记录最优模型。训练过程中验证集准确率可能在第35轮达到最高,之后反而下降,如果只保存最后一个epoch的模型,可能错过最优状态。一般做法是每次验证集涨了就覆盖保存模型文件,这样最终留下的始终是最好的模型。
5.4 训练曲线可视化:别只用终端打印数字
只看终端输出的数字很难看出训练趋势,我习惯用TensorBoard或直接把指标画成曲线图。每5到10个batch记录一次训练loss,每个epoch记录一次验证准确率和验证loss。曲线的趋势比单点数值重要得多,训练loss如果平滑下降,说明学习率合适;如果锯齿状严重,可能是batch size太小或数据乱序没开。验证loss和训练loss之间的距离能直观反映泛化差距,这个距离越来越大,就是在提醒你过拟合要来了。
6. 模型评估与保存部署:从训练完成到真正能用
6.1 测试集评估:验证集调参,测试集只做最终确认
训练过程中我们用验证集判断模型好坏、调整超参数,但如果反复用同一个测试集挑最优模型,实际上是在把测试集的信息泄漏到模型中。严格的做法是:训练集学参数,验证集选超参数,测试集只做最终一次评估。CIFAR-10的测试集有10000张,训练中完全不看,最后跑一次得到结果,这个数字才是可报出的准确率。
6.2 模型保存的格式选择:pt、onnx、TorchScript
模型评估满意后就要考虑怎么保存。PyTorch常见的两种方式:保存state_dict和保存整个模型。state_dict只包含参数和缓冲区,体积小,加载时需要先实例化模型结构再load_state_dict()。整个模型文件包含结构定义和参数,加载方便但可移植性差。代码兼容性方面,我倾向保存state_dict,因为它不依赖脚本中的类定义路径,别人拿去用时不容易出错。
如果要上线服务,光有PyTorch模型不够,还要考虑推理框架的兼容性。可以把模型导出成ONNX格式,这样可以用ONNX Runtime做CPU推理,或者转成其他平台的格式。导出ONNX的常见坑是动态输入尺寸问题,图像分类模型输入一般是固定尺寸,导出时不需要动态轴,反而省事。
6.3 单张图片推理:加载模型、预处理、预测三步走
保存好模型后,单张图片的推理流程有三个步骤:加载模型权重、对输入图片做和训练一致的预处理、前向传播取最大概率对应的类别。这里最容易出错的是预处理不一致,训练时用了RandomCrop和RandomHorizontalFlip增强,推理时如果也用了相同变换,预测结果会不稳定。推理时只保留Resize、ToTensor、Normalize这些确定性变换,所有随机增强一律取消。我见过很多学员把增强流水线原封不动搬到推理代码里,结果同一张图每次预测不同,就是这个原因。
7. 扩展与进阶方向:下一节该往哪里走
图像分类流程跑通之后,可以做的进阶方向不少。第一个方向是把CIFAR-10换成真实业务数据,流程完全一样,重点练习数据清洗和类别不均衡处理。第二个方向是尝试更大的模型,比如ResNet-50或EfficientNet,观察模型容量对精度和训练时间的影响,理解模型效率和效果之间的权衡。第三个方向是迁移学习:用ImageNet上预训练好的权重做微调,这是实际项目中最常用的做法,因为真实业务往往没有足够数据从头训练一个深网络。
就我自己的经验而言,图像分类是深度学习从理论到应用的一座最稳的桥。它能让你完整经历数据、模型、训练、评估、调参、部署的每个环节,每个环节踩过的坑都会转化为对深度学习更本质的理解。很多后来的从业者回忆起成长过程,都觉得是某一节图像分类实战打通了任督二脉。这一节课程的价值也正是在这里:不是教会你跑通一个模型,而是教会你独立设计、训练、诊断和改进一个完整系统的方法论。跟着把代码一行行敲完,多跑几轮实验,比看一百遍理论都管用。