☰
CNN入门实战:卷积核、数据管道与不收敛排查
2026/10/27 13:20:57 网站建设 项目流程

很多人第一次接触 CNN,卡住的地方往往不是反向传播的链式求导,而是——模型搭好了,loss打印出来是一个几乎不动的数字,准确率在 10% 上下反复横跳,然后开始怀疑人生。这篇 CNN 入门级实战教程不走“先推公式再上代码”的老路,而是按我实际带人入门时验证过的顺序来:先把卷积核在图上到底干了什么看清楚,再把数据管道搭对,然后手搓一个能跑通的小网络,最后把训练不收敛的排查链路走一遍。卷积神经网络这个东西,说到底就是一套“用局部窗口提取特征、用层级堆叠换抽象能力”的工程手段,它没有想象中那么玄。适合谁看?写过一点 Python、用过数组运算、但对深度学习只有模糊概念的读者,也适合那些跑通过别人的 demo 却说不清每一层在干什么的人。下面所有内容都是可以直接抄进编辑器运行的,参数取值我也会解释为什么这么给,而不是甩一串数字让你背。

1. 卷积核到底在图上的哪块位置做了什么

1.1 一张彩色图片进入网络之前长什么样

在写任何一行nn.Conv2d之前,先搞清楚进网络的东西是什么形状。一张 224×224 的彩色图,在内存里是一个3 × 224 × 224的张量,三个通道分别是红绿蓝。灰度图就是1 × 224 × 224。如果你从磁盘读进来的是224 × 224 × 3,那在做卷积之前必须把通道维换到前面去,这个顺序搞反是新手最常见的报错来源之一。

为什么通道维要放前面?因为在卷积运算里,卷积核的深度必须和输入的通道数对齐。一个 3×3 的卷积核,作用在彩色图上实际是3 × 3 × 3的立方体,它在空间上滑一个 3×3 的窗口,同时在深度上一次性覆盖三个通道,把它们加权求和成一个数。所以卷积核从来不是“二维的”,它的真实形状是(输出通道数, 输入通道数, 高, 宽)。

这里有个很直观的类比:把图片想成一张三层叠起来的透明描图纸,红绿蓝各画一层。卷积核是一块 3×3 的方形放大镜,你把它同时压在三层纸上往下看,看到的是三层叠加之后的图案,而不是一层一层分开看。这就是为什么第一层卷积的输入通道数必须等于 3,写成 1 就会直接报维度不匹配。

张量维度错误几乎占了新手报错的一半以上。我的建议是养成习惯:在forward的第一行打印x.shape,跑通一次之后删掉。这个动作看起来很笨,但它能让你在两分钟内定位到底是在数据加载阶段维度就错了,还是网络中间某一层的输出尺寸被算错了。

1.2 卷积、池化、全连接各自解决什么问题

这三种层不是凑数用的,每一种都在解决一个具体的工程问题,理解了这个分工,网络结构设计就不再是抄别人的层堆叠。

卷积层解决的是局部特征提取 + 参数共享。假设你用全连接层处理一张 224×224×3 的图,展平之后是 150528 维,接一个 1024 维的隐藏层,参数量就是 1.5 亿。而一个 64 通道的 3×3 卷积层,参数量只有64 × 3 × 3 × 3 + 64 = 1792。差了五个数量级。参数共享的意思是:同一个卷积核在整张图上滑动,不管它扫到左上角还是右下角,用的都是同一组权重。这个假设叫“平移不变性”,对于图像这类内容在哪个位置出现都不改变语义的数据来说,是极强的先验。

池化层解决的是降维与局部鲁棒性。2×2 的最大池化把每个 2×2 小块的四个数取最大,输出一个数,尺寸直接减半,计算量降到四分之一。取最大值而不是平均值,是因为我们关心的往往是“这个区域里有没有出现这个特征”,只要出现了,强度多少不那么重要。代价是空间分辨率下降,所以池化的次数不能太多。

