☰
从零动手深度学习02:图像分类实战与CNN调参全记录
2026/10/18 5:54:47 网站建设 项目流程

这个标题其实是我给自己“深度学习从零动手”系列定的编号。上一篇01,我把开发环境搭好,跑通了一个最简单的线性回归任务;这一篇02,我打算把难度往上抬一点,主线换成图像分类。原因很简单:图像分类是理解深度学习最直观的入口——输入一张图,输出一个类别,整个流程里几乎包含了数据、模型、训练、评估这四大核心环节。把这套流程吃透,后面再做目标检测、语义分割,甚至文本处理,思路都是换汤不换药,只是数据形态和网络结构的差别。

这篇内容适合两类人阅读。第一类是刚搭好环境的小白,想找一个小而完整的项目把流程走通;第二类是已经写过几个训练脚本,但对“为什么这么调”“这一步到底在干什么”还比较模糊的初学者。文章里不会堆公式,而是用我实际跑实验的步骤和踩过的坑,把每个关键操作背后的原因讲清楚。假如你手头只有一台普通笔记本,没有独立显卡,也完全不用慌,下面这个实验用CPU就能跑,只是训练时间会慢一些,我会在文中给出参考时间。

1. 项目定位与整体设计思路

1.1 我到底在做一个什么问题

我选的实验任务是:给图片分类。具体来说,我用了一份开源的迷你花卉数据集,里面包含10个常见花卉类别,每个类别的样本量从几十张到一百张不等,图片尺寸也有差异。任务就是训练一个模型,让它看到一张没见过的花,能判断出属于哪个类别。

为什么不选更复杂的目标检测或者语义分割?因为那些项目的前置知识太多,调试起来也麻烦。图像分类是“最小可用闭环”,把分类跑通,你就知道训练循环里每一行代码正在做什么。之后再往更复杂的任务走,你已经有了一套完整的骨架,剩下的只是往骨架里填不同的内容。

深度学习项目表面上看起来花样很多,但骨架是固定的四件套:数据、模型、损失函数、优化器。数据决定了你能学什么,模型决定了能学到多复杂的东西,损失函数在告诉你当前错得有多离谱,优化器负责根据这个错误去更新参数。后面我做的任何项目,本质上都是在这四个位置上换内容,而不是跳出这个框架。所以这个系列02,其实就是把四件套完整地走一遍,走完你就有了自己的“标准动作”。

1.2 为什么不用手工特征,而要用神经网络

在深度学习普及之前,做图像分类的主流方法是手工设计特征。有人提取颜色直方图,有人计算边缘信息,还有人用更复杂的纹理描述子,然后把特征送进一个浅层分类器。这套方法在小数据集上确实有效,但它有一个致命问题:特征是人设计的,设计者的经验和想象力就是模型的天花板。换一个任务、换一种场景,特征往往要从头设计一遍,成本非常高。

深度学习换了一条路:我只需要定义网络结构,它自己从数据里学习该用什么特征。这个过程的直觉可以用“拼图”来理解。网络的第一层卷积在学最基础的小块,比如横向边缘、竖向边缘,就像拼图里的单色块;中间层把基础块组合成纹理,比如花瓣的褶皱、叶子的脉络;再往后组合成更大的部件,比如整个花冠的形状。不同的训练数据会让它自动学会不同的“拼图块库”,不需要人去规定这些块长什么样。

不过,自动学特征不意味着模型越大越好。我在实验里刻意选择了一个很小的卷积神经网络,参数量只有十多万。原因有两个:第一,这个小数据集本身只有几百张图,太大的模型很容易把训练样本“背下来”,而不是学到通用规律;第二,小模型在CPU上几分钟就能跑一轮,方便我快速迭代参数。这个选择本身就是重要的工程思维:先跑通,再跑好,别一上来就追求大模型和复杂结构。

2. 数据准备与预处理

2.1 数据目录怎么组织,训练集和验证集怎么划分

拿到数据以后,第一步不是写模型,而是把文件夹整理好。我习惯按类别建子文件夹,每个文件夹放这一类别的所有图片。好处是代码可以按文件夹名读标签,后面换数据集也不需要改整体逻辑。目录结构大致是下面这样:

