1. 实战目标与整体思路
1.1 为什么图像分类是绕不开的基石
多模态大模型学习笔记写到这里,前几篇一直在聊注意力机制、Transformer架构、多模态对齐这些偏“顶层设计”的概念。但说句实在话,如果基础视觉能力不过关,后面那些跨模态融合、图文检索、视觉问答全都像空中楼阁。卷积神经网络(CNN)作为视觉领域最经典的骨架,哪怕在如今Vision Transformer遍地走的年代,它依然是理解特征提取、感受野、空间层次这些核心概念的最佳教材。这次实战就是要把CNN从公式推到落地,用一套完整的图像分类流程把之前学的抽象理论全部串起来。
图像分类看似是最简单的视觉任务,但它背后涉及数据预处理、模型设计、训练调参、评估迭代的完整闭环。把这个闭环跑通了,后面再做目标检测、语义分割、图像生成,甚至是多模态大模型里的视觉编码器,你会发现很多套路都是相通的。所以这篇笔记不是单纯教你怎么调一个模型,而是帮你建立“从数据到模型再到评估”的工程直觉。
1.2 这次实战我们要做什么
这次实战的目标非常明确:用PyTorch从零搭建一个卷积神经网络,在CIFAR-10数据集上完成图像分类。CIFAR-10是计算机视觉领域最经典的入门数据集之一,包含10个类别的60000张32×32彩色图像,每个类别6000张。选择它有几个原因:数据量适中,单张图片分辨率不高,普通笔记本电脑的CPU或者入门级显卡就能跑起来,训练一轮用不了太久;同时它又保留了真实图像分类的难度——物体有遮挡、背景复杂、类内差异大,足够暴露各种训练细节问题。
具体来说,我们要实现这几件事:加载并预处理数据、构建一个包含卷积层、池化层、全连接层的CNN模型、编写训练循环、在测试集上评估准确率、可视化训练过程中的损失曲线和准确率曲线。最后还会加上数据增强和模型保存加载的环节,让整个流程更接近真实项目的使用方式。
1.3 工具与数据集选型
框架我选了PyTorch,原因很直接:动态图机制调试方便,社区生态成熟,torchvision库内置了CIFAR-10数据集和很多预训练模型,后续如果要迁移到多模态任务也顺滑。Python版本建议3.8以上,PyTorch 2.x即可。如果你用的是GPU,需要先装好对应版本的CUDA;没有GPU也没关系,把batch size调小一点,用CPU训练CIFAR-10也能在十分钟内完成一个简单的模型训练。
额外说一句,不要一上来就去Kaggle下载那种几百GB的大数据集。训练一个分类模型,最重要的是先把流程走通,理解每个环节在干什么。CIFAR-10这种中等规模的数据集,既能让你感受到数据加载、归一化、批次迭代的完整过程,又不会因为训练时间过长而消磨耐心。等这套流程熟练了,再迁移到ImageNet子集或者自己业务里的数据,只是改改路径和类别的体力活。
2. 卷积神经网络核心部件拆解
2.1 卷积层:用一个小窗口扫过全图
卷积层是CNN的灵魂。它的核心操作可以理解成一个带着“放大镜”的小窗口在图像上滑动,每滑动到一个位置,就把窗口覆盖的像素值和窗口内的权重做点积,得到一个数值。这个小窗口叫做卷积核或者滤波器,它的大小一般是3×3或者5×5。不同的卷积核会关注不同的特征,比如有的卷积核专门检测水平边缘,有的检测垂直边缘,有的检测颜色块。
这里的关键参数有三个:卷积核大小(kernel_size)、步长(stride)和填充(padding)。步长决定窗口每次滑动多远,填充决定是否在图像边缘补零。补零的最大好处是能保持特征图尺寸不缩水,尤其当网络层数加深时,如果不做填充,特征图会越变越小,边缘信息丢失得很厉害。第一次写模型的人最容易在这里翻车,卷积层参数算不清楚导致维度对不上,往后传的时候直接报错。
从本质上看,卷积层就是在做特征提取。浅层卷积核捕捉的是纹理、边缘这类低级特征,深层卷积核叠加后就能捕捉到眼睛、轮子、翅膀这类高级语义特征。这也是CNN和全连接网络最大的区别:全连接网络把每个像素当作独立的输入,完全忽略了图像的空间结构;而卷积操作保留了像素之间的局部空间关系,参数还少得多。
2.2 池化层:压缩信息,保留关键特征
池化层的作用说白了就是“降采样”。最常见的最大池化(Max Pooling)是取一个小窗口内的最大值作为输出,比如2×2的窗口步长2,能把特征图的宽高各缩小一半。为什么取最大值?因为激活值大代表这个区域响应强,可能对应着有辨识度的特征,取最大值相当于保留了最强烈的信号。
池化层带来的好处很直观:第一,大幅减少了计算量和参数量,让网络可以更深;第二,提供了平移不变性,物体稍微挪动几个像素,池化后的结果变化不大,这正好契合图像分类“不管物体在哪,类别都一样”的需求。不过也要注意,池化不是万能的,过度池化会让特征图太小,丢失细节。在比较新的网络设计里,有些模型会用步长为2的卷积代替池化,效果类似但可学习的参数更多。对于实战入门来说,还是先用最经典的MaxPool2d,逻辑清晰且不容易出错。
2.3 全连接与激活函数:从特征到类别
卷积层和池化层交替堆叠之后,我们得到的是若干个特征图,这时候要把它们“压平”成一维向量,然后接全连接层。全连接层的每个神经元都和上一层的所有神经元相连,它的作用是把前面提取到的分布式特征整合起来,映射到样本标记空间。以CIFAR-10为例,最后一层全连接的输出维度就是10,对应10个类别。
激活函数决定了神经网络的非线性表达能力。没有激活函数的话,不管网络多深,本质上都是线性变换的堆叠,连XOR这种简单问题都解决不了。实战里常用的激活函数有ReLU、LeakyReLU、Sigmoid、Tanh。ReLU计算简单、收敛快,是CNN里最常用的选择。它最大的问题是神经元“坏死”:如果某个神经元输出恒为负,梯度就一直是0,这个神经元再也不会更新了。LeakyReLU在负数区间保留了一个很小的斜率,就是为了缓解这个问题。新手实战直接用ReLU就好,配合合理的学习率,绝大多数情况下都不会出问题。
2.4 损失函数与优化器:定义“学得好”的标准
损失函数是模型的“裁判”。图像分类最常用的损失函数是交叉熵损失(CrossEntropyLoss)。它衡量的是模型预测的概率分布和真实标签的概率分布之间的差异。在PyTorch里,CrossEntropyLoss会自动把模型输出的原始分数(logits)做Softmax归一化,然后再计算交叉熵,所以不需要手动加Softmax层。
优化器则是“教练”,负责根据损失函数计算出的梯度来更新模型权重。最基础的优化器是随机梯度下降(SGD),但直接使用SGD收敛很慢且容易震荡。现在实战中用得最多的还是Adam,它结合了动量和自适应学习率的优点,基本不需要手动调整太多参数,默认学习率0.001就能跑出不错的结果。不过Adam在有些任务上最终收敛效果不一定比精心调过学习率策略的SGD好,这属于后话。对于第一个实战项目,我的建议是直接上Adam,把精力放在理解训练流程上。
3. 图像分类实操全流程
3.1 环境准备与数据加载
首先确保你已经安装好了PyTorch和torchvision。安装方式这里不展开,用官方推荐的方式即可。数据加载直接用torchvision.datasets.CIFAR10,自动下载到指定目录。这里有一个常见的坑:数据下载需要联网,如果网络不稳定可能失败,建议事先确认网络畅通。
import torch import torch.nn as nn import torch.optim as optim import torchvision import torchvision.transforms as transforms # 定义数据预处理 transform = transforms.Compose([ transforms.ToTensor(), # 将PIL图像转为Tensor,并把像素值从[0,255]缩放到[0,1] transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)) # 标准化到[-1,1] ]) # 加载训练集和测试集 trainset = torchvision.datasets.CIFAR10( root='./data', train=True, download=True, transform=transform) testset = torchvision.datasets.CIFAR10( root='./data', train=False, download=True, transform=transform) trainloader = torch.utils.data.DataLoader( trainset, batch_size=64, shuffle=True, num_workers=2) testloader = torch.utils.data.DataLoader( testset, batch_size=64, shuffle=False, num_workers=2) classes = ('plane', 'car', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck')这里有两个细节值得解释。Normalize的参数(0.5,0.5,0.5)和(0.5,0.5,0.5)是针对RGB三个通道的均值和标准差,因为我们已经先把像素缩放到[0,1],再用(像素-0.5)/0.5就能映射到[-1,1]。标准化是必要的,它能让数据分布更加稳定,加快模型收敛。另一个细节是DataLoader的num_workers参数,它控制用几个子进程加载数据。在Windows系统上设置大于0偶尔会遇到多进程问题,实在不行就设成0,不过训练速度会略慢。
3.2 构建CNN模型
下面是一个经典结构:三个“卷积+池化”模块,再接两个全连接层。这个结构虽然简单,但对CIFAR-10来说足够取得80%以上的准确率。
import torch.nn.functional as F class SimpleCNN(nn.Module): def __init__(self): super(SimpleCNN, self).__init__() self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.conv3 = nn.Conv2d(64, 128, kernel_size=3, padding=1) self.pool = nn.MaxPool2d(2, 2) self.fc1 = nn.Linear(128 * 4 * 4, 256) self.fc2 = nn.Linear(256, 10) def forward(self, x): x = self.pool(F.relu(self.conv1(x))) x = self.pool(F.relu(self.conv2(x))) x = self.pool(F.relu(self.conv3(x))) x = x.view(-1, 128 * 4 * 4) x = F.relu(self.fc1(x)) x = self.fc2(x) return x这个模型的关键在于理解特征图尺寸的变化。输入是3×32×32,经过第一个卷积(padding=1)后仍是32×32,然后池化变成16×16;第二个卷积后池化变成8×8;第三个卷积后池化变成4×4。此时通道数为128,所以压平后就是128×4×4。如果在自己的数据上改模型,一定要手动算一下这个尺寸,或者临时打印一下中间变量的shape,否则全连接层的输入维度很容易对不上。
还有一个容易被忽略的点:卷积层默认的偏置是存在的,但BatchNorm层会重置偏置,所以网上有些代码会在卷积层里写bias=False。我这个模型没用BatchNorm,因此保持默认即可。如果你加了BatchNorm,建议把bias设为False,省一点参数且避免冗余。
3.3 训练循环与超参数设置
训练循环是整个实战的发动机。核心逻辑很简单:取一个批次的数据,把图像输入模型得到预测分数,计算损失,反向传播,更新参数。但真正写的时候有很多细节会影响结果,比如每轮训练结束要记得把梯度清零,比如要在model.train()和model.eval()之间切换。
model = SimpleCNN() criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) num_epochs = 10 for epoch in range(num_epochs): running_loss = 0.0 for i, data in enumerate(trainloader, 0): inputs, labels = data optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() if (i + 1) % 200 == 0: print(f'Epoch {epoch+1}, Batch {i+1}, Loss: {running_loss / 200:.3f}') running_loss = 0.0 print('训练完成')几个关键决策的解释:批量大小64,学习率0.001,训练轮数10。批量大小影响梯度估计的稳定性和内存占用,64在CIFAR-10上是一个比较平衡的选择。学习率0.001是Adam的默认值,对于这个小模型来说很稳妥。训练轮数10在CPU上可能需要十几分钟,在GPU上几分钟就够了。如果时间紧张,可以先用3轮跑通流程,再跑完整10轮。
我实际跑下来的损失曲线趋势是:前几轮损失快速下降,从2.0降到1.0左右,后面下降速度放缓,这是正常现象。如果损失值降得很慢,或者甚至反弹,大概率是学习率设置有问题,后面会专门说。
3.4 模型评估与可视化
训练完成后,必须在测试集上评估模型的泛化能力。注意测试时要先调用model.eval(),把Dropout和BatchNorm切换到推理模式,再用torch.no_grad()关闭梯度计算,这样既省内存又能保证结果一致。
model.eval() correct = 0 total = 0 with torch.no_grad(): for data in testloader: images, labels = data outputs = model(images) _, predicted = torch.max(outputs.data, 1) total += labels.size(0) correct += (predicted == labels).sum().item() print(f'测试集准确率: {100 * correct / total:.2f}%')这个简单的CNN在CIFAR-10上跑10轮,准确率大概在70%到75%之间。听起来不高,但你要知道CIFAR-10本身是个很有挑战的数据集,图片分辨率只有32×32,很多物体本身就极难辨认。如果使用数据增强和更深的结构,比如ResNet18,准确率能提升到90%以上。但我们的目的不是刷分,而是跑通流程。
可视化部分,可以用matplotlib把训练损失画成曲线。很简单,在训练循环里把每个batch的loss存到列表里,最后画图。还可以随机抽一些测试图像,显示模型预测的标签和真实标签,错的地方标红。这一步能直观感受到模型在哪类图片上容易犯错。
3.5 数据增强与模型保存
数据增强是图像分类里提高泛化能力最有效的手段之一。简单来说,就是对训练图片做随机的平移、旋转、裁剪、翻转、颜色抖动等操作,让模型看到更多样化的输入。CIFAR-10主要需要水平翻转和随机裁剪。
transform_train = transforms.Compose([ transforms.RandomHorizontalFlip(), transforms.RandomCrop(32, padding=4), transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)) ])RandomCrop的padding=4表示先在32×32图片周围补4个像素的零,再随机裁剪回32×32,相当于做了一个小幅度的平移。加上水平翻转,能有效提升模型对物体朝向和位置变化的鲁棒性。用了数据增强之后,相同模型10轮训练准确率能提升3到5个百分点,性价比极高。
训练完成后保存模型,推荐只保存状态字典而不保存整个模型对象,这样加载时更灵活。
torch.save(model.state_dict(), './cnn_cifar10.pth') # 加载模型 model = SimpleCNN() model.load_state_dict(torch.load('./cnn_cifar10.pth')) model.eval()4. 常见问题与排查技巧
4.1 过拟合怎么破
过拟合是图像分类实战里最容易遇到的现象:训练集损失不断下降,准确率接近100%,但测试集准确率反而停滞甚至下降。主要原因就是模型把训练数据里的噪声和细节都背下来了,而不是学习到泛化规律。
破解方法按照优先级排列:第一是数据增强,这是最温和又有效的正则化手段;第二是Dropout,在全连接层之间随机丢弃一部分神经元,迫使网络不要把赌注压在单个特征上;第三是降低模型复杂度,比如减少卷积层的通道数或者去掉一层卷积;第四是早停,在测试集准确率不再提升时终止训练。实际项目里往往是几个手段组合使用,不要指望一招鲜。
我自己的经验是,如果你发现测试准确率比训练准确率低了15个百分点以上,先检查数据增强有没有加,再检查模型是不是太深。CIFAR-10这种规模的数据集,用我上面那个三卷积结构加上数据增强,过拟合现象会轻很多。
4.2 学习率与优化器踩坑记录
学习率可以说是训练过程中最敏感的超参数。学习率太大会导致损失震荡甚至爆炸,太大会导致训练极其缓慢。用Adam时,0.001是一个不错的默认值,但如果你的数据分布和CIFAR-10差异很大,可能需要尝试0.0001到0.01之间的几个值。
我在实战中遇到过这样一个情况:第一次训练忘记对输入做归一化,像素值在0到255之间,结果Adam怎么调学习率损失都降不下去。后来才意识到这是数值尺度问题,归一化之后训练瞬间顺畅。如果你发现损失卡在某个位置不动,首先检查归一化,然后用一个小实验:把学习率先设成0.0001,如果损失在个位数轮次内明显下降,再考虑调大。
还有一个容易被忽视的点:优化器参数的weight_decay。默认是0,也就是没有L2正则化。加上一个0.0001到0.001的weight_decay能有效抑制过拟合,在很多CNN任务里是必要的。我的建议是训练时加上weight_decay=0.0001,尤其是用SGD的时候。
4.3 显存不足与其他环境问题
GPU显存不足是所有人都会遇到的报错。解决办法要么降低batch size,要么降低图像分辨率,要么减少模型通道数。对于CIFAR-10来说,batch size从64降到32基本就能解决大多数入门显卡的显存问题。如果是在CPU上跑,还要注意DataLoader的num_workers不要设太高,否则数据加载反而可能成为瓶颈。
另一个常见问题是训练过程中内存越占越多。这通常是因为在循环里创建了新的计算图,比如把loss.item()误写成loss,导致梯度图一直被保留。正确做法是每次都提取数值量。如果你发现内存开销无限增长,检查一下所有累积的变量是不是都用.item()转成了普通数字。
4.4 结果不理想时的排查顺序
模型训练完之后,准确率如果比随机猜测(10%)高不了多少,先别急着改模型。我建议按这个顺序排查:先打印几个batch看数据是否正确加载和归一化;检查模型输出的shape是否正确;确认损失值在初始时是否合理(CIFAR-10交叉熵初始大约为2.3,因为log(10)≈2.3);然后检查梯度是否正常,如果梯度为NaN,大概率是输入数据有问题。
如果初始损失正常但训练不降,那就是学习率或者模型结构的问题。如果训练下降了但测试集不升,那就是过拟合。把这些问题一个个排除,你会发现大部分情况不是模型想象力不够,而是某个基础环节出了纰漏。
5. 从CNN到多模态:后续方向与个人体会
5.1 图像分类结果如何迁移到多模态任务
跑完这个实战,你已经掌握了CNN处理图像的基本流程。在多模态大模型里,视觉部分通常就是先用一个预训练的CNN或者ViT把图像编码成特征向量,然后再和文本特征做对齐。所以你现在训练这个分类模型的encoder部分,其实就是多模态模型的前半段骨架。
不妨做一个小扩展:把我们训练好的CNN去掉最后的全连接层,把前面卷积部分输出当作图像特征提取器,用它给每一张图片生成一个特征向量。然后再把这些特征向量和对应的文本描述向量做相似度计算,就能做一个最简版的图文匹配任务。虽然效果比较粗糙,但能帮你理解视觉编码器是怎么为多模态对齐服务的。这也是笔记标题里“多模态大模型学习笔记”这个系列把它们放在一起的原因:视觉基础没打牢,后面那些看似高大上的跨模态模型都是空中楼阁。
5.2 自己的几点实操心得
这个实战做完,我最大的感受是:动手写代码和只看理论完全是两回事。看卷积、池化的公式觉得很清晰,但真当维度对不上、损失不下降、显存爆掉的时候,才会逼着自己去理解数据的shape变化、梯度的传播路径、归一化的作用这些抽象概念。
第二个体会有两点值得分享。一是不要迷信网络结构,先把基础流程跑通比什么都重要。我之前见过不少新手一上来就复现ResNet、DenseNet,结果连数据加载都调不通。二是一定要养成记录实验的习惯,每次改了哪个超参数、结果如何,都随手记下来。我一开始觉得麻烦,后来发现它在排查问题时的价值无可替代。
最后说个小技巧:训练完成后,别忘了用测试集里的图像做一次“人工质检”,就是随机打印一小批预测结果,看看模型错在哪。有时候准确率看着还行,但错的东西非常有规律,比如把蓝色背景的天空归为飞机,把深色汽车归为卡车。这时候再去调整数据分布或者增加特定类别样本,比盲目堆模型结构有效得多。
希望这篇笔记对你有实际帮助。下一篇准备把CNN替换成预训练视觉Transformer,对比两者在图像分类上的表现,那时候会更接近现在多模态模型里视觉编码器的真实形态。咱们下篇见。