ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

CNN入门实战:从零搭建图像分类网络、参数计算与训练排查

CNN入门实战:从零搭建图像分类网络、参数计算与训练排查

1. 先把目标和场景定死:CNN入门项目怎么选

每次有人问我“CNN 该怎么入门”,我第一反应不是讲卷积核,而是先问一句:你打算让这个网络解决什么具体问题。这个问题不回答清楚,后面抄多少代码都是白搭。CNN 入门级实战的核心,从来不是把 LeNet 或 ResNet 的代码敲一遍跑通,而是完整走一遍“任务定义—数据准备—模型搭建—训练调参—评估落地”的闭环。这条闭环跑通了,你才算真正摸到了深度学习的门把手;跑不通,看再多原理图也只是纸上谈兵。

我给这个入门实战定的目标很朴素:用一个自己写的、层数不超过十层的卷积网络,在一个中等规模的图像分类数据集上,把验证集准确率做到一个能看过去的水平,并且整个过程能被完整复现、被逐层解释。适合谁看?适合已经会写 Python、懂一点 NumPy 和矩阵运算,但没完整训练过一个卷积网络的人;也适合那些跑过开源 Demo、却说不清padding为什么设成 1、batch size为什么不能随便调的人。下面这套东西,我在不同机器上重复过很多次,删掉了所有花架子,只留能跑通、能解释、能扩展的部分。

1.1 为什么拿图像分类当第一个 CNN 项目

图像分类是 CNN 的“母题”。检测、分割、人脸识别、医学影像判读,往上追两层都是在分类骨架上长出来的。你把这个骨架摸透,后面换任务只是换头、换损失、换数据管线。反过来说,如果一上来就啃目标检测,你会同时被锚框设计、正负样本匹配、NMS 后处理三座山压住,根本分不清是模型没学好还是后处理写错了。

另一个现实原因是反馈快。分类任务的评价指标单一,就是准确率,加上混淆矩阵和每类召回,一眼能看出模型是整体不行还是偏科。这种“单一指标、快速反馈”的特性,对入门者极其重要,因为你需要在一两天内反复迭代十几次,才能建立起对超参数的直觉。如果每次实验都要等三个小时才出一个模棱两可的结果,学习效率会低到让人放弃。

还有一个容易被忽略的点:分类任务的数据标注成本最低。你只需要给每张图一个标签,不需要画框、不需要像素级掩码。这意味着你可以自己造数据、自己扩数据,遇到问题时能快速验证“是不是数据的问题”。我在带新人的时候,通常会要求他们先自己做一个小数据集,哪怕只有五六类、每类两三百张,这个从零到一的过程比直接用现成数据集有价值得多。

1.2 数据集与任务规模的选择逻辑

选数据集有个基本原则:先小后大,先干净后脏。入门阶段最忌讳直接上百万级的大数据集,一是训练慢,二是出了问题你根本不知道从哪查。我的建议是控制在 5 到 20 个类别、每类 300 到 1000 张、总样本量一万张以内。这个量级下,一张消费级显卡几个小时能跑完一轮完整实验,你有一整天就能试五六组配置。

分辨率也要压。原图可能是 4K 的,但入门模型完全没必要吃这么大。我一般统一缩到 64×64 或 96×96。这里有个很多人踩的坑:直接把输入从 224 降到 64,模型结构却不改,结果最后特征图只剩 2×2,信息损失严重。正确做法是分辨率降下来之后,同步减少下采样次数,或者把最后的池化层去掉。输入尺寸和网络深度是要配套设计的,不是两个独立的旋钮。

类别平衡也得看一眼。如果某一类占了 70% 的样本,模型只要无脑全猜这一类就能拿到 70% 准确率,训练日志会显得很好看,但模型其实什么都没学到。入门阶段建议直接做类别均衡采样,或者在划分数据时保证每类在训练集、验证集里的比例一致。这个检查花不了五分钟,但能帮你避免后面几小时的困惑。

规模档位类别数每类样本建议分辨率单轮训练耗时(入门级显卡)
极小3-5100-30048×481-3 分钟
小5-10300-60064×643-8 分钟
中10-20600-100096×9610-25 分钟

