ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零训练小型CNN:图像分类实战全流程拆解

从零训练小型CNN:图像分类实战全流程拆解

从 LeNet-5 那个年代开始,图像分类就一直扮演着视觉模型“启蒙课”的角色。哪怕是到了多模态大模型满天飞的现在,图像塔里那个主干网络,本质上仍然是个做分类预训练出来的特征提取器。这篇是《多模态大模型学习笔记》系列第十篇,我不打算去聊多么宏大的架构改动,而是回到最基础的动作:不加载任何预训练权重,纯手写一个小型卷积神经网络,完成一次从数据准备、网络搭建、训练调参到结果评估的完整图像分类实战。

你会发现真正卡住你的往往不是模型结构,而是数据管道的组织、归一化的细节、训练脚本里那些看不见的坑。这篇笔记就是把这一整套流程拆开揉碎,让你拿去能跑、跑完能懂、懂了能改。我默认你已经有 Python 基础,会一点 NumPy,对卷积神经网络有过模糊认识但未必亲手写过完整训练循环。跟着走一遍,后面再去看多模态模型里的视觉分支,会顺手很多。

1. 为什么这篇笔记要从一个小型CNN分类器跑起

1.1 多模态模型里的“视觉腿”本质上是个分类器

很多人会有个误区,觉得多模态模型那么复杂,视觉部分一定是用了什么神秘结构。其实你去看那些经典的双塔结构模型,图像分支的那套主干网络,基本就是从图像分类模型沿用过来的。区别只是最后把“预测类别”的全连接头换成了“输出特征向量”的投影层。换句话说,分类任务训练出来的视觉特征,是被多模态模型直接拿去做图文对齐的底座。

所以如果你能亲手把一个小型分类器从零训练到能用,你就能明白视觉编码器到底在提取什么特征、最后一层输出的向量为什么能表示一张图、以及后面做图文匹配时那个“特征对齐”到底对齐的是什么。这个过程对后续理解多模态模型很有帮助。

1.2 这篇笔记覆盖的范围和我用的环境

本篇是系列里的一个实战节点。我会把整条链路分成四块:数据管道怎么搭、网络结构怎么选、训练循环怎么写、模型怎么评估。

我这边运行环境是当前主流的深度学习框架配合 Python 3.x,显卡条件一般的话也没有关系,因为图像尺寸和网络规模都控制得比较小,用 CPU 也能跑完整个训练流程,只是慢一些。建议初学者先在本机把代码跑通,再去考虑上 GPU。

一句话总结本篇目标:你自己亲手写出来的模型,在验证集上达到一个合理可用的准确率,并且你能说清楚每个模块为什么这么设计。

2. 数据集准备与数据管道:图像分类的第一道坎

2.1 数据集选型和目录整理

我这次使用的是一份公开的花卉图像数据集,共10个类别,每类大约2500张图片,总计两万多张。选择它的原因是:类别数不多也不少,单标签任务足够纯粹,图像内容本身有明显视觉差异,但同类里也有角度、光照、遮挡的变化,拿来练手很合适。

拿到原始数据后最重要的一件事,就是把目录结构整理成框架能直接识别的形式。按类别建文件夹,每个类别的图片放进去:

data/ ├── train/ │ ├── daisy/ │ ├── rose/ │ ├── tulip/ │ └── ... ├── val/ │ ├── daisy/ │ ├── rose/ │ ├── tulip/ │ └── ... └── test/ ├── daisy/ ├── rose/ ├── tulip/ └── ...

这种“训练集/验证集/测试集按类别分文件夹”的组织方式,是所有分类任务的通用约定。你后续不管换什么框架、换什么工具,按这个方式整理数据几乎都能直接接上。我习惯把原始图片按8:1:1划分到三个目录,这样训练时不会和验证数据混在一起,避免评估结果虚高。

2.2 归一化与尺寸调整不能想当然

这一步看起来基础,但我见过太多新手在这个地方埋坑。图片被读进来的时候是一个 H×W×C 的数组,取值范围是0到255的整数,而深度学习框架需要的通常是 C×H×W 的浮点张量,且最好服从均值为0、方差为1的分布。这两次转换缺一不可。

第一次转换是通道维度调整:把通道维从最后挪到最前(HWC到CHW),这由数据加载框架自动完成。第二次是归一化:先用255把像素缩放到0到1,再用数据集的均值标准差做标准化。很多人会跳过标准化,只做缩放,结果也能跑,但收敛速度和稳定性会有明显差距。标准化让不同通道的数据分布居中,相当于把每条数据都拉到同一个坐标系里,优化器不用在不同量纲之间来回折中。