全连接层解决的是特征到类别的映射。前面的卷积堆叠把图像变成了一组抽象特征图,最后需要把这些特征组合成分类得分。通常的做法是先做全局平均池化,把C × H × W压成C × 1 × 1,再接一层Linear(C, num_classes)。早年流行的做法是直接展平再堆两三层全连接,参数量巨大且容易过拟合,现在除了特别小的网络,基本都用全局平均池化收尾。

下面这张表是我给入门者列的分工速查,覆盖了实际写代码时最需要记住的部分:

层类型核心作用关键参数参数量公式常见误用
Conv2d局部特征提取in/out_channels, kernel_size, padding, strideCout×(Cin×k×k+1)通道数随便给,不考虑下一层输入
BatchNorm2d稳定训练、加速收敛num_features2×C放在激活函数之后
MaxPool2d降采样、扩感受野kernel_size, stride无连续池化导致尺寸过小
Linear特征到类别映射in_features, out_featuresCin×Cout+Coutin_features 手算错误
Dropout抑制过拟合p无训练和推理阶段未切换

1.3 三个必须会手算的数字:输出尺寸、参数量、感受野

真要说入门阶段最值钱的一项能力,就是能手算这三个数字。会算,你就能在写代码之前预判网络能不能跑通;不会算,就只能靠报错信息一点点试。

输出尺寸的公式是out = floor((in + 2×padding − kernel_size) / stride) + 1。举个具体例子:输入 224,卷积核 3,padding 1,stride 1,那么(224 + 2 − 3)/1 + 1 = 224,尺寸不变。这就是为什么 3×3 卷积配 padding=1 被称为“same padding”,它是最常用的组合。输入 224,卷积核 3,padding 0,stride 1,结果是 222。差两个像素看着不多,但堆十几层之后尺寸会塌得很快。

特别提醒一个坑:stride=2的时候,如果输入是奇数尺寸,向下取整会丢掉一个像素。比如 7 进 2 出,就是 3。这种尺寸的隐性缩水在小数据集上不明显,但在需要精确定位的任务里会带来偏移误差。

参数量的公式在前面表格里给了。以Conv2d(3, 64, 3, padding=1)为例,参数量是64 × (3 × 3 × 3 + 1) = 1792,其中那个 +1 是偏置项。到了Conv2d(256, 256, 3, padding=1),参数量就是256 × (256 × 3 × 3 + 1) = 590080。同样是一个卷积层,差了三百多倍。所以通道数的设计一定要有梯度感,不能凭感觉往上堆。

感受野指的是输出特征图上一个点,对应原图多大区域。计算公式是逐层递推:RF_new = RF_old + (k − 1) × 累积stride。一个 3×3 卷积接一个 2×2 池化,再一个 3×3 卷积,感受野是 3 → 4 → 8。也就是说,三层之后每个点只“看到”原图 8×8 的区域。如果你的目标物体在原图上占 200×200,那么感受野必须堆到接近这个量级,网络才可能“看全”整个物体。这是为什么深层网络必须靠不断池化或步长卷积把感受野撑大的根本原因。

我见过不少入门实现,堆了五六层卷积但一次都没降采样,感受野只有十几像素,结果模型死活学不到全局信息,还以为是学习率的问题。感受野这个概念在入门教程里经常被跳过,但它在排查“模型明明能收敛却效果很差”这类问题时非常关键。

2. 环境搭建与数据管道:新手翻车率最高的地方

2.1 框架选择:入门阶段别贪多

选框架这件事,我的建议非常直接:入门阶段只装一个,把它用透,别在选型上纠结超过十分钟。主流的两三个框架在 CNN 这件事上能力没有本质差距,真正的差距在学习成本和你后面能找到的参考资料密度上。

我给入门者的判断标准只有三条。第一,能不能在半小时内跑通一个官方自带的示例;第二,报错信息是否足够具体,能不能告诉你哪一层的维度对不上;第三,社区里针对常见问题的回答多不多。按这三条去试,很快就能定下来。

