
这个标题其实是我给自己“深度学习从零动手”系列定的编号。上一篇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(trainTrue): 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_classes10): super().__init__() self.features Sequential( Conv2d(3, 16, kernel_size3, padding1), ReLU(inplaceTrue), MaxPool2d(2), Conv2d(16, 32, kernel_size3, padding1), ReLU(inplaceTrue), MaxPool2d(2), Conv2d(32, 64, kernel_size3, padding1), ReLU(inplaceTrue), MaxPool2d(2), ) self.classifier Sequential( Flatten(), Linear(64 * 16 * 16, 256), ReLU(inplaceTrue), 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(dim1) 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个epochbest_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”项目里最大的体会是所谓深度学习其实就是大量小实验叠加的过程。你不需要一开始就用令人眼花缭乱的模型只需要把数据、模型、损失、优化这四个旋钮都拧对跑通一个简单版本再根据验证集反馈逐步调整。那些听起来很高级的效果都是这么一点一点试出来的。下一篇我大概率会在这个小网络上做迁移学习用预训练模型把验证准确率再往上推一截。如果你正卡在“代码能跑但效果不行”的阶段不妨先把这篇文章里的流程完整复现一遍遇到问题就拿那张速查表对照着查应该能帮你少走不少弯路。