注意:上表的时间是在batch size取 32、开启混合精度的情况下估的。如果你发现自己跑一轮要一个小时,先别怀疑模型,去查数据加载是不是成了瓶颈。

1.3 框架与工具链选型的取舍

框架这块我不绕弯子:入门就用 PyTorch。理由不是它“更先进”,而是它的调试体验对新手友好。动态图意味着你可以像写普通 Python 一样在训练循环里打print、下断点、看中间张量的形状,出错时的报错信息也基本能定位到具体那一行。静态图框架在入门阶段会让你把大量时间花在“图构建错误”上,而不是“模型为什么学不好”上,这是两种完全不同的学习曲线。

工具链上,我只建议装三样东西:框架本体、一个数据处理库、一个可视化工具。可视化工具用来画 loss 曲线和混淆矩阵,别小看这一步,很多人训练完只看最后一行数字,中间的震荡、平台期、突然发散全都错过了,而这些恰恰是最有信息量的部分。至于实验管理平台、分布式训练、自动调参那一套,入门阶段一律不碰,那是等你已经能稳定跑出结果之后才需要考虑的效率工具。

如果你手上只有 CPU,也不是不能做。把分辨率降到 32×32、类别压到 5 类、模型通道数砍一半,一样能跑出有意义的实验结果。我见过有人用笔记本 CPU 把整个流程走通,虽然慢,但对原理的理解一点不打折。硬件决定的是实验规模,不是学习路径。

2. 卷积网络的核心构件拆解与参数计算

模型结构这块,很多人是从网上抄一份现成的,能跑通就完事。但入门阶段真正的价值在于:你得能手算出每一层的输出尺寸和参数量,能在改结构之前预判它会带来什么影响。这一节我把这些计算摊开讲,顺便解释每个设计选择背后的理由。

2.1 卷积层:核大小、步长、填充的连带影响

卷积操作的本质是“局部感受野 + 权重共享”。局部感受野让网络先看小范围纹理,再逐层组合成大范围语义;权重共享让同一个卷积核在整张图上滑动,参数量与图像尺寸解耦。这两点是 CNN 相比全连接网络在图像上占据优势的根本原因,也是为什么一张 64×64 的图用全连接层会瞬间爆参数、而用卷积层却很轻量的原因。

三个超参数里,核大小决定单次观察范围。3×3 是事实上的默认值,原因是两个 3×3 堆叠的感受野等于一个 5×5,但参数量是 2×(9C²)=18C² 对比 25C²,还多了一次非线性激活。这就是 VGG 之后大家都爱堆 3×3 的原因,不是跟风,是算过账的。5×5 和 7×7 一般只用在第一层,用来快速降分辨率。

步长控制下采样速度。步长设 2 意味着输出尺寸减半,它和池化层在功能上有重叠。我的习惯是:主干用步长 1 的卷积保持分辨率,下采样交给池化或者步长 2 的卷积二选一,别在同一层里既用步长 2 又接池化,那样降得太快,小目标信息直接没了。

填充是为了控制输出尺寸。padding=1配 3×3 核、步长 1,输出尺寸和输入完全相同,这叫 same padding。它的实际作用是保护边缘像素——不加填充的话,图的四个角落参与计算的次数远少于中心区域,边缘信息会被系统性削弱。

输出尺寸的通用公式是:

O = floor((I + 2P - K) / S) + 1

其中 I 是输入边长,K 是核大小,P 是填充,S 是步长。这个公式必须背下来,因为它能帮你在写代码之前就发现尺寸对不上的问题。

2.2 输出尺寸与参数量的手算过程

我拿这次实战要用的结构举例,输入是 3×64×64,具体配置如下:

层配置输出尺寸参数量
Conv13→32, 3×3, P=1, S=132×64×64(3×3×3+1)×32 = 896
Conv232→32, 3×3, P=1, S=132×64×64(3×3×32+1)×32 = 9248
MaxPool2×2, S=232×32×320
Conv332→64, 3×3, P=1, S=164×32×32(3×3×32+1)×64 = 18496
Conv464→64, 3×3, P=1, S=164×32×32(3×3×64+1)×64 = 36928
MaxPool2×2, S=264×16×160
Conv564→128, 3×3, P=1, S=1128×16×16(3×3×64+1)×128 = 73856
Conv6128→128, 3×3, P=1, S=1128×16×16(3×3×128+1)×128 = 147584
MaxPool2×2, S=2128×8×80
GAP全局平均池化1280
FC128→类别数类别数128×C + C