为什么强调“只装一个”?因为两个框架同时装在一个环境里,最容易发生的是 CUDA 版本和依赖库版本冲突。我见过有人装完之后import直接报底层库加载失败,折腾一整天,最后发现是两个框架依赖了不同版本的同一个运行库。这类问题对入门者来说完全是噪音,跟 CNN 一点关系都没有。

环境管理上,我强烈建议每个项目单独建虚拟环境。命令不复杂:

python -m venv cnn_env source cnn_env/bin/activate # Windows 下是 cnn_env\Scripts\activate pip install torch torchvision --index-url <官方给出的对应版本源>

安装完之后,第一件事不是写模型,而是跑这三行验证:

import torch print(torch.__version__) print(torch.cuda.is_available()) # 没有独显就是 False,完全正常 print(torch.randn(2, 3).shape)

cuda.is_available()返回 False 不代表装错了。很多入门者的第一反应是“没显卡学不了深度学习”,这是误解。小尺寸图片分类任务,用 CPU 跑一个几层的 CNN,一个 epoch 也就几十秒到几分钟,完全能支撑入门阶段的所有实验。真正需要显卡的是大规模数据和深层网络,那已经是下一阶段的事了。

还有一点:不要在系统 Python 里装东西。一旦装崩了,重装系统的成本远高于建个虚拟环境。这是我见过最不值得的翻车方式。

2.2 自定义数据集要写的四个方法

入门教程里最省事的做法是用框架自带的示例数据集,但那只能让你跑通一次,换到自己的数据立刻抓瞎。所以我建议第一节实战课就直接写自定义数据集,把数据管道这件事一次搞明白。

核心就是继承Dataset类并实现三个方法,加上一个初始化。这四个部分各管一件事,缺一个就跑不起来:

import os from PIL import Image from torch.utils.data import Dataset class FolderDataset(Dataset): def __init__(self, root, transform=None): self.samples = [] # 存 (图片路径, 标签) 的列表 self.transform = transform self.classes = sorted(os.listdir(root)) # 类别名,排序保证可复现 self.class_to_idx = {c: i for i, c in enumerate(self.classes)} for cname in self.classes: cdir = os.path.join(root, cname) if not os.path.isdir(cdir): continue for fname in os.listdir(cdir): if fname.lower().endswith(('.jpg', '.png', '.jpeg')): self.samples.append( (os.path.join(cdir, fname), self.class_to_idx[cname]) ) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] img = Image.open(path).convert('RGB') # 统一转三通道 if self.transform is not None: img = self.transform(img) return img, label

__init__里做一次性扫描,把所有文件路径和标签缓存成列表。这一步不要放在__getitem__里做,否则每个 epoch 都要重新遍历目录,速度会慢到让你以为程序卡死。__len__返回样本总数,训练循环靠它算 epoch 长度。__getitem__负责按索引读一张图并返回(张量, 标签)。

Image.open(...).convert('RGB')这一行值得单独说。有些数据集里混着灰度图和带透明通道的 PNG,如果不统一转换,你会得到1×H×W或4×H×W的张量,然后在卷积层报通道数不匹配。这个 bug 的迷惑之处在于:前几百个样本都正常,跑到某个位置突然崩,因为那张图刚好是灰度图。统一convert('RGB')成本极低,能省掉一整类玄学问题。

还有个容易被忽略的点:类别名的排序。os.listdir返回的顺序在不同文件系统上可能不一样,如果不排序,标签映射每次运行都可能变,导致你保存的模型和下次训练时的标签对不上。这是一个极其隐蔽的坑,模型“看起来”训练正常,但预测结果的类别是乱的。

2.3 归一化、打乱与 batch 的三个硬规矩

数据管道搭好之后,有三件事如果做错,后面的训练全白费。它们看起来都是细节,但每一件都足以让模型完全不收敛。

第一,训练集要做随机打乱,验证集不要。DataLoader里的shuffle=True只给训练集开。原因很直白:如果训练数据是按类别顺序排列的,每个 batch 里全是同一个类别,梯度方向会剧烈摆动,模型很难收敛。打乱之后每个 batch 是混合的,梯度更稳定。验证集不需要打乱,因为不参与梯度更新,打乱只会让结果难以复现。

