1. 从全连接到卷积:为什么图像任务需要换一套骨架
很多人学完全连接网络之后,第一次接触卷积神经网络(CNN)都会有一个疑问:既然全连接层理论上能拟合任意函数,为什么还要费劲设计卷积这套东西?我当初也是这么想的,直到自己拿全连接网络去跑一张稍微大一点的图片,才发现问题根本不是"能不能拟合",而是"根本跑不动"。
假设你有一张 256×256 的彩色图片,展平之后就是 256×256×3 ≈ 19.6 万个输入维度。如果第一层隐藏层只放 1000 个神经元,光这一层的参数量就是 19.6 万 × 1000 ≈ 1.96 亿个参数。这还只是第一层,模型稍微深一点,参数量直接爆炸。更致命的是,全连接层把每个像素都当成独立特征,图片整体平移几个像素,输出就完全变了,这对图像识别来说显然不合理——猫往左挪一点,它还是猫。
卷积神经网络解决的就是这两个痛点。它靠两个核心思想:局部连接和权值共享。局部连接是说每个神经元只看输入图片的一小块区域(比如 3×3),而不是全图;权值共享是说同一个卷积核在整张图上滑动,用的都是同一组参数。这样一来,参数量从"像素数×神经元数"直接降到"卷积核大小×通道数",而且天然具备平移不变性。
我习惯用一个类比来解释:全连接层像是让每个学生把整本书背下来再回答问题,卷积层则像是给每个学生发一个放大镜,只负责看局部细节,然后大家把看到的东西汇总起来。显然后者更高效,也更符合图像本身的规律。
这一章先把 CNN 的动机讲透,后面几章再逐步拆解卷积、池化、通道、感受野这些具体概念,最后落到 PyTorch 的实际代码上。如果你之前只写过全连接网络,跟着走完这一套,基本能把 CNN 的来龙去脉理清楚。
1.1 图像数据的三个天然属性
要理解 CNN 为什么这么设计,得先搞清楚图像数据本身有什么特点。我总结下来是三条:局部相关性、平移不变性、多尺度结构。
局部相关性指的是,图片里一个像素和它周围像素的关系,远比和远处像素的关系紧密。一条边缘、一个角点,都是局部现象。所以卷积核只覆盖一个小窗口是合理的,没必要让每个神经元都去看全图。
平移不变性指的是,同一个物体出现在图片的不同位置,它的特征模式是一样的。一只猫在左上角和在右下角,检测猫耳朵的那个卷积核都应该能识别出来。权值共享正好实现了这一点——同一个核在整张图上滑动,参数不变。
多尺度结构指的是,图像既有细粒度的纹理(比如毛发),也有粗粒度的形状(比如整体轮廓)。这就解释了为什么 CNN 要堆很多层,还要用池化来降采样——浅层抓细节,深层抓语义。
这三条属性不是凭空总结的,它们直接决定了 CNN 的每一个设计选择。理解了它们,后面看到 stride、padding、pooling 这些概念时,你就知道它们各自在解决什么问题,而不是死记参数。
1.2 全连接层的参数量到底有多夸张
我用一个具体计算让你直观感受一下。假设输入是 224×224×3 的图片(这是很多经典网络的输入尺寸),展平后是 150528 维。如果接一个 4096 维的全连接层,参数量是:
150528 × 4096 ≈ 6.17 亿
这还只是第一层。而一个 3×3 卷积核、输入 3 通道、输出 64 通道的卷积层,参数量是:
3 × 3 × 3 × 64 + 64(偏置)≈ 1792
两者差了三十多万倍。这就是为什么图像任务几乎不会用全连接层直接处理原始像素。全连接层在 CNN 里通常只出现在最后几层,负责把卷积提取的特征映射到类别上。
提示:算参数量的时候别忘了偏置项。卷积层的偏置数量等于输出通道数,全连接层的偏置数量等于输出神经元数。虽然偏置占比很小,但做严谨对比时不能漏。
2. 卷积运算的四个关键参数:kernel、stride、padding、channel
卷积这个词听起来玄乎,其实本质就是一个滑动窗口做加权求和。但真正写代码的时候,你会发现 PyTorch 的nn.Conv2d有一堆参数,每个都影响输出尺寸和计算量。这一章我把这四个最关键的参数逐个拆开讲,并且给出输出尺寸的计算公式,让你不用再靠试错去猜。
2.1 卷积核大小与感受野的关系
卷积核(kernel)决定了每次看多大范围。常见的是 3×3、5×5、7×7。核越大,单层能看到的范围越大,但参数量也按平方增长。一个 7×7 核的参数量是 3×3 核的约 5.4 倍。
这里有个经验:堆两个 3×3 卷积,感受野等于一个 5×5;堆三个 3×3,感受野等于一个 7×7。但参数量呢?三个 3×3 是 3×3×3=27(忽略通道),一个 7×7 是 49。所以现代网络几乎都用小核堆叠,既省参数又增加了非线性层数。VGG 就是靠这个思路把网络做深的。
感受野的计算公式是:第 k 层的感受野 = 前一层感受野 + (kernel_size - 1) × 前面所有层的 stride 乘积。这个公式在做目标检测、语义分割时特别重要,因为你要知道深层特征到底对应原图多大区域。
2.2 stride 与 padding 如何共同决定输出尺寸
stride 是滑动步长,padding 是边缘补零的圈数。这两个参数直接决定输出特征图的大小。通用公式是:
输出尺寸 = floor((输入尺寸 + 2 × padding - kernel_size) / stride) + 1
我拿几个常见组合算给你看。输入 32×32,kernel=3,stride=1,padding=1:
(32 + 2 - 3) / 1 + 1 = 32
输出还是 32×32,这叫"same"卷积,尺寸不变。如果 padding=0:
(32 - 3) / 1 + 1 = 30
输出变成 30×30,每卷一次就小一圈。如果 stride=2,padding=1:
(32 + 2 - 3) / 2 + 1 = 16
输出直接减半,这就是用 stride 做降采样的方式。
padding 的作用不只是控制尺寸,它还影响边缘信息的利用。不加 padding 的话,图片边缘的像素被卷积核覆盖的次数远少于中心像素,边缘信息会被"稀释"。所以大多数卷积层默认 padding = kernel_size // 2,保证输出尺寸和输入一致。
2.3 输入输出通道与卷积核的形状
通道(channel)是 CNN 里最容易搞混的概念之一。输入图片如果是 RGB,输入通道就是 3;中间特征图的通道数由卷积核数量决定。关键在于:每个输出通道都有一个独立的卷积核,而这个核的深度等于输入通道数。
举个例子,输入是 64 通道,输出要 128 通道,kernel=3。那么卷积核的形状是 128 × 64 × 3 × 3。也就是说,有 128 个核,每个核是 64×3×3。计算时,每个输出通道是把所有输入通道的卷积结果加起来。这就是为什么通道数增加时参数量增长很快。
我见过不少初学者以为"输出通道数就是卷积核数量"这句话就够了,但真正写自定义卷积或者做通道剪枝时,必须清楚核的完整形状,否则维度对不上。
| 参数 | 作用 | 常见取值 | 对输出尺寸的影响 |
|---|---|---|---|
| kernel_size | 每次覆盖范围 | 3、5、7 | 核越大输出越小 |
| stride | 滑动步长 | 1、2 | 步长越大输出越小 |
| padding | 边缘补零 | 0、1、2 | 补零越多输出越大 |
| out_channels | 输出通道数 | 32、64、128 | 不影响空间尺寸 |
2.4 用 PyTorch 验证输出尺寸
光看公式容易记混,我习惯直接在 PyTorch 里跑一遍验证。下面这段代码你可以直接复制运行:
import torch import torch.nn as nn # 输入:batch=1, 通道=3, 高=32, 宽=32 x = torch.randn(1, 3, 32, 32) # 卷积:输入3通道,输出16通道,3x3核,stride=1,padding=1 conv = nn.Conv2d(in_channels=3, out_channels=16, kernel_size=3, stride=1, padding=1) y = conv(x) print(y.shape) # torch.Size([1, 16, 32, 32]) # 换成 stride=2, padding=0 conv2 = nn.Conv2d(3, 16, kernel_size=3, stride=2, padding=0) y2 = conv2(x) print(y2.shape) # torch.Size([1, 16, 15, 15])第二组输出是 15×15,因为 (32 - 3) / 2 + 1 = 15.5,向下取整是 15。这个"向下取整"经常被忽略,导致拼接特征图时尺寸对不上。我在做 U-Net 类结构时就踩过这个坑,编码器降采样再解码器上采样,尺寸差一个像素,torch.cat直接报错。解决办法是要么调整 padding,要么在拼接前做一次裁剪对齐。
注意:当 (输入 + 2×padding - kernel) 不能被 stride 整除时,PyTorch 默认向下取整。如果你需要精确控制输出尺寸,最好自己算一遍,别依赖默认行为。
3. 池化层与激活函数:CNN 里容易被低估的两个角色
卷积层是主角,但池化和激活函数这两个配角如果理解不到位,模型效果会差很多。这一章我分别讲它们的作用、常见类型,以及实际使用中的取舍。
3.1 最大池化与平均池化的选择逻辑
池化(pooling)的核心作用是降采样,减少空间尺寸和计算量,同时扩大感受野。最常见的是最大池化(Max Pooling)和平均池化(Average Pooling)。
最大池化取窗口内的最大值,保留最显著的特征。它在图像任务里用得最多,因为"某个特征出现了"这个信息比"特征的平均强度"更重要。比如检测一条边缘,只要窗口里有一个强响应就够了,不需要平均。
平均池化取窗口内的平均值,对噪声更鲁棒,但会削弱强特征。它在一些需要平滑特征的场景里还有用,比如早期的网络或者某些回归任务。
我个人的经验是:分类任务优先用最大池化,全局平均池化(Global Average Pooling)用在网络最后替代全连接层。全局平均池化是把每个通道的整张特征图取平均,输出一个向量,参数量为零,还能有效防止过拟合。ResNet 之后就基本成了标配。
池化窗口通常是 2×2,stride=2,这样空间尺寸正好减半。也有用 3×3、stride=2 的,输出尺寸是 floor((n-3)/2)+1,会稍微小一点。
3.2 ReLU 及其变体为什么成为默认选择
激活函数给网络引入非线性,没有它,再多卷积层堆起来也等价于一个线性变换。早期用 sigmoid 和 tanh,但它们有个致命问题:梯度饱和。输入稍微大一点,梯度就接近零,深层网络根本训不动。
ReLU(Rectified Linear Unit)解决了这个问题。它的定义极简单:f(x) = max(0, x)。正区间梯度恒为 1,不存在饱和;计算也快,就是一个阈值判断。但它也有缺点:负区间梯度为零,神经元可能"死亡",一旦输出恒为负,这个神经元就再也不更新了。
针对这个问题出现了几个变体。LeakyReLU 给负区间一个小斜率(比如 0.01),避免完全死亡。ELU 在负区间用指数函数,输出更接近零均值。GELU 是近年来 Transformer 和很多新网络用的,形式是 x 乘以标准正态分布的累积分布函数,平滑性更好。
实际选型上,普通 CNN 用 ReLU 就够了,性价比最高。如果发现训练中大量神经元不激活,可以换 LeakyReLU 试试。GELU 计算稍贵,但在大模型里表现更稳。
3.3 批归一化在卷积网络中的位置
批归一化(Batch Normalization,BN)虽然不算卷积或池化,但在现代 CNN 里几乎是必选项,所以放在这里一起讲。它的作用是对每个 mini-batch 的激活值做标准化,让分布稳定在均值 0、方差 1 附近,然后再用两个可学习参数做缩放和平移。
BN 带来的好处很实在:允许更大的学习率、加快收敛、有一定正则化效果。位置一般是"卷积 → BN → 激活",这个顺序在大多数网络里都是默认的。
不过 BN 对 batch size 敏感。batch 太小的时候,统计量估计不准,效果会打折。这时候可以考虑 GroupNorm 或 LayerNorm,它们不依赖 batch 维度。我在小 batch 训练分割模型时就换过 GroupNorm,稳定性明显好于 BN。
import torch.nn as nn block = nn.Sequential( nn.Conv2d(64, 128, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2) )注意这里卷积的bias=False,因为 BN 自带平移参数,再加偏置是冗余的。这个小细节能省一点参数,也是很多开源实现的惯例。
4. 用 PyTorch 搭一个完整的 CNN:从数据到训练
前面讲的都是零件,这一章把它们组装起来,写一个能真正跑起来的完整流程。我以一个 10 类分类任务为例,输入是 3×32×32 的图片,结构参考经典的小型 CNN。
4.1 网络结构设计与维度推演
先设计结构,我习惯在纸上把每层的输入输出维度写清楚,避免后面出错。
- 输入:3×32×32
- Conv1:3→32,3×3,padding=1 → 32×32×32
- MaxPool:2×2 → 32×16×16
- Conv2:32→64,3×3,padding=1 → 64×16×16
- MaxPool:2×2 → 64×8×8
- Conv3:64→128,3×3,padding=1 → 128×8×8
- MaxPool:2×2 → 128×4×4
- 全局平均池化 → 128
- 全连接:128→10
这个结构参数量不大,训练快,适合入门练手。关键是每一步的维度都能对上,最后展平或者全局池化之后接分类头。
import torch import torch.nn as nn class SmallCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, 3, padding=1, bias=False), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1, bias=False), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1, bias=False), nn.BatchNorm2d(128), nn.ReLU(inplace=True), 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) model = SmallCNN() print(sum(p.numel() for p in model.parameters()))AdaptiveAvgPool2d(1)的好处是不管前面特征图多大,输出都是 1×1,这样网络对输入尺寸就不那么敏感了。相比直接view展平,它更灵活,也不容易因为尺寸算错而报错。
4.2 数据加载与增强的实操细节
数据管道这块,我用torchvision的标准流程。训练集做增强,验证集只做标准化,这是基本原则——验证集不能引入随机性,否则评估结果不稳定。
from torchvision import transforms, datasets from torch.utils.data import DataLoader train_tf = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)), ]) val_tf = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)), ]) train_ds = datasets.CIFAR10(root='./data', train=True, download=True, transform=train_tf) val_ds = datasets.CIFAR10(root='./data', train=False, download=True, transform=val_tf) train_loader = DataLoader(train_ds, batch_size=128, shuffle=True, num_workers=4) val_loader = DataLoader(val_ds, batch_size=256, shuffle=False, num_workers=4)RandomCrop(32, padding=4)是先补 4 圈零再随机裁回 32×32,相当于做了平移增强。RandomHorizontalFlip做水平翻转。这两个是最基础也最有效的增强,几乎不增加计算成本。
num_workers设成 4 还是 0,取决于你的机器。Windows 上有时多进程会出问题,设 0 最稳但慢。Linux 上一般设成 CPU 核心数的一半左右比较合适。
4.3 训练循环与学习率调度
训练循环本身不复杂,但有几个细节决定成败。优化器我用 AdamW,学习率 1e-3,权重衰减 1e-4。损失函数是交叉熵。
import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = SmallCNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=30) for epoch in range(30): model.train() for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(imgs) loss = criterion(outputs, labels) loss.backward() optimizer.step() scheduler.step() model.eval() correct, total = 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.to(device), labels.to(device) outputs = model(imgs) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) print(f"Epoch {epoch+1}, Val Acc: {correct/total:.4f}")余弦退火让学习率从 1e-3 平滑降到接近 0,通常比固定学习率效果好。model.eval()和torch.no_grad()在验证时一定要加,前者影响 BN 和 Dropout 的行为,后者省显存。
我实测下来,这个结构在 CIFAR-10 上跑 30 轮,验证准确率能到 75% 左右。想再高就得加深网络或者用更强的增强,但作为入门示例已经够用了。
5. 训练中常见的坑与排查思路
代码能跑通不代表没问题。这一章我列几个自己踩过或者见别人踩过的坑,给出排查方向。这些内容在教科书里通常不会写,但实际做项目时几乎都会遇到。
5.1 损失不下降的几种典型原因
损失不动是最常见的问题。我一般按这个顺序排查:
第一,检查学习率。太大导致震荡甚至发散,太小导致几乎不动。可以先用 1e-3 试,不行再调。有个简单办法是跑几个 batch,看 loss 有没有变化。
第二,检查数据标签是否对齐。我遇到过一次,数据增强里做了随机裁剪,但标签没跟着变,结果模型在学噪声。这种问题很隐蔽,建议可视化几个 batch 的图片和标签确认。
第三,检查 BN 的位置和 momentum。BN 放在激活前面还是后面,效果不一样。另外如果 batch size 太小,BN 统计量不准,也会导致训练不稳。
第四,检查是否有梯度消失或爆炸。可以打印每层的梯度范数,如果深层梯度接近零,考虑加残差连接或者换激活函数。
5.2 过拟合与欠拟合的判断与应对
判断过拟合很简单:训练准确率远高于验证准确率,且验证准确率开始下降。应对手段按性价比排序是:数据增强、Dropout、权重衰减、早停、减小模型。
欠拟合则是训练和验证都上不去。这时候要加模型容量、加训练轮数、检查学习率是否太小、检查特征是否足够。
我个人的习惯是先把模型训到过拟合,再逐步加正则化压下来。这样你能确定模型容量够,问题只是泛化,而不是根本没学会。
5.3 显存不足时的取舍策略
显存不够是绕不开的问题。几个实用手段:
- 减小 batch size,配合梯度累积模拟大 batch
- 用混合精度训练(
torch.cuda.amp),显存能省接近一半 - 减少模型宽度或深度
- 用梯度检查点(gradient checkpointing),用计算换显存
梯度累积的写法是在多个 batch 上累加梯度,达到目标后再step一次:
accum_steps = 4 optimizer.zero_grad() for i, (imgs, labels) in enumerate(train_loader): outputs = model(imgs.to(device)) loss = criterion(outputs, labels.to(device)) / accum_steps loss.backward() if (i + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad()注意 loss 要除以累积步数,否则梯度会放大。这个细节很容易漏,导致等效学习率变大。
5.4 验证集准确率波动的排查
验证准确率忽高忽低,通常有几个原因。一是验证集太小,统计噪声大;二是 BN 在验证时的 running mean 还没稳定;三是学习率太大导致模型在最优解附近震荡。
我的做法是:验证集至少几千张,BN 的 momentum 设 0.1 左右,学习率后期用调度器降下来。如果还波动,就多跑几个 epoch 取平均,或者用 EMA(指数移动平均)保存权重。
提示:EMA 是个很实用的技巧,保存一份模型参数的滑动平均版本用于验证,通常比直接用当前参数更稳。实现上就是每次更新后
ema_param = decay * ema_param + (1-decay) * param,decay 取 0.999 左右。
6. 从入门结构到现代 CNN 的演进线索
把基础打牢之后,你会发现现代网络的各种设计其实都能追溯到几个核心思路。这一章我梳理几条演进线索,帮你把知识串起来,而不是孤立地记一堆网络名字。
6.1 深度增加带来的问题与残差连接
网络越深,表达能力越强,但训练越难。这不是过拟合,而是退化问题——深层网络的训练误差反而比浅层高。残差连接(Residual Connection)解决了这个问题,它让信息可以跨层直接传递,梯度也能更顺畅地回传。
残差块的形式是y = F(x) + x,其中 F 是几层卷积。如果 F 学到的是一些微小修正,整个块就接近恒等映射,深层网络至少不会比浅层差。这个思路后来被广泛应用,几乎成了深层网络的标配。
6.2 多尺度特征融合的思路
图像里的目标有大有小,单一尺度的特征不够用。多尺度融合的思路是同时利用不同深度的特征图:浅层分辨率高、细节多,深层语义强、分辨率低。把两者结合起来,既能定位又能分类。
具体实现上有几种:一是特征金字塔(FPN),自顶向下上采样再和浅层相加;二是空洞卷积(Dilated Convolution),在不降分辨率的情况下扩大感受野;三是 Inception 式的多分支并行,用不同大小的卷积核同时提取特征。
6.3 轻量化与注意力机制的取舍
移动端和边缘设备对模型大小和速度有要求,于是出现了轻量化设计。深度可分离卷积(Depthwise Separable Convolution)把标准卷积拆成逐通道卷积和逐点卷积,参数量和计算量都大幅下降。MobileNet 系列就是靠这个思路做起来的。
另一条线是注意力机制。通道注意力(如 SE 模块)学习每个通道的重要性权重,空间注意力学习每个位置的重要性。它们通常作为插件嵌入现有网络,增加少量参数就能提升效果。
实际选型时,如果算力充足、追求精度,可以用带注意力的较深网络;如果部署在资源受限设备上,轻量化结构更合适。没有绝对的好坏,只有场景匹配。
| 设计思路 | 代表结构 | 主要解决的问题 | 代价 |
|---|---|---|---|
| 残差连接 | ResNet | 深层网络退化 | 少量显存 |
| 多尺度融合 | FPN、Inception | 目标尺度差异 | 结构复杂 |
| 深度可分离卷积 | MobileNet | 参数量和计算量 | 精度略降 |
| 通道注意力 | SE 模块 | 通道重要性差异 | 少量参数 |
我在实际项目里的体会是,先把基础 CNN 训明白,再根据具体瓶颈去选进阶结构。很多时候,数据质量和增强策略带来的提升,比换一个更复杂的网络更明显。与其一上来就堆最新结构,不如先把数据管道和训练流程打磨扎实,这部分收益往往被低估。