卷积层参数量的公式是 (K_h × K_w × C_in + 1) × C_out,那个 +1 是偏置项。以 Conv6 为例:核是 3×3,输入通道 128,输出通道 128,所以 (3×3×128+1)×128 = 1153×128 = 147584。这批参数占了整个网络的一半以上,说明参数主要集中在中后段的通道扩展上,这也解释了为什么给后面几层减通道能有效控参。

总参数量算下来大概 29 万左右,模型文件在 1.2MB 上下。这个体量意味着它能在很普通的设备上推理,也意味着它的表达能力有限——如果你的任务有上百个细分类别,这个容量肯定不够,得加深加宽。参数量和任务复杂度要匹配,这是选型时最该先想清楚的事。

还有一批参数容易被漏算:批归一化层。每个 BN 层有 2×C 个参数(缩放和偏移),虽然量不大,但层数一多就不能忽略。所以严格说,上面表格里的数字要再加上 BN 的部分,总量会到 30 万出头。

2.3 池化、批归一化与激活的位置安排

池化我选最大池化而不是平均池化,理由是最大池化保留的是区域内的最强响应,对纹理边缘更敏感,在分类任务上通常表现更好。平均池化更适合需要保留整体统计特性的场景。池化层的参数量是零,它不学习任何东西,只做固定规则的降采样,这一点常被初学者忽略——以为池化也有参数要训练。

批归一化的位置我固定在“卷积之后、激活之前”。这个顺序不是随意的:卷积输出先被归一化到均值 0、方差 1 的分布,再送进 ReLU,能有效避免大量神经元落在负半轴被永久抑制。如果反过来先激活再归一化,ReLU 已经把负值清零了,归一化的统计特性会被破坏,效果打折扣。

BN 还有两个副作用值得说清楚。一是它让网络对初始化不敏感,你可以放心用默认初始化而不用精心设计;二是它引入了训练和推理的行为差异——训练时用当前 batch 的统计量,推理时用滑动平均。这意味着如果你的 batch size 太小(比如 2 或 4),BN 的统计量会很不稳定,训练会出现明显抖动。这也是为什么小 batch 场景下要考虑换成组归一化的原因。

激活函数入门阶段统一用 ReLU,不要一上来就试各种变体。ReLU 计算快、梯度不饱和,在正区间导数为 1,能让梯度顺畅回传。它的缺点(负区间梯度为 0,可能造成神经元死亡)在入门规模下基本不构成问题。等你遇到了“训练到一半大量通道输出恒为 0”的现象,再去考虑 LeakyReLU 或 GELU 也不迟。

2.4 感受野与全局平均池化的取舍

感受野是很多人算不明白但极其重要的概念。它指的是输出特征图上一个点,对应回原始输入图的区域大小。逐层递推的公式是:

r_out = r_in + (K - 1) × j_in

j_out = j_in × S

其中 r 是感受野,j 是相邻特征点之间的跳距。按上面表格的结构算下来,最后一层卷积的感受野是 36×36,而输入是 64×64,也就是说每个输出点能看到原图一半以上的区域。这个比例是合理的:太小说明网络看得不够远,语义信息不足;太大说明过度聚合,定位能力丢失。

全局平均池化替代全连接层,是我在这个结构里做的一个明确取舍。传统做法是把 8×8×128 展平成 8192 维再接连全连接层,参数量是 8192×C,类别数 10 的话就是八万多个参数,占了总参数量的四分之一。换成 GAP 之后,每个通道求一个平均值得到 128 维向量,再接一层 128→C 的小全连接,参数量降到一千多。

这个替换带来的不只是省参数。全连接层对输入的空间位置极其敏感,换个视角、挪个位置,展平后的向量就完全不同;GAP 是逐通道求均值,天然具备空间平移不变性。另外 GAP 强制每个通道输出一个有意义的响应强度,这在后面做类激活图可视化时会非常方便。代价是收敛可能稍慢一点,因为梯度回传的路径变短了,但这个代价在入门规模下完全可接受。