第二,归一化的均值方差要和实际数据匹配。很多人直接抄 ImageNet 的mean=[0.485, 0.456, 0.406],但如果你的数据是医学影像或者工业缺陷图,分布和自然图像完全不同,用这个均值方差反而会把有用的信号压掉。正确做法是先在训练集上统计一遍真实均值方差,或者至少先用mean=[0.5,0.5,0.5], std=[0.5,0.5,0.5]做一个粗略的标准化。

归一化的作用是让输入分布集中在 0 附近、方差接近 1,这样每一层的输入尺度稳定,梯度不会因为输入太大而爆炸、也不会因为太小而消失。如果不做,输入在 0-255 范围,第一层卷积的权重更新会非常剧烈,学习率稍微大一点就发散。

第三,batch size 和学习率要一起调。一个经验规律是:batch size 翻倍,学习率大致也可以翻倍。原因是更大的 batch 给了更准确的梯度估计,可以迈更大的步子。反过来说,如果你把 batch 从 64 降到 8,学习率还保持不变,训练会变得非常抖动。

下面这组是我在小尺寸图片分类任务上反复验证过的起始配置,可以直接用:

参数入门推荐值说明
batch_size32 或 64显存不够就往下调,别硬扛
初始学习率1e-3(Adam)/ 1e-2(SGD)Adam 对入门更友好
优化器Adam自适应学习率,少调一个超参
归一化按训练集统计,粗略可用 0.5别盲抄自然图像的值
打乱训练集 True,验证集 False影响梯度和复现性
num_workers0 起步,稳定后再调大Windows 下大于 0 易出问题

num_workers这一项我特别提醒:在 Windows 环境下设成大于 0 有时会直接卡死或者抛出进程相关异常,入门阶段先用 0,等整个流程稳定了再回头优化读取速度。为了省几秒钟的读取时间而卡在一个莫名其妙的进程问题上,非常不划算。

3. 手搓一个能跑起来的 CNN:逐层拆解设计意图

3.1 网络结构为什么是这个形状

我把入门网络的设计原则总结成一句话:每经过一次降采样,通道数翻倍。这不是玄学,背后有明确的计算理由。降采样让特征图的空间尺寸减半,空间信息量变成四分之一;为了让每一层承载的信息总量大致持平,通道数就要翻倍。这既是经验规律,也让每一层的计算量保持在同一个量级,不会出现某一层特别慢的情况。

下面这个网络是我给入门者用的标准模板,输入假设是 64×64 的三通道图,10 类输出:

import torch.nn as nn class SmallCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( # block 1: 64x64 -> 32x32 nn.Conv2d(3, 32, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.Conv2d(32, 32, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), # block 2: 32x32 -> 16x16 nn.Conv2d(32, 64, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.Conv2d(64, 64, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # block 3: 16x16 -> 8x8 nn.Conv2d(64, 128, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.pool = nn.AdaptiveAvgPool2d(1) # 任意输入尺寸都能压成 1x1 self.classifier = nn.Linear(128, num_classes) def forward(self, x): x = self.features(x) x = self.pool(x) x = x.flatten(1) return self.classifier(x)

逐层说设计意图。bias=False配BatchNorm2d是一个必须记住的组合:BatchNorm 本身就有一个可学习的偏移参数 β,它会吸收掉卷积的偏置作用,两个都留着是纯浪费参数。这个细节在很多入门代码里被忽略,虽然不致命,但体现的是对每一层在干什么的理解。

卷积后面紧跟 BatchNorm 再跟 ReLU,这个顺序是有讲究的。BatchNorm 做的是把这一层的输出标准化到均值 0、方差 1,然后 ReLU 做非线性截断。如果顺序反了,先 ReLU 再 BatchNorm,那么所有负值已经被截成 0,标准化之后分布会变得很怪,等于白做。这个顺序在早期论文里也有过不同做法,但现在基本是社区共识。

用AdaptiveAvgPool2d(1)代替直接展平接全连接,好处是输入尺寸可以变化。如果写死Linear(128*8*8, num_classes),那么一旦你把输入从 64 改成 96,网络立刻报错,而且in_features手算错一个数也是同样的报错,排查起来很烦。自适应池化把这个耦合彻底解开了,代价是几乎可以忽略的。

3.2 训练循环里真正起作用的那几行

训练循环的骨架很短,但每一行的顺序都不能乱。乱一步,轻则不收敛,重则不报错但结果全错。看这段:

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = SmallCNN(num_classes=10).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) for epoch in range(30): model.train() running_loss = 0.0 for x, y in train_loader: x, y = x.to(device), y.to(device) optimizer.zero_grad() # 1. 清空上一步的梯度 logits = model(x) # 2. 前向 loss = criterion(logits, y) # 3. 算损失 loss.backward() # 4. 反向传播 optimizer.step() # 5. 更新参数 running_loss += loss.item() * x.size(0) train_loss = running_loss / len(train_loader.dataset) print(f'epoch {epoch+1}, train_loss={train_loss:.4f}')