data/ train/ A类/ B类/ ... J类/ val/ A类/ B类/ ... J类/ test/ 待预测图片/

这里我特意用了train、val、test三个目录。很多人只分训练集和测试集,这是不够的。训练集就像平时做习题,验证集像模拟考,测试集才是最终考试。你要通过验证集的成绩来选模型、调超参数,所以验证集绝不能参与到训练过程中。最后做完所有决定,再用测试集做一次评估,结果才是可信的。

划分比例上,样本多的时候按8:1:1来分,样本少的时候验证集和测试集各留15%左右也可以。关键是做划分时要固定随机种子,否则每次运行结果都不一样,你根本无法判断改动到底有没有效果。我第一次做这个实验就没固定随机种子,第二天跑同样的代码,准确率差了将近三个百分点,后来排查了很久才发现是数据集打乱顺序的问题。这个坑各位一定要避开。

2.2 从图片文件到张量:尺寸统一与标准化

深度学习框架不认识图片文件,它认识的是张量。一张彩色图片在计算机里是三个二维矩阵叠在一起,每个矩阵对应红、绿、蓝一个通道,每个元素是0到255的整数。在处理之前,我需要做两件事:统一尺寸和标准化。

统一尺寸很简单,把所有图片缩放到同样大小。我这次用的是128×128,因为原始图片大小不一,太小会丢掉细节,太大又浪费计算。128×128×3的张量输入给一个小网络,CPU完全扛得住。如果你用的是更大尺寸的数据集,可以先从64或96开始,跑通后再往上加。

标准化就需要稍微解释一下了。神经网络里的参数初始化通常假设输入数据分布在0附近,如果直接把0到255的像素值丢进去,数值范围太大,梯度更新会很不稳定。标准做法是每个通道先减均值,再除以标准差,把数据拉成均值为0、方差为1的分布。这就好比同一道应用题,用厘米和毫米写答案都能算对,但混在一起就乱了。代码可以写成类似下面这样:

# 按通道统计训练集的均值和标准差 mean = [0.485, 0.456, 0.406] # 一份常见自然图片的均值近似值 std = [0.229, 0.224, 0.225] # 对应的标准差近似值 def make_transforms(train=True): if train: return Compose([Resize((128, 128)), RandomHorizontalFlip(), RandomRotation(15), ColorJitter(0.3, 0.3, 0.3), ToTensor(), Normalize(mean, std)]) else: return Compose([Resize((128, 128)), ToTensor(), Normalize(mean, std)])

我这里直接用了图像领域通用的一组均值和标准差近似值,没有自己重新统计。如果你的数据是灰度图,或者来自特殊的拍摄场景,最好根据实际数据重新计算。这个细节往往决定了模型能不能快速收敛,我见过不少新手在标准化这步偷懒,结果loss一直横着不动,回头排查才发现输入取值范围差了好几个数量级。

2.3 数据增强:让有限的样本发挥更大的价值

这个小数据集总共只有几百张图片,直接训练很容易过拟合。数据增强就是给训练数据“加戏”:水平翻转、随机角度旋转、轻微裁剪、颜色微调,都是很常用的手段。它背后的逻辑是:真实世界里同一朵花,从左边拍和从右边拍,光照不同、角度略有偏移,都应该是同一个类别。把这种变化模拟进训练数据,模型就能学到更稳健的特征,而不是死记硬背某几张图。

需要注意的一点是,我在上面代码里把增强操作只加在了训练集的分支里。验证集和测试集只做缩放和标准化,不能做随机翻转或随机裁剪,否则每次评估结果都不一样,你看到的准确率带着随机噪声,根本无法判断模型的真实水平。这个细节在调试的时候尤其重要。

数据增强虽然好,但也不是越猛越好。随机裁剪比例过高,会把花的中心区域裁掉,等于给样本乱标标签;旋转角度太大,人眼都认不出来,模型也容易被带偏。我从15度旋转开始试,效果不够好再逐步加大。这种“从保守开始,逐步加量”的顺序,比一开始就上大幅度操作要稳妥得多。