尺寸方面我统一调整为96×96。为什么不是原始分辨率?一来显存开销小、训练速度快;二来96×96对花卉这种主体突出的图像足够表达特征。做分类任务不必一味追求高分辨率,先让管道通起来,后面真要提升精度再加大尺寸不迟。训练时为了增强鲁棒性,我一般用随机裁剪加缩放来模拟距离变化,验证集则固定居中裁剪,保证评估结果稳定。

2.3 数据增强:不是把数据“变多”,而是给模型注入先验

数据增强是图像分类里最划算的投资。很多人误以为它是为了让数据量变大,其实它的核心作用是把人类先验注入到模型中:同一朵花,翻转一下、裁剪一下、颜色偏一点,类别没有变。模型在不断的随机增强中学会对这些无关变化不敏感,从而更加关注真正的分类依据。

我这次使用的增强组合如下:

  • 随机裁剪缩放,模拟相机距离和角度的变化
  • 随机水平翻转,模拟观察角度的左右变化
  • 随机颜色抖动,模拟光照和成像差异

注意一个关键原则:数据增强只加在训练集上,验证集和测试集只做固定尺寸的缩放和居中裁剪。如果验证集也做随机增强,每次评估的输入都不一样,指标就会有额外方差,你很难判断一个准确率的提升到底是模型变好了还是随机性造成的。

训练时对每个batch重新随机增强,意味着同一个样本在不同epoch看到的是不同版本。这种“看万花筒”式输入方式能显著抑制过拟合,配合后面会提到的dropout和早停,基本可以覆盖小数据集的泛化需求。

3. 从LeNet到ResNet:我最后选了一个怎样的卷积栈

3.1 卷积、池化、全连接:三个基础算子在干什么

动手写网络之前,先把三个基础算子的直觉理清楚。

卷积核就像一个小型滑动窗口,每次扫描图片的局部区域,提取该区域内的某种局部模式。使用多个卷积核就可以同时检测多种模式,比如边缘、角点、纹理。卷积有两个关键属性:局部连接和权值共享。局部连接意味着每个神经元只关心图像的一小块区域,符合图像局部相关的特点;权值共享则意味着同一个卷积核在整张图上复用,大幅减少参数量。

池化的作用是下采样。它把一片区域的响应浓缩成一个值,让特征图尺寸减半。只不过现在很多网络更倾向于用步长为2的卷积替代池化,因为可学习的下采样比固定取最大值或平均值的方式更灵活。对小型网络来说,池化依然简单可靠,一般用于早期阶段快速降分辨率。

全连接层则是在空间特征之上做全局决策。它把三维特征图拉平成向量,再经过若干线性变换映射到类别数。你可以把卷积层理解成“特征提取器”,全连接层理解成“分类器”。两个部分在训练中是端到端一起优化的,分类器也会反过来影响特征提取器的学习方向。

3.2 我搭建的小型CNN结构:三层卷积加一个分类头

这次我没有直接搬一个很深的网络,而是先定义一个三层卷积栈。结构如下:

  • 第一层:3通道输入,32个3×3卷积核,批归一化,ReLU,最大池化
  • 第二层:32通道输入,64个3×3卷积核,批归一化,ReLU,最大池化
  • 第三层:64通道输入,128个3×3卷积核,批归一化,ReLU,最大池化
  • 分类头:全局平均池化得到128维向量,接dropout,再接10分类全连接层

我特意在分类头里做了全局平均池化,而不是直接把特征图拉平。这样做的好处是无论输入尺寸怎么变,最后得到的特征向量长度都是固定的,模型对分辨率变化更宽容。代码如下:

import torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.pool = nn.AdaptiveAvgPool2d(1) self.dropout = nn.Dropout(0.5) self.classifier = nn.Linear(128, num_classes) def forward(self, x): x = self.features(x) x = self.pool(x) x = x.view(x.size(0), -1) x = self.dropout(x) return self.classifier(x)

为什么每层通道数翻倍?因为卷积网络的抽象级别是逐层递进的。低层在学边缘、颜色块,中层开始组合出纹理和局部形状,高层才逐渐对应到花瓣、叶片这类语义部件。每一层的特征都需要更多通道来承载,所以通道数翻倍符合信息增长的直觉。

为什么都用3×3小卷积核?两个3×3堆叠感受野等效于一个5×5,但参数量更少,而且中间多了一次非线性变换,表达能力更强。这也是传统分类网络被证明有效的设计经验。

3.3 经典结构的演进逻辑:BN和残差到底解决什么问题

把时间轴拉长看,网络结构演进其实是在回应两个问题:怎么让网络更深,以及怎么让深层网络好训练。