zero_grad()放在最前面,这一步如果漏了,梯度会在 batch 之间不断累加,模型很快就会发散。这是入门阶段最容易漏的一行,而且症状很怪——前几个 batch 看起来正常,后面loss突然变成nan。

loss.item()取的是标量值,不取的话你会把计算图一直挂在内存里,跑到后面显存爆炸。loss.item() * x.size(0)是为了加权平均,因为最后一个 batch 的样本数可能不足,直接平均每个 batch 的 loss 会有偏差。

model.train()和model.eval()的切换非常关键,虽然这个网络里只有 BatchNorm 受它影响。在eval()模式下,BatchNorm 用的是训练阶段累积的滑动均值方差,而不是当前 batch 的统计量。如果你忘了切换,验证阶段的输出会受 batch 内其他样本影响,指标不可信,而且这种错误不会报错,只会让你的评估结果莫名其妙地波动。我建议把验证循环单独写成一个函数,函数开头固定写上model.eval(),从结构上避免遗忘。

还有一个torch.no_grad(),验证阶段必须包上。它的作用是关闭梯度记录,减少显存占用、加快速度。不写也能跑,只是慢一些,但这属于那种“顺手就该做对”的事。

3.3 第一次跑完必须看的四个信号

模型跑起来了不等于跑对了。第一次训练结束后,我建议固定检查这四个信号,缺一个都可能是在自欺欺人。

第一个信号是 loss 的量级。对于一个 10 分类问题,随机猜测的交叉熵是ln(10) ≈ 2.3。如果初始 loss 是 2.3 左右,说明初始化正常。如果一上来就是 15 或者 0.01,那说明标签有问题、损失函数选错了、或者输出层多加了 Softmax。

第二个信号是 loss 的下降形态。健康的曲线是前几个 epoch 下降很快,之后逐渐变缓,整体呈一个光滑的凸形。如果 loss 几乎是直线下降,学习率可能偏小;如果上下剧烈震荡,学习率偏大或者 batch 太小;如果一直平着,见下一节的排查。

第三个信号是训练集和验证集的差距。训练准确率 99%、验证准确率 60%,这是典型的过拟合。两个都低,那是欠拟合或者数据管道有问题。这两个都高且接近,才算训练成功。这个对比要每个 epoch 都打印,不要只打印 loss。

第四个信号是预测结果的分布。拿一批验证数据,统计模型预测的类别分布。如果所有样本都被预测成同一类,那说明模型根本没学到东西,只是在输出一个先验概率最高的类别。这个检查特别有效,因为它在准确率上可能还表现为 10% 左右,看起来“正常”,实际上模型是死的。

