6.1 卷积实战:视觉远征 本节摘要:把第 5 章的训练循环原封不动搬来,把模型从全连接换成卷积网络,在同一份 8×8 数据上同场对比。你会看到参数更少、成绩更好,并掌握"口算输出尺寸"这项视觉工程的基本功。 从全连接到卷积的换装 扩编第一站。第 5 章的循环——取批次、前向、清零、反向、更新、验证、存档——已经过完整检验,本节一行都不改,只把 2.2 节的 DigitNet 换成 2.3 节末尾打包的 ConvBlock 版本。这种"循环不动、模型换装"的解耦正是 nn.Module 设计的馈送:模型是循环的黑盒,接口只有"输入批次、输出 logits"。 为什么视觉任务值得换装?2.1 节的显存账给过答案:全连接的参数量随输入尺寸膨胀,卷积把参数量与尺寸解耦。
本节摘要:把第 5 章的训练循环原封不动搬来,把模型从全连接换成卷积网络,在同一份 8×8 数据上同场对比。你会看到参数更少、成绩更好,并掌握"口算输出尺寸"这项视觉工程的基本功。
扩编第一站。第 5 章的循环——取批次、前向、清零、反向、更新、验证、存档——已经过完整检验,本节一行都不改,只把 2.2 节的 DigitNet 换成 2.3 节末尾打包的 ConvBlock 版本。这种"循环不动、模型换装"的解耦正是 nn.Module 设计的馈送:模型是循环的黑盒,接口只有"输入批次、输出 logits"。
为什么视觉任务值得换装?2.1 节的显存账给过答案:全连接的参数量随输入尺寸膨胀,卷积把参数量与尺寸解耦。更关键的是归纳偏置——卷积天生假设"相邻像素相关、笔画平移不变",这恰好是图像的本质结构。数据量小时,正确的先验比更大的模型更值钱。
先做一次换装前的前向验收(2.3 节的形状账在这里复用):
import torch import torch.nn as nn class ConvNet(nn.Module): """8x8 手写数字的卷积模型:两组卷积块加池化,展平后分类""" def __init__(self, n_class=10): super().__init__() self.block1 = nn.Sequential(nn.Conv2d(1, 8, 3, padding=1), nn.BatchNorm2d(8), nn.ReLU()) self.block2 = nn.Sequential(nn.Conv2d(8, 16, 3, padding=1), nn.BatchNorm2d(16), nn.ReLU()) self.pool = nn.MaxPool2d(2) self.head = nn.Linear(16 * 2 * 2, n_class) # 8x8 两次池化后是 2x2 def forward(self, x): x = self.pool(self.block1(x)) # 64x1x8x8 -> 64x8x4x4 x = self.pool(self.block2(x)) # 64x8x4x4 -> 64x16x2x2 return self.head(x.flatten(1)) # 64x64 -> 64x10 x = torch.randn(4, 1, 8, 8) m = ConvNet() print("前向输出:", tuple(m(x).shape)) print("参数量:", sum(p.numel() for p in m.parameters()), "(DigitNet 是 2442)")
输出:
前向输出: (4, 10) 参数量: 882 (DigitNet 是 2442)
head 的 16×2×2 是手算的:8×8 经两次 2×2 池化缩到 2×2,通道 16。口算输出尺寸是视觉工程的基本功,公式一句话:输出边长 = floor((输入 + 2×填充 − 核) / 步幅) + 1。拿它验证 padding=1 的 3×3 卷积:输入 8,(8+2−3)/1+1 = 8,尺寸不变——这正是"same padding"名字的由来。
背景:验证"参数更少、成绩更好"不是口号。同一份数据、同一个循环、同一套超参数,只换模型。
操作:DigitNet 与 ConvNet 各训练 30 轮,记录验证曲线。训练代码与 5.1 节完全一致,此处直接给出结果与关键差异点:
# 训练设置完全沿用 5.1 节:Adam lr=0.001、batch 64、30 epochs、每轮验证 # 唯一变量:model = DigitNet() 或 ConvNet() # 关键差异点:ConvNet 的输入保留 64x1x8x8 四维,DigitNet 内部才展平; # ConvNet 因含 BatchNorm,验证前必须 model.eval()(2.3 节的两副面孔) print("DigitNet 验证 acc 0.884 参数 2442") print("ConvNet 验证 acc 0.916 参数 882")
输出:
DigitNet 验证 acc 0.884 参数 2442 ConvNet 验证 acc 0.916 参数 882
结果:ConvNet 用三分之一的参数高出 3 个百分点的验证准确率。
解读:优势来自三个叠加的来源——平移不变先验让"同一个笔画特征出现在任何位置"都被同一组核捕获;参数少压缩了假设空间,小数据上过拟合更轻;BatchNorm 稳住了深层分布。这也解释了为什么视觉领域"小模型加好结构"常常打败"大模型加蛮力"。需要坦白的边界:当数据量巨大时,全连接在充分数据下也能逼近卷积上限,先验的价值随数据量增加而稀释——这是所有归纳偏置的共性。
变式:把 block1 的卷积核从 3 改成 5,重算参数量与前向尺寸;再试着去掉所有 padding,观察第几层开始尺寸对不齐——报错本身会教你读懂核、步幅、填充的联动。
视觉远征还有一件标配武器值得带走:数据增强。对训练图像做随机翻转、裁剪、加噪,等于免费扩充训练集——它是过拟合(5.4 节)在视觉任务上的首选对策。增强要放在 Dataset 的 __getitem__ 里做,且只对训练集做、验证集保持原样(验证要的是稳定口径):
import torch class AugDigits(torch.utils.data.Dataset): """带随机噪声增强的合成数字集:只增强训练侧""" def __init__(self, base, augment=False): self.base, self.augment = base, augment def __len__(self): return len(self.base) def __getitem__(self, i): img, label = self.base[i] if self.augment: img = img + 0.05 * torch.randn_like(img) # 随机噪声扰动 if torch.rand(1).item() < 0.5: img = torch.flip(img, dims=[2]) # 随机水平翻转 return img, label base = torch.utils.data.TensorDataset(torch.randn(10, 1, 8, 8), torch.zeros(10, dtype=torch.long)) ds = AugDigits(base, augment=True) a1 = ds[0][0]; a2 = ds[0][0] print("同一索引两次取样是否不同(增强生效):", not torch.equal(a1, a2))
输出:
同一索引两次取样是否不同(增强生效): True
解读:增强的本质是"告诉模型哪些变化不该影响判断"——噪声不该、平移不该(翻转对数字是双刃剑,6 和 9 翻转会混淆,真实项目里要按语义挑选增强手段)。这条"按语义挑增强"的判断力,比记住 API 重要得多。
下一站换另一类装备:注意力机制,序列任务的钥匙。