LeNet-5是最早的卷积网络之一,它确立了“卷积+池化+全连接”的基本范式,当年用来做手写数字识别已经很好用。VGG进一步把卷积核统一成3×3,通过不断堆叠卷积层来增加深度,用实验证明了深度对性能的重要性,代价是参数和计算量爆炸。ResNet的核心创新是残差连接:把输入加到卷积输出上,让每一层学习的目标从“完整映射”变成“残差修正”。这样梯度可以顺着跳跃连接直达浅层,深层网络的优化难度显著降低,几十层上百层才变得可行。

批归一化又是一个里程碑。它把每一层的输入分布强行拉回到更稳定的区间,减少了所谓“内部协变量偏移”的影响。加了BN之后,网络对初始化和学习率的敏感度大幅下降,这也是为什么现在训练一个网络比十年前要省心很多。

我这次的小型网络结构只用了三层卷积,按理说堆到十几层也不会退化,因为训练数据的规模和类别复杂度撑不起那么大的容量。以下是网络中各层输出的尺寸和参数量概览:

层输出尺寸参数量估算
输入图片3×96×96-
Conv1 + BN + ReLU32×96×96约0.3万
MaxPool32×48×480
Conv2 + BN + ReLU64×48×48约1.9万
MaxPool64×24×240
Conv3 + BN + ReLU128×24×24约7.4万
MaxPool128×12×120
GlobalAvgPool1280
分类头10约0.1万

整体参数量十万不到,训练非常快。就是这样一个“轻量级”网络,足以让你把整套流程跑通,并且得到一组可以分析的结果。

3.4 为什么不直接加载一个预训练大模型

说实话,如果目标是刷准确率排行榜,我肯定会直接加载别人训练好的大模型,效果马上就能提升一大截。但从学习角度来说,直接用预训练模型会让你跳过所有不该跳过的理解过程。

自制小型CNN能把所有变量握在自己手里:换一个数据增强看准确率怎么波动、加一层dropout看泛化怎么变化、改一次学习率看收敛怎么起伏。这种“动一个旋钮看一次反应”的体验,是黑盒使用预训练模型给不了的。等后面你真正去做多模态项目,需要自定义视觉分支时,大多数情况下也要在自有数据上重新训练,那时候你不可能只靠加载现成权重解决所有问题。

所以这一篇我坚持从零训练,不是否定预训练,而是先把自己的底子打牢。

4. 训练脚本的骨架:损失函数、优化器、学习率的一次性配置

4.1 类别分布与评估指标先想清楚

开始训练之前,把评估方式定下来比写代码更优先。分类任务最基础的指标是top1准确率,但它掩盖了太多信息,尤其是类别不均衡的时候,可能整体准确率看着还行,某几个小众类别却从来没被正确预测过。

我这次的数据集类别相对均衡,所以我会先用准确率做快速迭代,同时记录每个类别的准确率。你如果自己拿到不均衡的数据集,就需要额外考虑加权采样,或者计算每个类别的召回率再求平均。先确认好这些,后面的训练才会有的放矢。

损失函数我直接用交叉熵。它是分类任务的标准选择。框架里封装的交叉熵损失会自动对模型输出的logits做softmax再计算损失,你不用手动加softmax层。

4.2 训练循环代码骨架

训练循环本身并不复杂,核心逻辑是四个动作:前向传播、计算损失、反向传播、更新参数。验证环节则要关掉梯度计算,避免显存浪费和意外的反向传播。下面是完整的骨架代码:

import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms # 数据管道 train_transforms = transforms.Compose([ transforms.RandomResizedCrop(96), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transforms = transforms.Compose([ transforms.Resize(104), transforms.CenterCrop(96), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_set = datasets.ImageFolder("data/train", transform=train_transforms) val_set = datasets.ImageFolder("data/val", transform=val_transforms) train_loader = DataLoader(train_set, batch_size=64, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_set, batch_size=64, shuffle=False, num_workers=4, pin_memory=True) model = SimpleCNN(num_classes=10) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) best_val_acc = 0.0 for epoch in range(30): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() train_loss = running_loss / total train_acc = correct / total # 验证 model.eval() val_correct = 0 val_total = 0 with torch.no_grad(): for images, labels in val_loader: outputs = model(images) _, predicted = torch.max(outputs, 1) val_total += labels.size(0) val_correct += (predicted == labels).sum().item() val_acc = val_correct / val_total scheduler.step() print(f"Epoch {epoch+1}/30 | train_loss {train_loss:.4f} | " f"train_acc {train_acc:.4f} | val_acc {val_acc:.4f}") if val_acc > best_val_acc: best_val_acc = val_acc torch.save(model.state_dict(), "best_model.pth")