model.eval() preds = [] with torch.no_grad(): for x, y in val_loader: x = x.to(device) out = model(x) preds.append(out.argmax(dim=1).cpu()) preds = torch.cat(preds) print(torch.bincount(preds, minlength=10).tolist())

这一小段代码值很多时间。它把“模型到底有没有输出多样性”这件事直接量化了。如果打印出来是[500, 0, 0, 0, 0, ...],那问题不在超参,在更上游的地方。

4. 训练不收敛时的排查顺序(附坑位对照表)

4.1 先判断是“学不动”还是“学歪了”

训练出问题时,最忌讳的是同时改三个东西——换学习率、换优化器、加深网络,然后不知道是哪个起了作用。我建议先做一个二分类判断,把问题范围砍掉一半。

看训练集上的表现。如果训练集 loss 都降不下去,那是“学不动”,问题在优化过程本身:学习率、初始化、损失函数、梯度流动。如果训练集 loss 能降到很低但验证集很差,那是“学歪了”,问题在泛化:过拟合、数据分布不一致、数据泄露。

这两类问题的解法几乎不重叠,所以先分清类别,能省掉大量瞎试。判断方法很简单:临时把验证集换成训练集的一部分,再跑一遍,看指标能不能上去。能上去,就是泛化问题;上不去,就是优化问题。

“学不动”里还有一个更隐蔽的分支:梯度根本没传过去。比如某些层被detach了,或者用了会截断梯度的自定义操作,或者requires_grad被意外设成了 False。这种情况的典型表现是某些层的参数在训练前后完全没变。可以在训练前后各取一次某层权重的均值做对比,差值是 0 就是没更新。

还有一种情况是“假学不动”:loss 在降,但降得极慢,几十个 epoch 才降一点点。这通常是学习率太小或者初始化尺度不对,不是结构问题。区分的关键是看 loss 有没有单调的下降趋势,哪怕幅度很小。

4.2 从数据到损失函数到优化器的逐层排查

判断完类别之后,按下面的顺序往下查。这个顺序是从上游往下游走的,因为上游的错误代价更大、更常见。

第一步,查数据。这是最容易被跳过也最容易出问题的地方。具体查三件事:标签和图片是否对得上、类别是否严重不均衡、有没有全黑或全白的坏图。标签对不上这件事听起来离谱,但在手工整理数据集时经常发生,尤其是文件名排序和标签映射不一致的时候。验证方法很简单:随机取 16 张图连同标签一起显示出来,肉眼看一遍。这十秒钟的操作能排除掉很多后面的无用功。

第二步,查输入尺度。输入是否归一化、归一化的参数是否合理、有没有出现全零输入。全零输入会让 BatchNorm 在训练初期直接输出全零,整个网络停滞。如果用了ToTensor()但忘了Normalize(),输入范围是 0 到 1;如果两者都没用,范围是 0 到 255。后者直接喂进网络,几乎必然发散。

第三步,查损失函数和输出层。分类任务用交叉熵时,网络最后一层不要加 Softmax。交叉熵损失内部已经包含了 log-softmax 运算,前面多一个 Softmax 会导致双重归一化,梯度被压缩得非常小,训练几乎不动。这个错误在入门代码里出现频率极高,因为从“推理阶段需要 Softmax”到“训练阶段不需要”这一步很容易混淆。

第四步,查学习率。学习率是单个影响最大的超参。一个快速的诊断方法是跑一次学习率扫描:从 1e-6 开始,每个 batch 按指数增长到 1,记录 loss。loss 开始明显下降的那个区间的中段,就是合适的量级。这个方法比反复试快得多,而且能直观看到“太大发散、太小不动”的分界在哪。

第五步,查 BatchNorm 和初始化。如果网络很深但没加 BatchNorm,深层几乎训不动。如果加了但momentum设得极端,也会不稳定。初始化方面,现在的框架默认用 Kaiming 或 Xavier,一般不用手动改,但如果你手写了初始化并设了一个很小的标准差,信号会逐层衰减到零。

下面这张表是我整理的对照速查,症状和原因一一对应:

症状最可能的原因验证方式处理
loss 恒定在 ln(类别数) 附近学习率过小 / 输出层多了 Softmax打印初始 loss 值调大 lr,去掉多余 Softmax
loss 变成 nan学习率过大 / 未归一化 / 除零逐步减小 lr 复现归一化、降 lr、加梯度裁剪
训练集 loss 降不下去数据标签错 / 梯度断流检查标签、对比权重前后差值修数据、检查 detach
训练好验证差过拟合 / 数据泄露对比两个集准确率加增强、加正则、查重复样本
预测全是同一类类别极不均衡 / 模型未学到统计预测分布重采样、加权损失
验证指标剧烈波动忘了 eval() / batch 太小检查模式切换补 eval()、增大 batch

4.3 我踩过的几个典型坑

第一个坑:归一化用了错的均值方差。早期我照抄了一份自然图像的统计值,用在一个偏暗的工业图像数据集上,结果模型训练得特别慢,准确率卡在 70% 上不去。换成按训练集真实统计的值之后,同样结构同样超参,准确率直接到了 88%。教训是:任何抄来的预处理参数都要先验证一下适不适合自己的数据。

第二个坑:shuffle=True加在了验证集上。表现是每次跑验证,指标都在小幅跳动,同一个模型跑三次得到三个不同的结果,让我误以为是模型不稳定。后来把验证集的 shuffle 关掉,指标立刻稳定了。验证集打乱不影响正确性,但会破坏可复现性,做对比实验时非常误导人。

第三个坑:num_workers开太大导致卡死。在某台机器上为了加快读取,把num_workers设成了 8,结果训练一开始就卡住不动,也不报错。降到 0 之后正常。后来才明白是子进程和数据加载的兼容问题。这个问题在入门阶段完全没有必要碰,先用 0,跑通再说。

第四个坑:Linear的in_features手算错。输入尺寸改了一次,特征图最后是 7×7,我按 8×8 算了,报错信息只说维度不匹配,没说具体哪一层。改成自适应池化之后彻底解决了。这个经历让我在小网络上坚持用AdaptiveAvgPool2d,省下来的调试时间远超它带来的那点性能差异。

这些坑有一个共同点:它们都不会给出“你错了”的明确提示,而是表现为“效果不好”。这才是最耗时的类型。所以我现在的习惯是,每搭一个新流程,先用一个极小的子集(比如 200 张图)过拟合一遍,确认模型能把训练集 loss 压到接近 0。能过拟合,说明整个链路是通的,再去跑全量数据。这个“先过拟合小样本”的技巧,是我认为入门阶段最值得养成的习惯,没有之一。

5. 跑通之后,往哪儿加东西才算真入门

5.1 数据增强与正则:先治过拟合

模型能跑通、训练集指标很好、验证集上不去,这时候第一个该动的是数据增强,而不是加深网络。加深网络往往会让过拟合更严重,这是一个很常见的反向操作。

数据增强的本质是在不改变语义的前提下扩充数据分布。对图像来说,常用的有随机水平翻转、随机裁剪、小角度旋转、颜色抖动。为什么这些操作安全?因为一只猫左右翻转之后还是猫,稍微裁掉一点边缘也还是猫。但要注意,垂直翻转对很多任务是不安全的,比如数字识别、交通标志识别,翻转之后语义就变了。

from torchvision import transforms train_tf = transforms.Compose([ transforms.RandomResizedCrop(64, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.5]*3, std=[0.5]*3), ]) val_tf = transforms.Compose([ transforms.Resize((64, 64)), transforms.ToTensor(), transforms.Normalize(mean=[0.5]*3, std=[0.5]*3), ])

关键点是训练和验证必须用不同的 transform。训练用带随机的版本,验证用确定性的版本。如果验证集也加了随机裁剪,每次评估的结果都不一样,你没法判断模型是否真的进步了。这是一个很容易犯的错误,因为很多人直接把训练用的 transform 复制一份给验证集。