3. 从零搭一个可训练的CNN:完整实操

原理讲完了,接下来是能直接抄的部分。我把整个流程拆成数据、模型、训练、监控四块,每一块都给出可直接运行的代码和背后的考虑。这套代码我在不同数据集上改改路径就能用,结构上没有花哨的东西,但每一行都有存在的理由。

3.1 数据管线:加载、划分、增强的顺序

数据这块最容易出错,而且错了很难发现。核心原则只有一条:先划分,再增强。如果你先把整个数据集做增强再划分,同一张原图派生出的多个版本可能同时落进训练集和验证集,验证集准确率会虚高,模型实际上一塌糊涂。这个坑我见过不止一次,症状是验证准确率高得离谱,一上真实数据就原形毕露。

划分比例上,训练、验证按 8:2 就行。测试集如果数据量紧张可以先不单独留,等模型定型再切一小块出来做最终评估。关键是验证集要独立、要固定,不能每次训练随机重划,否则你没法比较两次实验结果的好坏。

增强策略要克制。入门阶段我用三种:随机裁剪缩放、随机水平翻转、轻微颜色抖动。前两个几乎无风险,第三个要小心,如果你的任务本身依赖颜色(比如区分不同颜色的物体),颜色抖动会把关键信息抖没。垂直翻转也慎用,除非你的数据天然上下不分,否则会把“天上的东西”翻到地上,制造错误样本。

import torch from torch.utils.data import DataLoader, random_split from torchvision import transforms, datasets 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.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_tf = transforms.Compose([ transforms.Resize(72), transforms.CenterCrop(64), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])

注意验证集的变换里没有随机成分,只做确定性的缩放和裁剪。这是为了让每次评估的条件完全一致,否则你测出来的指标里混进了随机噪声,波动大得没法判断模型到底有没有进步。

归一化用 ImageNet 的均值方差是个务实的偷懒做法。理论上应该按你自己数据集的统计量来算,但如果你的数据也是自然图像,两者差别不大。真要坚持算,就是把训练集所有像素的均值和标准差求出来,替换掉这两行数字,收益通常在小数点后第二位。

full_ds = datasets.ImageFolder(root="./data/train", transform=train_tf) n_total = len(full_ds) n_val = int(n_total * 0.2) n_train = n_total - n_val train_ds, val_ds = random_split( full_ds, [n_train, n_val], generator=torch.Generator().manual_seed(42) ) val_ds.dataset = datasets.ImageFolder(root="./data/train", transform=val_tf) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4, pin_memory=True, drop_last=True) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False, num_workers=4, pin_memory=True)

这里有两个细节。一是random_split之后要给验证集单独换一套 transform,因为两个子集共享同一个 dataset 对象,不换的话验证集也会带上随机增强。二是训练集drop_last=True,把最后那个不满 32 的 batch 丢掉,避免 BN 在只有几个样本的 batch 上算出失真的统计量。验证集则可以drop_last=False,因为推理不走 BN 的 batch 统计。

提示:num_workers不是越大越好。设成 CPU 核心数的 1 到 2 倍比较稳妥,设太大反而会因为进程调度开销变慢。在 Windows 上如果遇到卡死,先把num_workers设成 0 排除多进程问题。

3.2 模型定义与形状自检

模型我按前面表格的结构写,每个 block 是“卷积—BN—激活”重复两次再接池化。这个模式叫双卷积块,比单卷积块多一层非线性,表达能力强一些,同时保持结构规整好排查。

import torch.nn as nn class SmallCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() def block(cin, cout): return nn.Sequential( nn.Conv2d(cin, cout, 3, padding=1, bias=False), nn.BatchNorm2d(cout), nn.ReLU(inplace=True), nn.Conv2d(cout, cout, 3, padding=1, bias=False), nn.BatchNorm2d(cout), nn.ReLU(inplace=True), ) self.features = nn.Sequential( block(3, 32), nn.MaxPool2d(2), block(32, 64), nn.MaxPool2d(2), block(64, 128), nn.MaxPool2d(2), ) self.gap = nn.AdaptiveAvgPool2d(1) self.classifier = nn.Linear(128, num_classes) def forward(self, x): x = self.features(x) x = self.gap(x) x = torch.flatten(x, 1) return self.classifier(x)