这段代码里的几个细节值得说明。训练时optimizer.zero_grad()必须放在前向传播之前,否则梯度会累加上一轮的数值;每次反向传播后用step更新参数;每轮结束调用scheduler.step()更新学习率。验证时用torch.no_grad()包裹,告诉框架不需要构建计算图,既省显存又省时间。

4.3 超参数选择:Adam还是SGD、学习率怎么定

优化器我这次选了Adam,因为它对学习率宽容度高,默认的1e-3就能跑出不错结果,适合快速验证管道。SGD加动量在图像分类上往往能打磨出更高的精度,代价是需要配合更好的学习率调度策略,比如余弦退火,并且要对初始学习率做几轮尝试。我的建议是先Adam跑通,后面想追求更优结果再换SGD。

学习率直接影响训练曲线的形态。学习率太高,loss会震荡甚至发散;太低,收敛慢得让人失去耐心。我这里用了一个通用策略:初始1e-3,配合余弦退火让学习率在整个训练周期内平滑下降。前几个epoch如果看到loss有下降趋势但比较慢,可以先不做调整,等曲线平了再动手。

batch size设为64对96×96的小图非常友好。如果显存不足,优先降batch size到32,通常影响不大。数据加载的num_workers可以按CPU核数调整,pin_memory在GPU训练时能提升一点数据传输效率。

还有一个小技巧:先用两个batch的数据跑一次过拟合测试。这个测试的意思是,在小数据量上训练几十步,看loss能不能降下去、准确率能不能到100%。如果能,说明数据管道、模型代码、训练循环都没有问题;如果连小batch都过拟合不了,那一定是代码有bug,此时不必急着跑全量数据。

5. 训练过程中真实会遇到的问题和排查思路

5.1 loss不降或震荡的排查链路

训练过程中最让人崩溃的问题就是loss完全不动,或者忽高忽低地震荡。我第一次跑类似项目时被这个现象卡了整整一天,后来发现是数据归一化的均值标准差设置错了。现在再遇到这类问题,我会按下面这个顺序排查。

第一步确认数据管道。打印一个batch的图像张量,检查数值范围是不是大致符合标准化的分布;同时打印标签,看类别编号是否从0开始连续。数据加载出问题会静默地影响整个训练,早期排查收益最高。

第二步确认正向传播和损失计算。用一个固定batch前向一次,看输出形状是不是和类别数一致;再手动检查一下损失函数输出有没有异常,比如极端的NaN。

第三步确认反向传播和参数更新。这一步容易被忽略:看看每次optimizer.step()之后模型参数的权重有没有发生变化。如果参数纹丝不动,要么是梯度被关闭了,要么是损失没有连接到模型参数上。

第四步考虑学习率。loss震荡不下来时,把学习率降一个数量级再试。1e-3降1e-4,往往就能从发散转为平稳下降。

快速定位的方法我前面也提了:构建一个只有两个样本的小batch,过拟合它。这个测试能隔离大部分系统性bug。如果两样本过拟合都不行,问题一定在代码;如果行了但全量数据不收敛,才是超参数问题。

5.2 过拟合:训练acc高、验证acc低的处理顺序

训练进行到一半时,几乎必然会看到训练准确率一路走高,验证准确率却开始回落。这是标准的过拟合信号。处理方法有固定顺序,不要一上来就换模型。

最优先做的是增强正则和早停。数据增强已经在了,可以再加一个随机擦除或用更强的颜色抖动;dropout从0.5提到0.6也有帮助;同时把保存策略改成“保留验证集上最佳模型”,而不是训练最后一轮的模型。这三点成本低、见效快。

然后看weight decay。Adam类优化器对weight decay的处理略有讲究,不过直接用普通weight decay也能看到效果。把它从0调到1e-4,很多情况就缓和了。

最后才是降模型容量。比如把通道数从32/64/128减半到16/32/64。这时网络变小,拟合能力下降,过拟合自然缓解,但前提是前面的正则手段已经不够用再考虑。下表总结了不同手段的适用场景:

手段成本适用场景
增加数据增强低训练充分但泛化不足的头号选择
提高dropout低全连接层占比大时效果明显
早停/保存最佳低所有训练都应该默认开启
增加weight decay低过拟合程度较轻时
减小模型容量中前几种手段效果有限时的备选

5.3 容易被忽略的“虚假高准确率”

准确率看着很高,不代表模型真的学到了东西。我遇到过两种情况特别容易骗人。