正则方面,入门阶段掌握两个就够了。Dropout放在全连接层前面,比例 0.3 到 0.5;weight_decay加在优化器里,典型值是 1e-4 到 1e-2。这两个的作用机制不同:Dropout 在训练时随机失活一部分神经元,逼网络不依赖某几个特定特征;weight_decay 是在损失里加权重范数的惩罚项,限制权重变大。两个一起用没问题,但不要同时开到很大,否则会欠拟合。

有一个反直觉的观察:数据增强的效果通常比调网络结构更大。在小数据集上,把增强做对,往往比换一个更复杂的骨干网络提升更明显。原因是过拟合的根源是数据量不足,结构再强也是在有限的数据上拟合,增强才是真正增加了模型见到的多样性。

5.2 迁移学习:小数据集最划算的一步

如果你的数据只有几百到几千张,从头训练一个 CNN 基本是在浪费算力。这时候最划算的做法是迁移学习:拿一个在大规模数据上预训练好的骨干网络,把最后的分类层换掉,只训练这一层,或者用很小的学习率微调整个网络。

为什么这招管用?因为卷积网络的前几层学到的是非常通用的东西——边缘、角点、颜色块、简单纹理。这些特征在几乎所有图像任务里都用得上。数据量小的时候,你根本没能力从零学出这些通用特征,直接借用是最高效的路径。

具体操作上,有两种常见策略:

策略做法适用场景学习率
特征提取冻结骨干,只训分类头数据量极小(几百张)1e-3
整体微调骨干用小学习率,头用大学习率数据量中等(几千张)骨干 1e-5,头 1e-3

实现上要注意一个细节:冻结网络时用requires_grad = False,但 BatchNorm 层即使在冻结状态下仍然会更新滑动均值方差。如果不想要这个行为,需要额外把它们设成eval()模式。这个小细节在入门阶段可以不深究,但知道它的存在能帮你解释一些“明明冻结了效果还是变了”的疑惑。

微调时学习率一定要小。骨干网络的权重已经在一个很好的位置上,用 1e-3 这种量级去更新,会把预训练学到的特征直接冲掉,效果反而不如冻结。这也是我见过最多的迁移学习误用:直接用从头训练的学习率去微调整个网络,结果还不如直接冻结。

5.3 什么时候该回头补数学

入门阶段完全不需要先把反向传播的推导搞明白再动手。这就像学开车不需要先懂发动机原理。但到了某个节点,你会发现自己需要补数学了,这个节点有几个明确的信号。

第一个信号:你开始需要解释“为什么这个改动有效”。如果每次调参都是试出来的,说不出机制,那说明需要的不是更多实验,而是对梯度流动、损失曲面的理解。这时候回头看链式法则、看卷积的反向传播推导,会非常有针对性,因为你已经有一堆具体现象等着被解释。

第二个信号:你想改损失函数但不知道怎么求导。标准化的一批损失函数可以直接调库,但一旦涉及自定义约束、多任务加权、特殊的采样策略,就需要理解每一项对梯度的贡献。这时候补一下矩阵求导会很有帮助。

第三个信号:你在读论文时看不懂公式。这个最直接。但我的建议是不要一上来就啃最难的,从综述和教程性质的材料开始,先把符号体系熟悉了,再去看原始论文。

补数学的顺序我建议是:先补线性代数里的矩阵乘法和维度变换(这个对读懂网络结构最直接有用),再补概率里的期望和交叉熵(理解损失函数),最后补微积分里的链式法则(理解反向传播)。这个顺序是按“对实际写代码的帮助程度”排的,不是按数学体系的完整性排的。

最后分享一个我自己一直在用的习惯:每学一个新的网络结构,先在纸上把每一层的输入输出形状画出来,包括 batch 维。画完之后再写代码,你会发现报错率下降得非常多。这个动作看起来原始,但它把“维度”这个最容易出错的东西在动手之前就固定下来了。我用这个方法带过几个完全没基础的人入门,他们反馈说把形状画一遍之后,很多原本看不懂的报错信息突然就能读懂了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询