3. 模型构建与训练流程

3.1 为什么图像要用卷积层,而不是全连接层

如果只让我用全连接层搭一个图像分类器,理论上也可以,但参数量会爆炸。假设输入是128×128×3的图,把它拉平成向量就是49152个数,第一个全连接层哪怕只有256个神经元,也需要一千多万个参数。这种结构完全没有利用图片的二维空间关系——相邻的像素是相关的,远处的像素相关性弱,而全连接层把远近信息一视同仁地处理,效率很低。

卷积层就是针对这个问题设计的。它用一个很小的窗口在图上滑动,每次只处理窗口里的局部区域,而且窗口参数在所有位置共享。这么一来,既保留了局部空间关系,又把参数量降了好几个数量级。这里可以用生活里的例子来理解:一家公司如果让每个员工认识所有客户,管理成本会高到离谱;换成每个窗口只服务自己区域、但窗口接待标准全公司统一,成本立刻就下来了,还能保证服务质量一致。

3.2 我搭的这个简单网络,每一层都在做什么

实验里用的结构非常朴素,没有引入任何花哨模块,但足够看清楚CNN的基本组成。我用的是三个卷积块加两个全连接层的组合:

class SimpleCNN(Module): def __init__(self, num_classes=10): super().__init__() self.features = Sequential( Conv2d(3, 16, kernel_size=3, padding=1), ReLU(inplace=True), MaxPool2d(2), Conv2d(16, 32, kernel_size=3, padding=1), ReLU(inplace=True), MaxPool2d(2), Conv2d(32, 64, kernel_size=3, padding=1), ReLU(inplace=True), MaxPool2d(2), ) self.classifier = Sequential( Flatten(), Linear(64 * 16 * 16, 256), ReLU(inplace=True), Dropout(0.5), Linear(256, num_classes), ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x

输入是128×128×3。第一层卷积输出16张特征图,尺寸还是128×128;池化把尺寸减半到64×64。第二层卷积输出32张特征图,池化后变成32×32。第三层卷积输出64张特征图,池化后变成16×16。最后把特征图拉平成64×16×16等于16384个数,送入全连接层,输出10个类别的分数。

这里有一个关键理解:卷积层并不是直接把图片变成类别,而是不断把“空间位置信息”转换成“更抽象的通道信息”。原始图片是3个通道的颜色,第一层变成16个基础特征,第二层变成32个组合特征,第三层变成64个高级特征。后面的全连接层负责把这些特征加权汇总成类别分数。整个过程很像公司里的层层汇报:基层收集细节,中层整理成纪要,高层只看最终结论。

3.3 损失函数、优化器和训练循环

多分类任务最常用的损失函数是交叉熵。用大白话说,它先把模型输出的10个分数转成概率分布,再与真实标签做比较:如果模型对正确类别的置信度很高,loss就小;如果置信度低,loss就大。为什么不直接用均方误差?因为分类输出的是离散类别,交叉熵对“置信度差了多少”更敏感,梯度更新也更有效,这是大量实践验证过的默认选择。

优化器我选了Adam,初始学习率1e-3。Adam相当于给每个参数单独调整学习步长,更新过程比较平稳,对新手很宽容,不容易出现梯度爆炸。很多人纠结要不要用SGD加动量,我的建议是:先用Adam把流程跑通,之后再换SGD做细调也不迟,不要在优化器选择上过度纠结。

训练循环本身不复杂,核心就是五件事:把模型设为训练模式、清零梯度、前向传播、计算损失、反向传播更新参数。

for epoch in range(30): model.train() train_loss = 0 for batch_x, batch_y in train_loader: optimizer.zero_grad() out = model(batch_x) loss = criterion(out, batch_y) loss.backward() optimizer.step() train_loss += loss.item() * batch_x.size(0) model.eval() val_loss = 0 correct = 0 # 关闭梯度记录,验证阶段不需要反向传播 for batch_x, batch_y in val_loader: out = model(batch_x) loss = criterion(out, batch_y) val_loss += loss.item() * batch_x.size(0) pred = out.argmax(dim=1) correct += (pred == batch_y).sum().item()

这里有一个非常容易踩的坑:训练时和验证时,模型要切换不同的模式。train()会启用Dropout和批归一化这些层在训练时的行为,eval()会关闭它们。新手最容易忘记切模式,结果验证结果忽高忽低,还以为是模型出了问题。我自己就犯过这个错,后来发现验证集和训练集的loss表现完全对不上,排查了很久才想到是模式切换的问题。

批次大小我选了32。batch size太小,梯度噪声大,收敛不稳;太大,一次要处理很多样本,内存容易不够,而且收敛到的地方常常不够好。CPU上这个项目跑起来,32是一个比较中庸的选择。如果你内存不够,优先减小batch size,而不是缩小图片尺寸,因为图片尺寸改变会连带着模型输入形状一起变化,影响面更大。

3.4 训练过程中应该盯住哪些指标

训练期间我最常看的不是训练准确率,而是验证集loss和验证集准确率。训练loss下降说明模型在拟合训练数据,验证loss下降才说明模型学到了可以泛化的规律。我习惯把每个epoch的结果打印出来,最后画成两条曲线:一条训练loss,一条验证loss。如果两条曲线一起下降,说明方向没问题;如果训练loss还在降,验证loss已经开始往上升,那就是过拟合冒头了。

另外,时刻记住:小数据集上的训练准确率可以很容易做到99%,但那只是“背题”背得好,不代表考试能力强。真正决定模型能不能用的是验证集表现。这个过程就好比备考,平时刷题正确率再高,都不如模拟考成绩有参考价值。

4. 评估与过拟合问题处理

4.1 一张图看懂欠拟合、过拟合和正常训练

很多人把过拟合挂在嘴边,但真正看到曲线时却判断不了。我的标准很简单:看训练loss和验证loss的位置关系。如果两者都在高位,谁也降不下来,这是欠拟合,说明模型容量不够、学习率太低或者训练轮数不足。如果训练loss一路走低,验证loss先降后升,且两条线之间的距离越拉越大,这是过拟合。正常情况应该是两者一起下降,差距不大,最后收敛在比较低的位置。

对照下来,我这个基础模型在20个epoch附近开始出现过拟合迹象:训练准确率超过95%,验证准确率却停在82%左右,差距明显。这时候我的选择有几个:增强数据增强强度、加入Dropout、缩小模型容量、启用早停。我最后同时做了两件事,一是把Dropout加进全连接层,二是设置早停条件,验证集准确率从82%提升到了88%左右。

4.2 验证集准确率之外,还要看混淆矩阵

准确率这个数字会掩盖很多细节。比如整体准确率88%,但很可能有一类花几乎全错,另一类却接近满分。只看一个总数,你不知道问题到底出在哪。我做这个实验的第二个版本时就是这种情况,于是打印了混淆矩阵。

混淆矩阵是一个10×10的表格,行是真实类别,列是预测类别。我一眼就看出模型把A类和B类互相搞混的次数特别多。点开样本图对比之后发现,这两类花不仅颜色接近,而且B类的训练样本里有很多是侧面视角,模型根本没看过足够的正面样本。优化方向一下就变得很明确:不是换模型,而是给B类多找些不同角度的训练样本,或者对B类做更强的翻转和旋转增强。这种定位问题的能力,是准确率数字给不了你的。

4.3 Dropout和早停的具体配置

Dropout的原理很直白:训练时以一定概率随机把某些神经元置为0,让网络不能依赖某一条固定通路。如果用备考来类比,就是不要只背一套解题模板,而是学会用多种思路解同一道题,考试时才不容易被变化打乱。我一般放在最后的全连接层前面,比率用0.5;放在卷积层之间时,用0.2到0.3比较合适。放得太早太猛会造成欠拟合,所以从小比率开始试更稳妥。

早停的实现也不复杂:每个epoch结束之后,比较当前验证loss和历史最优验证loss,如果连续几个epoch都没有刷新最优记录,就停止训练,并把模型权重恢复成最优那一次的状态。我常用的逻辑是连续等待5个epoch:

best_loss = float('inf') bad_epochs = 0 patience = 5 for epoch in range(max_epochs): # 训练和验证... if val_loss < best_loss: best_loss = val_loss best_state = copy.deepcopy(model.state_dict()) bad_epochs = 0 else: bad_epochs += 1 if bad_epochs >= patience: print("早停触发") model.load_state_dict(best_state) break

早停并不是为了避免过拟合,而是在过拟合开始后及时止损,避免最后一次epoch把最优权重覆盖掉。我建议每个训练脚本都配上早停,因为早期epoch的验证结果经常比末期更好,这在数据量小的项目里特别明显。不做早停,哪怕你已经看到验证准确率开始下降,也只能眼睁睁看着模型继续学“坏”。

5. 常见问题与排查技巧实录

5.1 环境安装与运行速度问题

这个项目对环境的要求不高,但安装步骤里仍然有几个常见的坑。一是依赖库版本互相冲突,尤其是指定版本号时给得太新,反而和旧代码不兼容。我现在习惯新建一个独立环境,先装核心依赖,再逐个装辅助库,不要一口气全部装完。二是CPU训练很慢,我这个项目跑满30轮大概需要十几分钟,算是可以接受的范围。如果你用小图也跑出几十分钟一轮,先检查验证集的transform是不是和训练集写成了同一个。验证集如果也带了随机增强,额外计算量会高很多,而且结果还会带噪声,非常影响判断。

5.2 模型不收敛——从loss的表现判断病因

遇到loss不下降,我的建议是先别急着换模型,先看曲线形态。如果loss变成NaN,大概率是学习率太大,把数值冲到无效范围,直接把学习率除以10重跑通常就能解决;如果数据里存在缺失值或无穷值,也会导致NaN。如果loss从头到尾几乎是水平线,可能是数据没有标准化,或者标签和图片没有对齐,我遇到过类别索引写错位的情况,模型一直在拟合错误答案,自然怎么学都学不动。如果loss在下降,但验证准确率一直等于随机水平,比如10类任务约等于10%,那要检查数据加载时有没有打乱顺序,以及类别样本是不是严重不均衡。

现象可能原因优先排查动作
loss为NaN学习率过大、数据含无效值学习率除以10,检查输入数据
loss不降数据未标准化、标签错位打印一个batch的样本和标签核对
验证准确率停在随机水平类别不均衡、忘记打乱数据查看每个batch类别分布
训练和验证差距大过拟合加Dropout、加强数据增强、开早停
验证结果每次跑都不一样随机种子未固定、验证集用了增强固定随机种子,检查transform分支

这个表是我每次开新项目都会对照一遍的排查清单,排查效率比一遍一遍乱试高得多。

5.3 小白最容易忽略的三个小细节

最后分享三个我实际操作中踩过的小细节。第一,保存模型时不要只保存权重,最好把当时用的超参数也一起记下来,比如学习率、epoch数、数据增强方式,写成配置文件或者注释。否则过了两个月再打开这个项目,你完全想不起来当时是怎么调的,复现实验结果会花掉一整晚。第二,每次改代码只改一处,不要同时换模型结构和学习率,否则出了问题你永远分不清是谁的功劳、谁的过错。第三,训练过程中把每个epoch的loss写到日志文件里,不要只输出到屏幕上,因为窗口一关就全没了。有日志才能回头画曲线、做对比,这是后面所有调参工作的基础。

我在这个“深度学习02”项目里最大的体会是:所谓深度学习,其实就是大量小实验叠加的过程。你不需要一开始就用令人眼花缭乱的模型,只需要把数据、模型、损失、优化这四个旋钮都拧对,跑通一个简单版本,再根据验证集反馈逐步调整。那些听起来很高级的效果,都是这么一点一点试出来的。下一篇我大概率会在这个小网络上做迁移学习,用预训练模型把验证准确率再往上推一截。如果你正卡在“代码能跑,但效果不行”的阶段,不妨先把这篇文章里的流程完整复现一遍,遇到问题就拿那张速查表对照着查,应该能帮你少走不少弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询