
从 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_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.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×480Conv2 BN ReLU64×48×48约1.9万MaxPool64×24×240Conv3 BN ReLU128×24×24约7.4万MaxPool128×12×120GlobalAvgPool1280分类头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(brightness0.2, contrast0.2, saturation0.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, transformtrain_transforms) val_set datasets.ImageFolder(data/val, transformval_transforms) train_loader DataLoader(train_set, batch_size64, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_set, batch_size64, shuffleFalse, num_workers4, pin_memoryTrue) model SimpleCNN(num_classes10) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) 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(fEpoch {epoch1}/30 | train_loss {train_loss:.4f} | ftrain_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之后再单独整理一篇出来继续更新这个系列。