第一种是类别不均衡。假设10个类别里有一个类别占了50%的样本,模型只要把所有输入都预测成那一类,整体准确率就有50%。表面成绩单很好看,实际模型形同虚设。判别方法很简单:查看混淆矩阵或者每类的召回率,如果某个类别的召回率只有个位数,基本就是被多数类绑架了。

第二种是训练验证同源泄漏。如果你划分数据集时不小心让同一张照片既出现在训练集,又出现在验证集里,评估结果会乐观到不可信。更隐蔽的情况是,数据集中同一朵花的连续帧看起来很相似,随机划分时可能被分到两边。解决办法是划分时按“图像分组”或“拍摄批次”为单位切分,而不是逐张随机切。

还有一种现象值得单独说:某次实验我跑了10个epoch,验证准确率一直卡在10%左右。这个数值在10分类任务里恰好等于随机猜测,这说明模型根本没有学到任何规律。这种时候别急着调参,先检查标签映射是否错位,再检查训练集和验证集的类别顺序是否一致——我就曾经因为验证集分类目录顺序和训练集不一致,让模型“认真”学习了错误的对应关系,结果自然非常稳定,且稳定地差。

6. 评估与可视化:模型到底学到了什么

6.1 混淆矩阵与逐类指标

只看准确率无法知道模型错在哪里。我的做法是在验证集上把所有预测结果收集起来,生成混淆矩阵。矩阵的行是真实类别,列是预测类别,对角线的值越大越好。对角线之外那些值大的格子,就是模型最容易搞混的类别对。

花卉数据集里最典型的是菊花和蒲公英。它们在颜色、形状上都接近,模型把大量蒲公英样本错判成菊花,这其实反映了数据本身的视觉相似性。当你看到这种模式,就不该一味责怪模型,反而是回头想想能不能给这两类设计更有效的判别特征,比如增加更细微的纹理增强。

逐类指标方面,我至少会打印每个类别的精确率、召回率和F1值。精确率关注“预测成这个类别的样本里有多少是对的”,召回率关注“这个类别的真实样本里有多少被找回来了”。两个指标各有倾向,单独看都可能有误导性,所以要用F1做调和。下面是我这次实验某次迭代后的结果摘要:

类别精确率召回率F1
菊花0.860.780.82
蒲公英0.750.810.78
玫瑰0.920.890.90
向日葵0.950.970.96
郁金香0.800.840.82

一眼就能看出模型在向日葵上表现最好,在菊花蒲公英这两个相近类别上最吃力。这样的信息对后续改进的方向有直接指导意义。

6.2 用特征图和置信度理解模型行为

数值指标之外,我还会做两种直觉层面的可视化。

第一种是可视化第一层卷积核和中间层特征图。第一层的32个卷积核很小,可视化出来能看到边缘检测器、颜色块检测器之类的简单模式;中间层的特征图则能反映出模型对输入图像的响应位置,比如花瓣区域被激活、背景区域被抑制。我会从验证集挑几张预测正确的图片,把倒数第二层的特征图取出来画成缩略网格,直观感受模型“在看哪里”。

第二种是观察预测置信度。禁用梯度后对某张测试图片做推理,把softmax输出的概率排序,打印前三个类别及其概率。有一个现象很值得留意:模型可能高置信度地预测错误。也就是说,它把某张图以95%的概率判定成了错误的类别。这种“自信地犯错”通常是过拟合或数据分布差异导致的,说明模型的预测校准有问题。

一个可用的初级校准方法是温度缩放,但这不是本篇重点。先认识到这个现象的存在,后续模型做得复杂了自然需要认真处理。

6.3 模型保存与后续使用的两条路

训练完成后,我前面代码里已经做了保存最佳模型的逻辑。加载时注意一个容易踩的坑:直接加载state_dict之前必须先实例化一个结构完全一致的模型对象,否则参数名对不上,加载会报错。推理时记得调用model.eval()进入评估模式,并包在torch.no_grad()里,不然dropout和批归一化的行为会不一致,输出结果也可能忽高忽低。

保存的模型往后有两个使用方向。第一是当成品用:对单张图片做类别预测,把它嵌入你的图像分类应用里。第二是当半成品用:把最后一层分类头摘掉,把全局平均池化后得到的128维向量当作这张图的视觉特征。后面我去做多模态相关实践时,就是沿着第二条路走的:把这个轻量视觉模型作为视觉分支的特征提取器,配合文本分支做简单的图文匹配。这个特征向量还可以继续接到线性投影上,去对齐文本特征的空间。

所以,我这次专门没有依赖任何预训练权重,就是想在自己能完全掌控的模型上把这条链路打通。等我把视觉部分和文本部分结合起来跑通一个最小demo之后,再单独整理一篇出来继续更新这个系列。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进