1. 为什么图像识别绕不开CNN:从2012年说起
先说个背景。2012年之前,图像识别这件事基本靠人手工设计特征。你要告诉程序"注意边缘、注意角点、注意颜色分布",程序才能在这些规则的指引下做分类。当时做一张猫狗分类,你得先写一堆特征提取代码,效果还时好时坏,换个背景就翻车,换个光线又翻车。那会儿做图像识别,真正让人崩溃的不是模型不够好,而是"你压根不知道该让程序去看图像的哪个部位"。
2012年,以AlexNet为代表的深度学习技术在ImageNet图像识别大赛上夺冠,这件事相当于给整个行业扔了一颗炸弹。卷积神经网络(CNN)完全不需要你手工指定特征,它自己从数据里学——你要做的只是把原始像素扔进去,它自己会学会看纹理、看轮廓、看形状,甚至学会一些人类都没意识到的关键特征。从那时起,CNN成了图像识别的事实标准,到今天几乎所有视觉相关应用——人脸检测、自动驾驶、医学影像诊断、电商拍照搜商品——底层都是卷积神经网络。
这篇文章要做的,就是用Python从头搭一个CNN,在CIFAR-10数据集上做图像分类实战。CIFAR-10是计算机视觉领域最经典的入门数据集:10类物体、6万张32×32彩色小图,规模不大不小,恰好适合在个人电脑上完整跑通一遍训练流程。
用这套流程你能掌握三件事:CNN的每一层到底在干什么、PyTorch环境怎么搭怎么落到代码、训练过程中真正会踩的那些坑怎么排。不会只给你一段"能跑但看不懂"的代码,我会把每一步背后的理由也讲清楚。
在往下读之前,先明确你需要的基础:懂一点点Python基本语法,知道列表和函数怎么用就行。不需要懂高等数学公式,不需要懂反向传播的推导细节,这些我会用最直白的方式解释。
2. CNN核心原理:卷积、池化、全连接各司其职
很多人一上来就看代码,结果网络结构里conv2d、pool、fc这些名词堆在一起,根本分不清谁在干什么。我建议先把CNN这套"流水线"想清楚,再看代码就容易得多。
2.1 卷积层:用一组"特征检查器"扫描整张图
卷积层最核心的思路叫"局部感知"和"权值共享",这两个词听着唬人,实际意思非常简单。
"局部感知"是说:图像里某个特征往往只出现在一个小区域内。比如猫耳朵这个特征,看它不需要看整张图,只需要看左上角那一小块就够了。卷积层做的事就是拿一个很小的"窗口"(比如3×3的像素块)在图像上从左到右、从上到下逐步滑动,每次只看窗口里这块内容。
"权值共享"更直白:同一个窗口(在CNN里叫卷积核)整张图共用一套参数。为什么能共用?因为"边缘检测"这个特征无论在图像的哪个位置,它的判断方式是一样的。这个设计把参数量压到了极低——同样一层网络,如果用全连接方式处理一张32×32的图像,每个像素都要一个独立权重,光是第一层就要1024个参数;而卷积核共享下来,一个3×3卷积核只有9个参数。
你可以在脑子里把卷积核想象成一个"特征放大镜":第一个卷积层学会看最基础的纹理和边缘,第二层把边缘组合成局部形状,层数越深,看到的东西越抽象。这也是为什么CNN也叫"层次化特征提取器"——底层的低级特征逐层组合成高层语义特征,整个过程不需要人工干预。
2.2 池化层:在保留关键信息的同时砍掉冗余
卷积层输出的特征图分辨率通常还很大,直接丢给后面的层计算量会爆炸。这时候就需要池化层来"降采样"。
最常见的最大池化(Max Pooling)做法是:拿一个2×2的窗口在特征图上滑动,每个窗口只保留其中最大的那个数值。为什么取最大值而不是平均值?因为卷积层输出的每个位置数值代表"这个位置有没有检测到某个特征",取值越大代表响应越强烈。保留最大值,就是保留"这里特征最明显"的证据,把那些响应微弱的区域直接扔掉。
用生活化的类比:池化就像你拍照时把一张4000万像素的照片压缩成100万像素的缩略图。缩略图会损失细节,但是人眼一看缩略图还是能分辨"照片里是猫还是狗",因为关键结构信息还在。池化层做的事情也一样——去掉对分类任务无关紧要的噪声和位置细节,保留有区分力的响应。另外它还有个隐含好处:让模型对图像的微小位移不那么敏感,稍微平移几个像素,池化结果基本不变,鲁棒性自然就上来了。
2.3 激活函数与全连接层:最后一步是"做决定"
卷积和池化本质上都是线性变换的叠加。如果只有这两类层,无论堆多少层,整个网络表达的还是线性关系,根本学不了复杂分类边界。激活函数就是给网络引入非线性的关键。
现代CNN几乎默认使用ReLU(Rectified Linear Unit),它的定义就一句话:输入小于0时输出0,输入大于0时输出原值。ReLU的好处一是计算极快,二是能有效缓解梯度消失问题(深层网络反向传播时梯度连乘变小,ReLU在正值区间导数恒为1,梯度能顺畅传回去)。你可能在别的教程里看到过Sigmoid和Tanh,但CNN实战里基本都用ReLU系列。
等到图像经过多个卷积+池化+激活的交替加工后,最后会进入全连接层。全连接层这个名字说明它在做的事:把上一层输出的每个位置都与本层每个神经元连接。它不再关注"某个特征在哪",而是把所有提取到的高级特征拼接起来,统一做全局判断。这时候图像已经被压缩成一个向量,向量里每个数值代表某种特征的整体响应强度,全连接层要学的就是"哪些特征组合意味着这是猫、哪些组合意味着这是狗"。
如果把网络比作一个评审委员会:卷积层是基层调研员,各自去图像的不同区域收集证据;池化层是秘书,把收集到的证据去粗取精;激活函数是调研员的表达方式(只在"发现特征"的时候大声汇报);全连接层则是最终拍板的主任——听完全部汇报后给出结论。
2.4 一张表搞懂CNN各层的作用
| 层类型 | 核心参数 | 做了什么 | 输出变化 |
|---|---|---|---|
| 卷积层 | 卷积核大小、输出通道数 | 扫描局部区域,提取特征 | 通道数增加,分辨率略降(取决于padding) |
| 激活函数 | 类型(ReLU等) | 引入非线性 | 数值上对负值截断/变换,尺寸不变 |
| 池化层 | 窗口大小、步长 | 降采样,去冗余 | 分辨率减半,通道数不变 |
| 全连接层 | 神经元数量 | 全局特征组合与分类 | 展平为一维向量 → 各类别得分 |
实际写代码时,你不需要操心卷积的滑动手法,框架全帮你算了,但这个表格背后的逻辑决定了网络结构怎么设计、每个参数怎么选。
3. 环境准备:Python版本、依赖库与硬件选型
这个环节看着简单,但你能在网上搜到"python安装教程""python官网下载""python安装numpy库的方法"这些词,说明环境问题确实劝退了不少新手。这里给你一套亲测稳妥的配置路径。
3.1 Python与关键依赖库的选择
我推荐直接用Python 3.8或3.10版本,这两个版本对绝大多数深度学习库兼容性最友好。不要追求最新的大版本,深度学习生态有时会滞后于Python版本更新,遇到兼容问题排查起来非常痛苦。
需要装的库有这么几个:
- PyTorch:主力的深度学习框架,我用它做实战演示。选PyTorch而不是TensorFlow,是因为它对新手更友好——动态计算图让调试过程直观,报错信息相对容易理解。
- torchvision:PyTorch官方出品的视觉工具库,内置CIFAR-10等经典数据集和常用图像预处理函数。
- numpy:数组运算基础库,PyTorch的张量可以和numpy数组无缝转换,预处理时经常用到。
- opencv-python(cv2):图像读取和基础增强工具。虽然torchvision自带一部分预处理,但想自己加些数据增强策略时,cv2非常方便。
安装方式,Windows用户直接命令行执行常规pip安装即可,Linux和macOS同理。这里我特别提一个容易踩的坑:如果你有NVIDIA显卡,安装PyTorch前先去PyTorch官网选择对应的CUDA版本安装命令,官网会给你带CUDA支持的那一条命令;如果装成了CPU版本,之后想换回GPU版本还得卸载重来一遍,很麻烦。
3.2 CPU跑还是GPU跑:先认清你的硬件
很多人问:"没有GPU是不是就学不了深度学习?"答案是:学不了大数据集的大模型,但本文这个CIFAR-10 + 小型CNN场景,CPU完全能跑得动。
CIFAR-10一张图只有32×32×3像素,我们用的小型CNN参数量约几十万级别,训练10个epoch,在主流CPU上大约十几分钟到半小时。这个时间完全在可接受范围内。所以就算你只有一台普通笔记本,也可以把本文代码完整体验一遍。
不过我还是建议有NVIDIA显卡的话优先用GPU,体验完全不同。显存方面,4GB显存跑本文这个模型绰绰有余;如果你以后想上ResNet这类更深更宽的网络,最好至少8GB显存。
判断PyTorch是否用上了GPU,一句话代码的事:
import torch print(torch.cuda.is_available()) # True表示GPU可用3.3 一份可直接复制的环境版本清单
我实际验证过的组合长这样(截至写这篇文章时):
| 组件 | 推荐版本 | 备注 |
|---|---|---|
| Python | 3.8 或 3.10 | 稳定优先,别追新 |
| PyTorch | 2.x系列 | 1.x也能跑,代码兼容 |
| torchvision | 与PyTorch对应版本 | 两者版本需配套 |
| numpy | 1.24以上 | 2.x也兼容PyTorch 2.x |
| opencv-python | 4.8以上 | 可选,预处理用 |
装完后验证一下版本匹配,如果你的torch和torchvision版本不一致,import时就会报错。最简单的办法是直接装在同一个环境里,用pip安装时它们会自动依赖兼容版本。
4. 完整实战:CIFAR-10图像分类从零开始
核心环节来了。我用PyTorch搭一个小型CNN,在CIFAR-10上完成图像分类。全程代码都给你,直接按顺序跑就能看到效果。
4.1 数据集加载与预处理:让数据进入"适合学习"的状态
CIFAR-10数据集通过torchvision一行就能下载,它会自动分成训练集5万张、测试集1万张。但直接拿原始图训练效果会很差,原因在于数据没有归一化。
torchvision的transforms模块可以串联多个预处理步骤。核心三个操作:转成张量、归一化、数据增强。转成张量会把0~255的像素值压缩到0~1区间;归一化再将每个通道拉成均值为0、标准差为1的分布。别忘了我前面说的,归一化会让模型训练稳定很多——不同通道的分布一致了,梯度下降的方向才不会被迫"来回折返"。
数据增强则是用随机变换人为制造"新样本":随机水平翻转、随机裁剪、轻微旋转,这些都是图像识别里最经典、最有效、成本最低的防过拟合手段。一张猫的照片水平翻转后还是一张猫,但模型就能多学一次"换个角度依然认得出来"的能力。
import torch import torchvision import torchvision.transforms as transforms # 训练集预处理:数据增强 + 归一化 train_transform = transforms.Compose([ transforms.RandomHorizontalFlip(), # 随机水平翻转,防过拟合 transforms.RandomCrop(32, padding=4), # 随机裁剪并填充,扩充样本多样性 transforms.ToTensor(), # 像素值从 [0,255] 缩放到 [0,1] transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)) ]) # 测试集只做张量化和归一化,不做数据增强 test_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)) ]) trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=train_transform) testset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=test_transform) trainloader = torch.utils.data.DataLoader(trainset, batch_size=64, shuffle=True, num_workers=0) testloader = torch.utils.data.DataLoader(testset, batch_size=64, shuffle=False, num_workers=0)这里几个细节值得说明:
RandomCrop(32, padding=4):先把图四周补4像素,再随机裁回32×32,等于让每张图每次训练时看到的画面都略有偏移。Normalize里的均值方差是CIFAR-10数据集的统计值,直接沿用官方推荐值即可,不用自己算。num_workers=0在Windows下最稳,大于0有时会触发多进程报错。macOS和Linux可以适当调大加速数据读取。
4.2 定义CNN网络结构:我们自己设计一个三层特征提取流水线
这里写一个两层卷积加三层全连接的小型网络。它足够处理32×32的CIFAR-10图像,也足够小,在CPU上都能流畅训练。
import torch.nn as nn import torch.nn.functional as F class SimpleCNN(nn.Module): def __init__(self): super(SimpleCNN, self).__init__() # 第一层卷积:3通道(RGB) -> 16通道特征图 self.conv1 = nn.Conv2d(3, 16, kernel_size=3, padding=1) # 第二层卷积:16通道 -> 32通道特征图 self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1) # 池化层(共用):2x2窗口,步长2,分辨率减半 self.pool = nn.MaxPool2d(2, 2) # 全连接层:把特征图展平后映射到10个类别 self.fc1 = nn.Linear(32 * 8 * 8, 128) self.fc2 = nn.Linear(128, 10) def forward(self, x): # 第一阶段:卷积 -> 激活 -> 池化(32x32 -> 16x16) x = self.pool(F.relu(self.conv1(x))) # 第二阶段:卷积 -> 激活 -> 池化(16x16 -> 8x8) x = self.pool(F.relu(self.conv2(x))) # 展平:把 32x8x8 的特征图拉成一维向量 x = x.view(-1, 32 * 8 * 8) # 全连接层,最后直接输出10个类别的得分 x = F.relu(self.fc1(x)) x = self.fc2(x) return x网络结构的设计逻辑我拆开讲:
第一层卷积输出16个通道,意味着16个不同的"特征放大镜"扫描原图,每个放大镜关注一种低级特征(某种方向的边缘、某种颜色的分布)。池化后分辨率从32×32降到16×16。第二层卷积在16×16的特征图上继续提取,32个放大镜组合出更高级的特征(纹理块、局部形状),再池化到8×8。
为什么最后全连接层的输入维度是32 * 8 * 8?因为输入图32×32,经过两次2×2池化后分辨率变为8×8,而第二层卷积输出的通道数是32。每个通道上8×8个位置,总计32×8×8=2048个数值,展平后喂给全连接层。
你可能注意到我没加Dropout。这里我先不加,后面讲防过拟合时单独说它怎么加、加在哪。
4.3 训练循环:损失、优化器、反向传播的完整套路
训练代码是深度学习里最固定的流程:前向传播算损失,反向传播算梯度,优化器更新参数。这个套路在几乎任何任务里都一样,值得背下来。
import torch.optim as optim # 检查是否有GPU可用 device = torch.device("cuda" if torch.cuda.is_available() else "cpu") net = SimpleCNN().to(device) # 移动到GPU/CPU # 交叉熵损失:分类任务标准选择 criterion = nn.CrossEntropyLoss() # Adam优化器:自适应学习率,新手首选 optimizer = optim.Adam(net.parameters(), lr=0.001) # 训练5个epoch(数据完整过5遍) for epoch in range(5): running_loss = 0.0 for i, (inputs, labels) in enumerate(trainloader): inputs, labels = inputs.to(device), labels.to(device) # 梯度清零——PyTorch默认会累积梯度,不清零会导致重复叠加 optimizer.zero_grad() # 前向传播 outputs = net(inputs) loss = criterion(outputs, labels) # 反向传播 + 更新参数 loss.backward() optimizer.step() # 每2000个batch打印一次当前损失 running_loss += loss.item() if i % 2000 == 1999: print(f'[Epoch {epoch+1}, Batch {i+1}] loss: {running_loss / 2000:.3f}') running_loss = 0.0 print('训练完成')这里的细节我要重点解释,它们直接影响能不能收敛:
optimizer.zero_grad()这一行最容易被忽略。PyTorch默认不清空上一次反传的梯度,如果不手动清零,梯度会在多次迭代中不断累加,导致参数更新方向完全错乱。我见过太多新手光顾着写backward和step,忘了清零,结果loss掉不下去,还以为是网络设计有问题。
net.to(device)是把网络参数搬到GPU显存(或留CPU)。这里有个配套坑:如果网络在GPU上,输入数据也必须inputs.to(device),否则PyTorch报类型不匹配错误。而且每次从DataLoader取数据都要转一次,不能只在循环外面转,因为数据是每轮实时获取的。
4.4 模型评估:用测试集检验真实泛化能力
训练完还用训练集看准确率没有意义——模型可能在训练数据上表现完美,但遇到没见过的图就抓瞎。所以必须用测试集数据来评估。
# 评估模式:关闭dropout和batch normalization的随机行为 net.eval() correct = 0 total = 0 with torch.no_grad(): # 不计算梯度,省显存且加快速度 for images, labels in testloader: images, labels = images.to(device), labels.to(device) outputs = net(images) # 取每个样本得分最高的类别作为预测结果 _, predicted = torch.max(outputs.data, 1) total += labels.size(0) correct += (predicted == labels).sum().item() print(f'测试集准确率: {100 * correct / total:.2f}%')两个关键点:
net.eval()必须调用。如果你的网络里以后加了Dropout或BatchNorm层,这个模式会停掉它们的随机行为,保证评估结果稳定可复现;如果忘了切回train(),后续想继续训练会发现效果和之前不一样。torch.no_grad()包裹推理代码,告诉PyTorch这里不需要保存梯度信息,显存占用大幅降低,速度更快。
在CPU上跑5个epoch,准确率一般能到65%~70%左右。这个数字对小型CNN加CIFAR-10来说已经正常——CIFAR-10本身是个10分类任务,随机猜只有10%准确率,65%意味着模型已经学到大量有效特征。
5. 踩坑全记录:训练CNN最容易翻车的几个环节
这部分是我最想写的。理论看十遍不如亲手踩一遍坑,这里有五个我把代码从"勉强能跑"调到"稳定收敛"过程中遇到的典型问题,每个都对应一个容易忽略的细节。
5.1 数据不归一化:梯度下降在"峡谷"里挣扎
第一个实战版本我没做Normalize,直接拿0~1范围内的张量训练。结果loss从2.3降到2.0之后就不再动了,准确率一直卡在30%~40%附近。原因很简单:虽然ToTensor把像素压到了0~1,但不同颜色通道的均值方差差异较大,损失函数的等高线呈狭长椭圆状,梯度下降只能走"之"字形路径,效率极低。
把Normalize加回去之后,loss一路从2.3降到1.2附近,准确率轻松突破60%。这个前后对比太直观了。以后做任何图像任务,归一化这一步都不要省略。
补充一个容易被忽略的细节:训练时做数据增强(RandomCrop、RandomHorizontalFlip)有一个逻辑前提——测试时必须保持预处理一致(归一化),但不做数据增强。如果测试集也做随机翻转,每次评估结果都会变,指标不具可比性。
5.2 学习率踩雷:过大发散、过小蜗牛爬
学习率可能是CNN训练里最敏感的超参数。我第一次用lr=0.01配合Adam,loss下降到1.5附近后开始剧烈震荡,准确率忽高忽低。降到0.001后立刻稳定了。
经验法则:Adam优化器下,图像分类任务的学习率可以先从0.001起步。如果loss下降平稳,想进一步加速可以尝试0.002或0.003;如果出现震荡,果断调低到0.0005。
还有一个实用技巧:可以配合学习率衰减策略,比如每3个epoch把学习率乘以0.5,让模型后期做精细调整。PyTorch里用torch.optim.lr_scheduler.StepLR就能实现,几行代码的事。
5.3 过拟合:训练集欢天喜地、测试集原地踏步
如果你发现训练集准确率一路飙升到90%以上,但测试集只有60%,这就是典型的过拟合——模型把训练数据的"个性"当成"共性"来记了,遇到没见过的样本就懵。
解决过拟合有三个最常用的手段,按性价比排序:数据增强 > Dropout > 减小模型体积。数据增强我们已经在预处理里加了随机翻转和随机裁剪,效果明显。Dropout的操作则是在全连接层之间随机"丢弃"一部分神经元的输出,迫使网络学会冗余表达——部分信息被遮住时依然能做对判断。
Dropout加到网络里的方式:
# 在全连接层之间加Dropout self.dropout = nn.Dropout(0.5) # forward里: x = F.relu(self.fc1(x)) x = self.dropout(x) # 训练时随机置零一半神经元 x = self.fc2(x)加Dropout后,训练时准确率会略微下降,这是正常的;测试时net.eval()会自动关闭Dropout,模型在全量神经元下做推断,往往能换来测试集2~4个百分点的提升。
5.4 设备迁移与OOM:显存不够的排查顺序
批量大小(batch size)设太大,GPU显存容易爆,报错通常是"CUDA out of memory"。这时候不要慌,按顺序排查:
先确认batch_size是不是开得太大,试着从64降到32或16。再做一步:在训练循环里,算完loss、做完backward后,及时调del inputs, labels, outputs解除变量引用,帮助释放显存。如果还爆,检查是不是进程没释放——PyTorch的缓存有时会占着显存不还给系统,可以用torch.cuda.empty_cache()手动清一下。
还有个低级但常见的坑:训练结束后再加载测试集,如果代码里同时持有训练集和测试集的引用,显存会被数据集缓存占掉一块。用完的数据能删就删。
5.5 Light推理模式:为什么测试时结果和训练时不一样
模型训练完,直接套用net(x)做推理和先调用net.eval()再推理,结果可能不同。原因前面提过:Dropout层和BatchNorm层在训练和推理两种模式下行为不同。这不是bug,是框架的正常设计。如果你发现第二次跑同一张图结果变了,先检查是不是忘了调用net.eval()。
6. 从跑通到提升:骨干网络选择与下一步优化方向
如果你已经完整跑通了上面的代码,测试集准确率达到了65%~70%,恭喜你——你已经掌握了CNN图像识别实战的完整闭环。这个阶段之后通常会有个疑问:怎么把准确率推到80%、90%以上?这里说几条值得走的路。
6.1 用残差结构解决深度网络的退化问题
单纯堆更多卷积层不一定有效。网络越深,梯度在反向传播过程中逐层连乘,很容易消失,导致浅层参数学不到东西,训练集准确率反而下降——这叫退化问题,不是过拟合。
ResNet的核心贡献是引入了"残差连接":把输入直接跳跃加到输出上,让每一层学习的目标从"完整映射"变成"和输入相比的差值"。这个改动让50层甚至上百层的网络也能稳定训练。如果你想从"会跑小型CNN"进阶到"训练出真正高精度的模型",残差结构是绕不开的核心概念。
6.2 迁移学习:站在预训练模型的肩膀上
在CIFAR-10这种小数据集上从头训练,70%差不多是手工设计网络的极限。想要更高的准确率,最实用的办法是迁移学习:加载在ImageNet上预训练好的模型(ResNet18、ResNet50、EfficientNet等),把最后的全连接层换成新的10分类层,然后只微调这一层,或者把整个网络的学习率调小后整体微调。
为什么迁移学习效果这么强?ImageNet数据集有上千万张图像,预训练模型已经学会了一套通用的"视觉语法"——边缘、纹理、形状、物体部分之间的组合关系。这些能力迁移到CIFAR-10乃至你的自定义数据集上,相当于直接请了一位经验丰富的工程师进场,剩下的只需要几天时间适应你的具体任务。
在PyTorch里使用迁移学习比自己写ResNet还要简单,几行代码就能搞定,但要注意两点:一是预训练模型的输入预处理要求和普通数据不同,比如用ImageNet均值和方差归一化;二是冻结参数时,需要确保优化器只更新全连接层那部分参数,否则1024维之外的大多数层只会白算。
6.3 数据、模型、算力:当你调不动准确率时的检查思路
准确率上不去了,照着这个思路排查:
| 检查维度 | 常见问题 | 验证方法 |
|---|---|---|
| 数据 | 标签错误、样本不均衡 | 随机抽样看图像与标签是否匹配 |
| 预处理 | 归一化参数用错 | 打印训练集张量均值,应接近0 |
| 模型 | 结构太浅、通道数太少 | 训练集上是否也不达标,如果是,模型容量不够 |
| 超参 | 学习率不合适、batch过大 | 做一组小规模消融实验,逐个变量调 |
| 过拟合 | 训练集远超测试集 | 加增强策略省事,其次调大dropout |
记住一个原则:先确认训练集准确率是否足够高。如果训练集都学不进去,问题大概率出在模型容量、学习率、数据质量上;如果训练集学到90%以上而测试集不行,问题才转向过拟合。
7. 我的个人实战体会与一条小建议
把这次实战完整走下来,我最大的体会是:CNN的入门门槛比想象中低,但要真正用顺手,关键在于理解每一行代码背后的"为什么"——为什么预处理要做归一化,为什么lp内要先zero_grad,为什么评估时要切eval()。这些细节单独看都不起眼,合在一起却决定了你的模型是稳定收敛还是反复翻车。
最后分享一个我最近一直在用的调试技巧:不要一上来就跑完整训练。先用batch_size=4跑2个step,确认前向、反向、参数更新全流程不报错,再跑完整数据集。这能在几分钟内暴露大部分代码错误,而不是等训练半小时后才发现问题。另外,找一套公开数据集上的预训练权重作参照,你训练时打印的loss和准确率曲线可以拿它对标,偏差过大时就知道某个环节出了问题。
图像识别这条路,从跑通小型CNN到把部署到实际项目里,中间还有很多值得琢磨的东西:模型剪枝、量化、ONNX导出、服务化部署。但基础打牢了,后面这些都不难。就先从你自己的第一组猫狗分类开始吧。