注意卷积层里写了bias=False。因为紧跟着 BN,BN 自带偏移参数,卷积的偏置会被 BN 减均值再除以标准差的操作完全吸收掉,留着纯属浪费参数。这个细节很多教程不讲,但它体现的是对“每个参数是否真的有用”的敏感度。

写完模型不要急着训练,先做形状自检:

m = SmallCNN(num_classes=10) dummy = torch.randn(2, 3, 64, 64) out = m(dummy) print(out.shape) # torch.Size([2, 10]) total = sum(p.numel() for p in m.parameters()) trainable = sum(p.numel() for p in m.parameters() if p.requires_grad) print(f"总参数 {total/1e3:.1f}K, 可训练 {trainable/1e3:.1f}K")

跑一遍打印出来,如果参数量和你手算的对得上(大概 29 万),说明结构没写错。这一步看起来多余,但我确实见过因为少写一个池化层、导致最后特征图尺寸不对、报错信息又指向别处的情况,排查半小时才发现是结构问题。花三十秒做自检,比事后翻代码划算。

3.3 训练循环:损失、优化器、学习率策略

损失函数用交叉熵。注意 PyTorch 的CrossEntropyLoss内部已经包含了 softmax,所以模型最后一层不能再接 softmax,否则相当于做了两次,梯度会被压得极小,训练会异常缓慢。这个坑非常经典,症状是 loss 下降极其缓慢且始终停在一个较高的值。

优化器选 Adam,学习率初始 1e-3。选 Adam 而不是 SGD 的理由很实际:入门阶段你没有精力去调动量系数和权重衰减的组合,Adam 的自适应步长能让你在大多数结构上“开箱即用”地收敛。代价是最终精度可能比精心调过的 SGD 低一点点,但那点差距在入门阶段完全不重要,先跑通比跑满更重要。

学习率调度用余弦退火,比固定学习率稳。固定学习率在后期容易在最优解附近来回震荡,余弦退火让学习率平滑地降到接近 0,收敛曲线会更干净。

import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR criterion = nn.CrossEntropyLoss(label_smoothing=0.1) optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=epochs, eta_min=1e-5)

label_smoothing=0.1是个性价比很高的小技巧。它把独热标签从[0,0,1,0]软化成一类似[0.011,0.011,0.911,0.011]的分布,防止模型对训练样本过度自信,对抑制过拟合有明确帮助,几乎不增加成本。

主训练循环:

scaler = torch.cuda.amp.GradScaler() for epoch in range(epochs): model.train() running_loss, correct, seen = 0.0, 0, 0 for imgs, labels in train_loader: imgs, labels = imgs.cuda(non_blocking=True), labels.cuda(non_blocking=True) optimizer.zero_grad(set_to_none=True) with torch.cuda.amp.autocast(): logits = model(imgs) loss = criterion(logits, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() running_loss += loss.item() * imgs.size(0) correct += (logits.argmax(1) == labels).sum().item() seen += imgs.size(0) scheduler.step() train_loss, train_acc = running_loss / seen, correct / seen val_loss, val_acc = evaluate(model, val_loader, criterion) print(f"epoch {epoch:02d} | train {train_loss:.4f} {train_acc:.4f} " f"| val {val_loss:.4f} {val_acc:.4f} " f"| lr {scheduler.get_last_lr()[0]:.2e}")

optimizer.zero_grad(set_to_none=True)比默认的zero_grad()略快,因为它把梯度置为 None 而不是填 0,省了一次内存写入。这种细节对最终结果没影响,但养成习惯之后,在大模型上能省出可观的显存和时间。

混合精度那段(autocast加GradScaler)是为了提速和省内促。它在保证数值稳定的前提下,把大部分计算压到半精度。如果你的设备不支持,把这几行删掉换成普通的loss.backward()和optimizer.step()即可,逻辑完全等价。

3.4 训练过程监控与 checkpoint 保存

评估函数单独写出来,因为它要在验证和最终测试两处复用:

@torch.no_grad() def evaluate(model, loader, criterion): model.eval() total_loss, correct, seen = 0.0, 0, 0 for imgs, labels in loader: imgs, labels = imgs.cuda(), labels.cuda() logits = model(imgs) loss = criterion(logits, labels) total_loss += loss.item() * imgs.size(0) correct += (logits.argmax(1) == labels).sum().item() seen += imgs.size(0) return total_loss / seen, correct / seen

model.eval()这一行不能少。它会把 BN 切换到用滑动平均统计量、把 Dropout 关掉。少了它,验证集的准确率会莫名其妙地比训练集还低或者剧烈波动,因为每张图都在按 batch 内的统计量做归一化,评估结果根本不可比。

checkpoint 保存我建议只存最好的那一版,按验证准确率判断:

best_acc = 0.0 if val_acc > best_acc: best_acc = val_acc torch.save({ "model": model.state_dict(), "classes": full_ds.classes, "epoch": epoch, "val_acc": val_acc, }, "best.pt")

把类别列表一起存进去很重要。推理的时候,输出的索引要靠这个列表映射回可读的类别名,忘了存就得回去翻原始数据结构,很容易对错顺序。我见过因为类别映射错位、导致上线后所有预测都“差一位”的情况,排查了很久才发现是标签顺序问题。

4. 训练效果不达预期时的排查手册

这部分是整篇里最值钱的内容。跑通代码不难,难的是结果不如预期时知道往哪查。我按“先数据后模型、先简单后复杂”的原则整理了一套排查顺序,基本覆盖了入门阶段能遇到的绝大多数问题。

4.1 先怀疑数据,再怀疑模型

模型不收敛的时候,九成人的第一反应是改网络结构、加层、换激活函数。我的经验是:先花二十分钟把数据验证一遍,能省下几个小时的瞎调。

第一个检查项:能不能过拟合一个小批量。从训练集里取 16 张图,让模型在这 16 张图上训练 200 轮,正常情况下 loss 应该降到接近 0、准确率到 100%。如果连这个小实验都做不到,那问题一定出在模型结构或者训练配置上,跟数据量、正则化都没关系,范围立刻缩小了。这个“过拟合小批量”的测试是所有深度学习调试里最高性价比的一招。

第二个检查项:标签和数据有没有错位。做法很简单,把 batch 里的前几张图存出来看一眼,对照标签是不是真的匹配。这个检查听起来很蠢,但我确实遇到过数据加载器读了两遍文件导致索引错位的情况,训练了三个小时 loss 一直在 2.3 附近不动,最后发现是这个问题。

第三个检查项:类别分布。打印一下每个类别的样本数,如果严重不均衡,看混淆矩阵时就会发现模型只会预测那几个大类。这时候不是模型的问题,是采样策略的问题。

4.2 典型故障现象与处置速查表

下面这张表是我自己总结的,遇到问题先对号入座,能覆盖大部分场景:

现象最可能的原因处置方式
loss 完全不动,停在初始值附近学习率过大导致发散;或标签索引越界学习率降到 1e-4 试;检查类别数配置
loss 缓慢下降但准确率不涨学习率过小;BN 未生效学习率升到 3e-3;确认模型处于 train 模式
训练准确率 100%,验证只有 50%过拟合;增强不足加强数据增强;加大 weight_decay;早停
训练和验证都卡在某个值(如 10%)数据或标签有问题;模型没学到东西做过拟合小批量测试;检查标签映射
loss 变成 NaN学习率太大;输入未归一化;除零降低学习率;检查归一化;加梯度裁剪
验证准确率剧烈震荡batch 太小导致 BN 统计不稳;验证忘了 eval增大 batch;确认调用 model.eval()
显存溢出batch 太大;计算图未释放减小 batch;把累计 loss 改成.item()

表里有一行值得单独展开:累计 loss 时忘了.item()。如果你写的是running_loss += loss,那这个 loss 张量还挂在计算图上,每一轮迭代都会把整条计算图保留下来,显存会线性增长直到爆掉。这个错误极其隐蔽,因为代码逻辑上没错,只是性能上灾难。改成loss.item()之后就断开了计算图,显存占用立刻稳定。

4.3 过拟合与欠拟合的分头处理

过拟合的典型表现是训练集准确率远高于验证集,且差距随训练进行持续扩大。处理手段按性价比排序:先加强数据增强,这是最直接的;再加权重衰减,从 1e-4 提到 5e-4 试;然后加 Dropout,在全连接层前插一个 p=0.3 的 Dropout;最后才是减小模型容量。顺序别搞反,一上来就砍模型是最浪费的做法。

欠拟合反过来,训练集本身就学不好。这时候增强反而要减弱,因为增强太强会让模型连训练样本都记不住。处理方法通常是:加大模型容量、延长训练轮数、提高学习率、去掉过于激进的正则。我遇到过有人因为把随机裁剪的 scale 设成 (0.3, 1.0),裁得只剩一小块,模型连完整物体都没见过,训练准确率一直上不去,还以为是模型太浅。

还有一种介于两者之间的状态:训练准确率很高、验证准确率也不错,但两者在训练后期同时下滑。这通常是学习率太大导致的,模型已经走到最优点附近,又被大步长推开了。这时候余弦退火或者阶梯式降学习率能救回来。

4.4 那些文档里不会写的实操心得

第一个心得:固定随机种子,但别指望完全复现。torch.manual_seed能固定大部分随机性,但数据加载的多进程、GPU 上的非确定性算子仍会带来微小差异。所以比较两次实验时,别拿小数点后第三位的差别当结论,看趋势。

第二个心得:先跑一个基线,再谈优化。花二十分钟把最简单的配置跑通,得到一个基准准确率(比如 72%),后面所有改动都和这个数比。没有基线,你改了半天也不知道自己是进步了还是退步了。我见过有人连续调了一周,最后发现最好的那次还不如第一天随手跑的基线。

第三个心得:别在同一份验证集上反复调参调到饱和。如果你在验证集上试了三十组配置,选出的那个“最优”其实已经过拟合到验证集了。稳妥做法是留一份从不参与调参的测试集,在最后评估一次,这个数字才更接近真实水平。

第四个心得:训练日志要记全。除了 loss 和准确率,把学习率、每轮耗时、显存峰值也记下来。等到你回头分析为什么某次实验特别慢、为什么某一轮突然发散时,这些记录就是唯一的线索。光看最后一行结果,很多信息永久丢失了。

5. 训练完成之后:评估、可视化与轻量落地

模型训完,best.pt躺在文件夹里,很多人到这就结束了。但一个完整的实战流程还得往下走两步:搞清楚模型到底在哪类样本上出错,以及把它塞进一个能实际运行的环境里。

5.1 不要只看准确率:混淆矩阵与错误样本

总体准确率是个高度压缩的指标,它会把“某一类全错、其他类全对”和“每类都错一点”这两种完全不同的情况压成同一个数字。混淆矩阵能把这层信息展开。

from sklearn.metrics import confusion_matrix, classification_report import numpy as np model.eval() preds, gts = [], [] with torch.no_grad(): for imgs, labels in val_loader: logits = model(imgs.cuda()) preds.extend(logits.argmax(1).cpu().numpy()) gts.extend(labels.numpy()) cm = confusion_matrix(gts, preds) print(classification_report(gts, preds, target_names=full_ds.classes, digits=3))

看混淆矩阵的时候,重点关注非对角线上数值最大的那几对。它们告诉你模型把哪两类搞混了。这时候别急着调模型,先去看这些被搞混的样本长什么样。十有八九你会发现两类本身就长得像,或者标注标准不统一,或者某一类的样本里混进了另一类的图。这类问题改模型是解决不了的,得回去修数据。

分类报告里的每类召回率也值得看。如果某一类召回明显偏低,说明模型对这一类的特征学得不够。常见原因是这类样本数量少,或者类内差异特别大(同一类里有好几种完全不同的外观)。前者靠重采样解决,后者得考虑是不是该把这个类拆开。

5.2 卷积核与特征图可视化怎么做才有意义

第一层的卷积核可以直接可视化,因为它们作用在原始像素上,形状还有直观意义。后面层的核作用在抽象特征上,画出来基本是一团噪声,看了也没用。

import matplotlib.pyplot as plt w = model.features[0][0].weight.data.cpu() # [32, 3, 3, 3] w = (w - w.min()) / (w.max() - w.min()) fig, axes = plt.subplots(4, 8, figsize=(10, 5)) for i, ax in enumerate(axes.flat): ax.imshow(w[i].permute(1, 2, 0)) ax.axis("off") plt.tight_layout() plt.show()

第一层的核通常呈现为边缘检测器、颜色对比检测器这类基础滤波器。看到这个你会对“CNN 从底层特征开始学”有非常具体的感受,比看一百张原理图都管用。

真正有诊断价值的是特征图可视化:拿一张验证集里的图喂进去,把某一层的输出通道逐个画出来,看模型在这张图上关注了什么。如果发现中间层有大片通道全黑(也就是 ReLU 之后全为负被清零),说明这一层可能有大量神经元死亡,需要检查学习率是不是太大。

比这更进一步的是类激活图。因为我们的模型用了 GAP,实现起来特别简单:取最后一个卷积层的输出,用全连接层的权重做加权求和,就得到一张热力图,标出模型判断时最关注的区域。如果热力图集中在背景上而不是物体上,说明模型学到了错误的关联——这在数据有偏的时候非常常见,比如某一类的图片背景总是同一个颜色。

5.3 模型瘦身与推理落地

29 万参数的模型本身已经很小了,但部署时还有几件事值得做。

第一件是导出为推理图。训练好的模型带着 BN 层和各种训练专用结构,导出时可以做算子融合,把“卷积 + BN”合并成一个卷积,减少计算量。导出后模型体积通常还能再小一点,推理速度也会提升。

第二件是量化。把权重从 32 位浮点压到 8 位整数,模型体积降到四分之一,在 CPU 上的推理速度能提升两到三倍。代价是精度会有小幅下降,通常在一个百分点以内。做法分训练后量化和量化感知训练两种,入门阶段先试训练后量化,用一小批样本做校准就行。

第三件是写一个最小推理脚本,把“读图—预处理—前向—取 top-k—映射类别名”这条链路跑通。这一步最容易被跳过,但它是检验模型能不能真正用起来的唯一标准。我建议这个脚本要包含完整的预处理,因为训练和推理的预处理必须严格一致——少做一次归一化,或者 Resize 和 CenterCrop 的顺序反了,输出就会离谱到让人怀疑模型坏了。

注意:推理时的预处理要和验证集完全对齐。我习惯把验证集的 transform 抽成一个函数,训练和推理共用同一份代码,从源头上杜绝不一致。

6. 关于这套流程后续能怎么扩展

这套流程跑顺之后,往上加的东西就很多了。想提精度,可以把双卷积块换成残差连接,解决加深之后的梯度衰减;想提速,可以试试深度可分离卷积,用通道分离的方式把计算量砍掉一大截;想处理更大的图,可以引入空洞卷积扩大感受野而不降分辨率。

数据侧也有不少可做的:混切增强、随机擦除、自动增强策略搜索,这些在中小数据集上通常能带来两三个点的提升。损失函数侧可以试焦点损失处理难样本,或者用对比学习的方式做预训练。

但我想说的是,这些扩展都不着急。先把这一套跑通、跑明白、能解释清楚每一个数字的来龙去脉,比急着堆新模块重要得多。我在实际带人的过程中发现,那些一开始就追求“用最新结构”的人,往往在遇到 loss 不降的时候束手无策,因为他们不理解自己抄来的每个组件在干什么。反而是老老实实把基础结构手写一遍、手算一遍参数量的人,后面换任何新结构都能快速上手,因为他们有判断力。

最后分享一个我自己养成的习惯:每次实验前,先在一张纸上写下“这次我要验证什么、预期结果是什么、如果不对我下一步查什么”。写下来这三行,实验效率会明显不一样,因为你知道自己在找什么,而不是盲目地改参数然后盯着屏幕等奇迹。这个方法跟 CNN 没关系,但对我帮助最大的恰恰是它。

RELATED READING

延伸阅读

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