卷积神经网络入门:从卷积操作到图像识别

5 阅读19分钟

历史背景

卷积的概念来自 1980 年福岛邦彦的 Neocognitron(新认知机),灵感来自 Hubel 和 Wiesel 对猫视觉皮层的研究——神经元只对局部区域和特定方向的边缘有响应。

福岛邦彦模仿视觉皮层设计了两种细胞:

  • S 细胞(Simple):类似卷积层,用一组权重检测局部特征(比如某个方向的边缘)
  • C 细胞(Complex):类似池化层,对 S 细胞的输出做"最大值"操作,提供平移不变性

这和现代 CNN 的 Conv + Pool 结构几乎一致。1989 年 LeCun 第一次将反向传播和卷积结合,用于识别手写邮政编码。1998 年的 LeNet-5 在 MNIST 上达到 99%+ 的准确率,并被美国银行用于识别支票上的手写金额。

2012 年的 AlexNet 以 15.3% 的 top-5 错误率(第二名 26.2%)赢得了 ImageNet 大赛,标志着 CNN 真正成为计算机视觉的主导方法。

LeNet (1998)     → 手写数字识别,卷积首次成功应用
AlexNet (2012)   → 深层 CNN + GPU 训练,ImageNet 冠军,引爆深度学习革命
VGG (2014)       → 全 3×3 小卷积核堆叠的深层 CNN
ResNet (2015)    → 残差连接,152 层 CNN
MobileNet (2017) → 深度可分离卷积,移动端部署
EfficientNet     → 复合缩放,效率和精度的最优平衡

下面从最基础的"什么是卷积"开始,一步步拆解 CNN 的核心概念。

什么是卷积

卷积就是用一个"小窗口"在图像上滑动扫描,每次窗口覆盖的区域做一次加权求和。这个小窗口就是卷积核(也叫滤波器),它的滑动过程就是滑动窗口操作。

滑动窗口是怎么工作的

想象你拿一个放大镜在图片上从左到右、从上到下移动,放大镜能看到的区域就是"窗口":

输入图像 (4x4):       卷积核 (3x3):       输出 (2x2):
 1  2  3  4            1  0 -1              ?   ?
 5  6  7  8            1  0 -1              ?   ?
 9 10 11 12            1  0 -1
13 14 15 16

滑动过程:

第1步: 窗口停在左上角             第2步: 向右滑一格
┌───┬───┬───┬───┐              ┌───┬───┬───┬───┐
│ █ │ █ │ █ │   │              │   │ █ │ █ │ █ │
├───┼───┼───┼───┤              ├───┼───┼───┼───┤
│ █ │ █ │ █ │   │              │   │ █ │ █ │ █ │
├───┼───┼───┼───┤              ├───┼───┼───┼───┤
│ █ │ █ │ █ │   │              │   │ █ │ █ │ █ │
├───┼───┼───┼───┤              ├───┼───┼───┼───┤
│   │   │   │   │              │   │   │   │   │
└───┴───┴───┴───┘              └───┴───┴───┴───┘

第3步: 向下滑一格                第4步: 再向右滑一格
┌───┬───┬───┬───┐              ┌───┬───┬───┬───┐
│   │   │   │   │              │   │   │   │   │
├───┼───┼───┼───┤              ├───┼───┼───┼───┤
│ █ │ █ │ █ │   │              │   │ █ │ █ │ █ │
├───┼───┼───┼───┤              ├───┼───┼───┼───┤
│ █ │ █ │ █ │   │              │   │ █ │ █ │ █ │
├───┼───┼───┼───┤              ├───┼───┼───┼───┤
│ █ │ █ │ █ │   │              │   │ █ │ █ │ █ │
└───┴───┴───┴───┘              └───┴───┴───┴───┘

每个位置算一个数,拼起来就是输出。

完整计算过程

第一步:窗口放在左上角

图像区域:               卷积核:
 1  2  3               1  0 -1
 5  6  7          ×    1  0 -1
 9 10 11               1  0 -1

对应位置相乘再求和:
= 1×1 + 2×0 + 3×(-1) + 5×1 + 6×0 + 7×(-1) + 9×1 + 10×0 + 11×(-1)
= 1 + 0 - 3 + 5 + 0 - 7 + 9 + 0 - 11
= -6

输出(0,0) = -6

第二步:向右滑一格

图像区域:               卷积核:
 2  3  4               1  0 -1
 6  7  8          ×    1  0 -1
10 11 12               1  0 -1

= 2×1 + 3×0 + 4×(-1) + 6×1 + 7×0 + 8×(-1) + 10×1 + 11×0 + 12×(-1)
= 2 - 4 + 6 - 8 + 10 - 12
= -6

输出(0,1) = -6

依次类推,最终输出:

[-6  -6]
[-6  -6]

用 PyTorch 验证:

import torch
import torch.nn.functional as F

img = torch.tensor([
    [ 1,  2,  3,  4],
    [ 5,  6,  7,  8],
    [ 9, 10, 11, 12],
    [13, 14, 15, 16]
]).float().unsqueeze(0).unsqueeze(0)  # (1, 1, 4, 4)

kernel = torch.tensor([
    [[ 1,  0, -1],
     [ 1,  0, -1],
     [ 1,  0, -1]]
]).float().unsqueeze(0)  # (1, 1, 3, 3)

out = F.conv2d(img, kernel)
print(out[0, 0])
# tensor([[-6., -6.],
#         [-6., -6.]])

输出尺寸怎么算

输出高度 = (输入高度 - 卷积核高度) // stride + 1
输出宽度 = (输入宽度 - 卷积核宽度) // stride + 1

默认 stride=1 时简化为:

输出高度 = 输入高度 - 卷积核高度 + 1
输出宽度 = 输入宽度 - 卷积核宽度 + 1

为什么是这个公式?用一个具体例子看。

输入 5×5,卷积核 3×3,stride=1:

核从左到右能停在几个位置?

 位置1(列 0-2)          位置2(列 1-3)          位置3(列 2-4)
 ┌───┬───┬───┬───┬───┐  ┌───┬───┬───┬───┬───┐  ┌───┬───┬───┬───┬───┐
 │ █ │ █ │ █ │   │   │  │   │ █ │ █ │ █ │   │  │   │   │ █ │ █ │ █ │
 ├───┼───┼───┼───┼───┤  ├───┼───┼───┼───┼───┤  ├───┼───┼───┼───┼───┤
 │ █ │ █ │ █ │   │   │  │   │ █ │ █ │ █ │   │  │   │   │ █ │ █ │ █ │
 ├───┼───┼───┼───┼───┤  ├───┼───┼───┼───┼───┤  ├───┼───┼───┼───┼───┤
 │ █ │ █ │ █ │   │   │  │   │ █ │ █ │ █ │   │  │   │   │ █ │ █ │ █ │
 ├───┼───┼───┼───┼───┤  ├───┼───┼───┼───┼───┤  ├───┼───┼───┼───┼───┤
 │   │   │   │   │   │  │   │   │   │   │   │  │   │   │   │   │   │
 ├───┼───┼───┼───┼───┤  ├───┼───┼───┼───┼───┤  ├───┼───┼───┼───┼───┤
 │   │   │   │   │   │  │   │   │   │   │   │  │   │   │   │   │   │
 └───┴───┴───┴───┴───┘  └───┴───┴───┴───┴───┘  └───┴───┴───┴───┴───┘
 能停 3 次 = 5 - 3 + 1

核的宽度是 3。输入宽 5,核占掉 3,还剩 2 个"可移动的空间"。这 2 次移动加上起始的 1 次(不移动),就是 5 - 3 + 1 = 3 次,那个 +1 就是起始位置本身。

行方向同理,核从上到下也只能停 3 次。所以输出是 3×3。

如果 stride=2(每次跳一格),情况就不同了:

输入 5×5,卷积核 3×3,stride=2:

位置1(列 0-2)          位置2(列 2-4)
┌───┬───┬───┬───┬───┐  ┌───┬───┬───┬───┬───┐
│ █ │ █ │ █ │   │   │  │   │   │ █ │ █ │ █ │
├───┼───┼───┼───┼───┤  ├───┼───┼───┼───┼───┤
│ █ │ █ │ █ │   │   │  │   │   │ █ │ █ │ █ │
├───┼───┼───┼───┼───┤  ├───┼───┼───┼───┼───┤
│ █ │ █ │ █ │   │   │  │   │   │ █ │ █ │ █ │
├───┼───┼───┼───┼───┤  ├───┼───┼───┼───┼───┤
│   │   │   │   │   │  │   │   │   │   │   │
├───┼───┼───┼───┼───┤  ├───┼───┼───┼───┼───┤
│   │   │   │   │   │  │   │   │   │   │   │
└───┴───┴───┴───┴───┘  └───┴───┴───┴───┴───┘
 能停 2 次 = (5 - 3) // 2 + 1

stride=2 意味着每次滑 2 格。核从列 0-2 直接跳到列 2-4(中间没有"列 1-3"这个位置)。能停的次数是 (5 - 3) // 2 + 1 = 2

通用公式(含 stride):

输出尺寸 = (输入尺寸 - 核尺寸) // stride + 1

卷积核在"看"什么

不同数值的卷积核能检测不同的特征。

竖直边缘检测

用一张左边白、右边黑的图测试:

import numpy as np
import torch
import torch.nn.functional as F

kernel = [[ 1,  0, -1],
          [ 1,  0, -1],
          [ 1,  0, -1]]

img = np.array([
    [1, 1, 1, 0, 0, 0],
    [1, 1, 1, 0, 0, 0],
    [1, 1, 1, 0, 0, 0],
    [1, 1, 1, 0, 0, 0],
    [1, 1, 1, 0, 0, 0],
    [1, 1, 1, 0, 0, 0],
], dtype=np.float32)

卷积结果:

[[0. 3. 3. 0.]
 [0. 3. 3. 0.]
 [0. 3. 3. 0.]
 [0. 3. 3. 0.]]

解读:

  • 第 0 列:全 0 → 纯白区域,没有边缘
  • 第 1、2 列:全是 3 → 白黑交界处,检测到竖边
  • 第 3 列:全 0 → 纯黑区域,没有边缘

核的左列是 +1,右列是 -1。当左边亮(值大)、右边暗(值小)时,结果是大正值。纯白或纯黑区域正负抵消,结果是 0。

水平边缘检测

用一张上面白、下面黑的图测试:

kernel = [[ 1,  1,  1],
          [ 0,  0,  0],
          [-1, -1, -1]]

img = np.array([
    [1, 1, 1, 1, 1, 1],
    [1, 1, 1, 1, 1, 1],
    [1, 1, 1, 1, 1, 1],
    [0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0],
], dtype=np.float32)

卷积结果:

[[0. 0. 0. 0.]
 [3. 3. 3. 3.]
 [3. 3. 3. 3.]
 [0. 0. 0. 0.]]

解读:

  • 第 0 行:全 0 → 纯白区域,没有边缘
  • 第 1、2 行:全是 3 → 白黑交界处(行 2-3 之间),检测到横边
  • 第 3 行:全 0 → 纯黑区域,没有边缘

核的上行是 +1,下行是 -1。当上边亮(值大)、下边暗(值小)时,结果是大正值。纯白或纯黑区域正负抵消,结果是 0。

核心思想

传统图像处理中,这些核是人为设计的。深度学习的关键突破是:核的值不是人定的,是网络自己学出来的。网络通过训练发现什么样的核最有利于完成任务。

为什么引入卷积

全连接的问题:参数量爆炸

一张 224×224×3 的 RGB 图像,展平后有 150,528 个像素。全连接到一个 1000 节点的输出层(1000 是 ImageNet 的类别数,也是 AlexNet、ResNet 等经典网络的输出维度):

150,528 × 1,000 = 1.5 亿个参数

而 AlexNet 第一个卷积层只用了几万个参数,差了数千倍。参数太多意味着:

  • 需要极大量的训练数据
  • 极易过拟合
  • 计算和存储成本不可接受

全连接的问题:破坏空间结构

全连接层把图像展平成一维向量,每个像素对应一个固定的输入节点。展平后,位置信息变成了"这个像素是第几个节点":

4×4 图像:                  展平后:
(0,0) (0,1) (0,2) (0,3)   节点0  节点1  节点2  节点3
(1,0) (1,1) (1,2) (1,3)   节点4  节点5  节点6  节点7
(2,0) (2,1) (2,2) (2,3) → 节点8  节点9  节点10 节点11
(3,0) (3,1) (3,2) (3,3)   节点12 节点13 节点14 节点15

假设"猫耳朵"是一个 2x2 的亮块:

  • 左上角时,占据像素 (0,0)(0,1)(1,0)(1,1) → 对应节点 0,1,4,5
  • 右下角时,占据像素 (2,2)(2,3)(3,2)(3,3) → 对应节点 10,11,14,15

"猫耳朵"在左上角时,节点 0,1,4,5 被激活,它们连到隐藏层的权重是 W_左上。

"猫耳朵"在右下角时,节点 10,11,14,15 被激活,它们连到隐藏层的权重是 W_右下。

这是两组完全独立的权重参数。模型不知道这两组模式其实描述的是同一个东西。它必须通过训练数据看到足够多的"左上角有耳朵的猫"和"右下角有耳朵的猫",才能分别学会。

猫耳朵在左上角 → 激活节点 0,1,4,5  → 权重 W_左上 → 识别为"猫"
猫耳朵在右下角 → 激活节点 10,11,14,15 → 权重 W_右下 → 识别为"猫"

卷积就不一样:同一个核在整张图上滑动,不管"猫耳朵"出现在哪个位置,用的都是同一组权重。学一次,处处能用,这就是平移不变性

nn.Conv2d 函数

PyTorch 中用 nn.Conv2d 创建卷积层:

nn.Conv2d(in_channels, out_channels, kernel_size,
          stride=1, padding=0, dilation=1, groups=1, bias=True)
参数默认值含义
in_channels必填输入通道数。灰度图是 1,RGB 是 3
out_channels必填输出通道数,也是卷积核的数量
kernel_size必填卷积核尺寸,5 表示 5×5,(3,5) 表示 3×5
stride1滑动步长,每次移动几格
padding0边缘填充,四周补几圈 0
dilation1空洞卷积,核内像素间距
groups1分组卷积,通道分组计算
biasTrue是否加偏置,每个核一个偏置项

最常用的就是前三个参数:

# 灰度图输入,10 个 5×5 的核
nn.Conv2d(1, 10, 5)

卷积的三个核心优势

1. 局部性

  • nn.Conv2d(1, 10, 5) 的 5×5 核只关注 25 个像素的局部区域
  • 识别"左眼"只需要看左眼那块像素,不需要同时看右下角像素

2. 参数共享

  • 同一个"竖边检测核"在整张图上都用同一组权重
  • 左上角用这组权重,右下角也用这组权重
  • 不需要为每个位置学一套参数

3. 平移不变性

  • 因为参数共享,特征在哪都能被检测到
  • 猫在图的左边或右边,卷积都能认出来

这三点是因果关系:局部性 + 参数共享 → 平移不变性。

卷积层的参数计算

参数量公式:

参数量 = (kernel_h × kernel_w × in_channels) × out_channels + out_channels(偏置)

代入数字:

每个核:     3 × 3 × 3 = 27 个权重   ← 3 是因为 RGB 有 3 个通道
64 个核:    27 × 64 = 1,728
偏置:       64(每个核一个)
总计:       1,728 + 64 = 1,792

验证:

conv.weight.shape  # torch.Size([64, 3, 3, 3])
conv.bias.shape    # torch.Size([64])

sum(p.numel() for p in conv.parameters())  # 1792

权重张量的形状 [64, 3, 3, 3] 含义:

[卷积核数, 输入通道, 核高度, 核宽度]
 ↑        ↑       ↑      ↑
 64个核    RGB=3   3  ×   3

每个核有 3 个通道(分别对应 R、G、B),每个通道一个 3×3 的权重矩阵。计算过程是:

输入 (H, W, 3)  →  核13个通道分别与R/G/B做卷积 → 3个结果相加 → 输出特征图1 (H', W')
                →  核23个通道分别与R/G/B做卷积 → 3个结果相加 → 输出特征图2 (H', W')
                →  ...
                →  核64做同样操作 → 输出特征图64 (H', W')

简单说:一个核(含3个通道)扫描一次得到一张特征图,64 个核就得到 64 张特征图

不同模型的卷积参数量

# ResNet-18 第一层
nn.Conv2d(3, 64, 7, stride=2, padding=3)
# 参数: (7×7×3)×64 + 64 = 9,472

# MNIST CNN 第一层
nn.Conv2d(1, 10, 5)
# 参数: (5×5×1)×10 + 10 = 260

卷积层的串联

class SimpleCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 10, 5)   # 输入 1 通道,输出 10 通道
        self.conv2 = nn.Conv2d(10, 20, 5)  # 输入 10 通道,输出 20 通道
        self.fc = nn.Linear(320, 10)

    def forward(self, x):
        # conv1 卷积 → max_pool2d 池化 → ReLU 激活(引入非线性)
        x = F.relu(F.max_pool2d(self.conv1(x), 2))
        # conv2 卷积 → max_pool2d 池化 → ReLU 激活
        x = F.relu(F.max_pool2d(self.conv2(x), 2))
        # 展平后送入全连接层
        x = x.view(-1, 320)
        return self.fc(x)

关键规则:前一层的输出通道数必须等于后一层的输入通道数

conv1: nn.Conv2d(1, 10, 5)   ← 输出 10 通道
conv2: nn.Conv2d(10, 20, 5)  ← 输入必须是 10 通道
#                ↑
#                必须匹配 conv1 的 out_channels

最大池化

在卷积层之间,通常会插入池化层来降低空间尺寸。

F.max_pool2d(x, 2, 2)  # 2×2 窗口,stride=2

用 2×2 的窗口在特征图上滑动,每个窗口只取最大值。

输入 (4x4):
[[ 1, 3, 2, 4],
 [ 5, 6, 1, 2],
 [ 3, 2, 8, 7],
 [ 4, 1, 5, 3]]

2×2 窗口,stride=2:

左上: ┌───┬───┐          右上: ┌───┬───┐
      │ 13 │               │ 24 │
      │ 56 │ → max=612 │ → max=4
      └───┴───┘               └───┴───┘

左下: ┌───┬───┐          右下: ┌───┬───┐
      │ 32 │               │ 87 │
      │ 41 │ → max=453 │ → max=8
      └───┴───┘               └───┴───┘

输出 (2x2):
[[6, 4],
 [4, 8]]

尺寸变化:

输出尺寸 = (输入尺寸 - kernel_size) // stride + 1  ← 同卷积的输出尺寸公式max_pool2d(x, 2, 2) 为例:
24 → (24 - 2) // 2 + 1 = 12  ← 偶数输入时减半
25 → (25 - 2) // 2 + 1 = 12  ← 奇数输入时向下取整

为什么用最大池化

  1. 下采样(10, 24, 24) → (10, 12, 12),长宽各减半,数据量从 10×24×24=5760 降到 10×12×12=1440,减少了 (5760-1440)/5760 = 75%
  2. 保留最强响应:某区域 [0.1, 0.9, 0.2, 0.3],max = 0.9,只保留最显著的特征
  3. 一定的平移不变性:特征偏移 1 个像素,max 结果不变
F.max_pool2d(x, 2, 2)    # 显式写 kernel_size 和 stride
F.max_pool2d(x, 2)       # stride 默认等于 kernel_size,等价

数据流

有了卷积和池化,一个完整的 CNN 数据流是这样的:

输入 (1, 28, 28)     ← MNIST 灰度图
    ↓ conv1
(10, 24, 24)         ← 24 = 28 - 5 + 1max_pool2d(2)
(10, 12, 12)         ← 12 = (24 - 2) // 2 + 1
    ↓ conv2
(20, 8, 8)           ← 8 = 12 - 5 + 1max_pool2d(2)
(20, 4, 4)           ← 4 = (8 - 2) // 2 + 1
    ↓ 展平
(320,)               ← 20 × 4 × 4
    ↓ fc
(10,)                ← 10 类分类输出

完整训练流程

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from torchvision import datasets, transforms

# 1. 定义模型
class SimpleCNN(nn.Module):
    def __init__(self):
        super().__init__()
        # 卷积层:输入通道1(灰度图),输出通道10,卷积核5x5
        # 输入 (batch, 1, 28, 28) → 输出 (batch, 10, 24, 24)
        # 得到 10 张 24x24 的特征图,每张对应一个卷积核的扫描结果
        self.conv1 = nn.Conv2d(1, 10, 5)
        # 卷积层:输入通道10(匹配conv1的输出),输出通道20,卷积核5x5
        # 输入 (batch, 10, 12, 12) → 输出 (batch, 20, 8, 8)
        # 得到 20 张 8x8 的特征图,捕捉更复杂的模式
        self.conv2 = nn.Conv2d(10, 20, 5)
        # 全连接层:输入320(20通道 * 4 * 4),输出10(10分类)
        # 输入 (batch, 320) → 输出 (batch, 10)
        # 将提取的特征映射到10个类别的得分
        self.fc = nn.Linear(320, 10)

    def forward(self, x):
        # 第一层:卷积 + 池化 + ReLU激活
        # 输入 x: (batch, 1, 28, 28)
        # conv1后: (batch, 10, 24, 24)  ← 24 = 28 - 5 + 1,10张特征图
        # pool后:  (batch, 10, 12, 12)  ← 12 = (24 - 2) // 2 + 1,空间尺寸减半
        # ReLU: f(x) = max(0, x),将负值置为0,引入非线性,使网络能学习复杂模式
        x = F.relu(F.max_pool2d(self.conv1(x), 2))
        # 第二层:卷积 + 池化 + ReLU激活
        # conv2后: (batch, 20, 8, 8)  ← 8 = 12 - 5 + 1,20张特征图
        # pool后:  (batch, 20, 4, 4)  ← 4 = (8 - 2) // 2 + 1,空间尺寸减半
        # ReLU 再次引入非线性,过滤掉负响应,保留有意义的特征
        x = F.relu(F.max_pool2d(self.conv2(x), 2))
        # 展平:(batch, 20, 4, 4) -> (batch, 320)  ← 320 = 20 * 4 * 4
        # 将多维特征图展平为一维向量,供全连接层处理
        x = x.view(-1, 320)
        # 全连接:(batch, 320) -> (batch, 10),得到10分类输出
        # 每个输出值是对应类别的得分,后续通过 CrossEntropyLoss 计算损失
        return self.fc(x)

# 2. 准备数据
# transforms.Compose 将多个预处理步骤组合成一个流水线
# ToTensor: 将 PIL/NumPy 图像转为 Tensor,并将像素值从 [0,255] 缩放到 [0,1]
# Normalize: 用均值和标准差做标准化,(0.1307, 0.3081) 是 MNIST 数据集的统计值
# 标准化公式: (x - mean) / std,使数据分布更接近标准正态分布,加速收敛
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.1307,), (0.3081,))
])

# 下载并加载 MNIST 训练集
# train=True 表示使用训练集,download=True 表示本地没有则自动下载
# transform 指定对每张图像应用上述预处理流水线
train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform)
# DataLoader 负责将数据集分批送入模型
# batch_size=64 表示每次返回64张图像和标签,shuffle=True 表示每轮打乱顺序
# 打乱顺序防止模型记住 batch 的顺序,提高泛化能力
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=64, shuffle=True)

# 加载 MNIST 测试集(用于评估模型泛化能力)
# train=False 表示使用测试集,shuffle=False 表示不需要打乱
test_dataset = datasets.MNIST('./data', train=False, transform=transform)
test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=64, shuffle=False)

# 3. 训练
# 实例化模型(此时参数已随机初始化)
model = SimpleCNN()
# 优化器:Adam 是一种自适应学习率的优化算法,lr=1e-3 表示学习率为 0.001
# model.parameters() 返回模型所有可训练参数(卷积核权重、全连接层权重、偏置等)
# Adam 会根据每个参数的历史梯度自动调整学习率,比传统 SGD 收敛更快
optimizer = optim.Adam(model.parameters(), lr=1e-3)
# 损失函数:CrossEntropyLoss 用于多分类任务,内部包含 Softmax + 负对数似然
# 它将模型的原始输出(logits)转化为概率分布,并计算与真实标签的差距
criterion = nn.CrossEntropyLoss()

# 训练 3 个 epoch(每个 epoch 表示把整个训练集过一遍)
for epoch in range(3):
    # 切换为训练模式,启用 Dropout、BatchNorm 等训练时特有的行为
    model.train()
    # 每个 epoch 内,按 batch 遍历训练集
    # MNIST 训练集有 60000 张图,batch_size=64,所以每个 epoch 约 938 个 batch
    for images, labels in train_loader:
        # 梯度清零:因为 PyTorch 会累积梯度,每次反向传播前必须清零
        # 如果不清零,新梯度会和旧梯度叠加,导致参数更新方向错误
        optimizer.zero_grad()
        # 前向传播:将一批图像送入模型,得到预测输出
        # images 形状: (64, 1, 28, 28),output 形状: (64, 10)
        output = model(images)
        # 计算损失:模型输出和真实标签之间的差距
        # output 是 10 类得分,labels 是每个样本的真实类别索引
        loss = criterion(output, labels)
        # 反向传播:计算损失对每个参数的梯度
        # 梯度表示"这个参数应该往哪个方向调才能让损失变小"
        loss.backward()
        # 参数更新:根据梯度调整模型参数,使损失减小
        # Adam 会自动调整每个参数的更新步长
        optimizer.step()

    # 评估:每个 epoch 结束后在测试集上计算准确率(衡量泛化能力)
    # 切换为评估模式,关闭 Dropout、BatchNorm 的训练行为
    model.eval()
    correct = 0
    # torch.no_grad() 表示不记录梯度,节省内存和计算
    # 评估阶段不需要反向传播,所以不需要梯度信息
    with torch.no_grad():
        for images, labels in test_loader:
            # 前向传播得到预测
            # model(images) 输出 (batch, 10),argmax(dim=1) 取每行最大值的索引,即预测类别
            pred = model(images).argmax(dim=1)
            # 统计预测正确的数量
            # pred == labels 得到一个布尔张量,sum() 计算 True 的个数
            correct += (pred == labels).sum()
    
    # 计算准确率:正确数 / 总样本数
    accuracy = correct / len(test_dataset)
    print(f"Epoch {epoch+1}: Test Accuracy = {accuracy:.4f}")
Epoch 1: Test Accuracy = 0.9752
Epoch 2: Test Accuracy = 0.9849
Epoch 3: Test Accuracy = 0.9865

CNN 的实际用途

虽然 Transformer 在 NLP 领域占据主导,但 CNN 在视觉领域依然是基石,并且在 2024-2026 年仍有大量实际应用:

1. 工业检测与质量控制

产线上的缺陷检测(划痕、裂纹、异物)几乎都用 CNN。原因是 CNN 对局部特征敏感,且推理速度快、部署成本低。特斯拉、宁德时代、富士康的产线都在用。

2. 医学影像分析

  • X 光/CT 肺结节检测:用 3D 卷积(Conv3D)在体积数据上滑动
  • 眼底视网膜分割:U-Net(一种编码器-解码器 CNN 架构)是标准方案
  • 病理切片分类:ResNet、EfficientNet 作为 backbone

医学影像的特点是数据量小、精度要求高,CNN 的参数效率和局部感知优势在这里非常明显。

3. 自动驾驶

  • 车道线检测:实时 CNN 模型处理摄像头视频流
  • 交通标志识别:轻量级 MobileNet 部署在车载芯片上
  • 目标检测:YOLO 系列(Ultralytics YOLOv8/v10/YOLO11)的核心 backbone 依然是 CNN

4. 手机摄影

计算摄影几乎离不开 CNN:

  • 夜景降噪:CNN 从多帧低曝光图像中恢复细节
  • 人像模式虚化:语义分割网络识别人物轮廓
  • 超分辨率:SRCNN 及其变体将低分辨率图像放大

苹果、华为、小米的手机 ISP(图像信号处理器)都内置了 CNN 加速模块。

5. 安防与人脸识别

  • 门禁系统:人脸检测(MTCNN)+ 特征提取(ArcFace)
  • 视频分析:行为识别(SlowFast 网络,基于 3D 卷积)
  • 车牌识别:轻量 CNN + CTC 解码

6. 科学计算

  • 气象预报:ConvLSTM 预测降雨云图移动
  • 材料科学:CNN 分析显微镜图像,检测晶体缺陷
  • 天文:从望远镜图像中自动分类星系

7. 与大模型结合

CNN 并没有被 Vision Transformer 取代,而是经常作为混合架构的一部分:

  • 多模态模型(如 LLaVA、Qwen-VL)的视觉编码器有时用 CNN(CLIP 本身就支持 ResNet 或 ViT 作为视觉编码器,早期版本多用 ResNet)
  • SAM(Segment Anything Model) 的图像编码器是 ViT,但大量下游微调方案用 CNN 做轻量替代
  • 端侧部署:在手机、IoT 设备上,CNN 依然比 ViT 更高效

为什么 CNN 没有被取代

维度CNNViT
归纳偏置局部性 + 平移不变性(先验知识强)全局注意力(需要大量数据学习)
数据效率小数据集上表现好需要大规模预训练
推理速度卷积运算高度优化(cuDNN、TensorRT)注意力复杂度 O(n²)
部署成本手机/嵌入式芯片原生支持需要更多内存和算力

CNN 的归纳偏置(inductive bias)是它的核心优势——它"天生就知道"图像处理应该关注局部区域,而不是从头学习这个规律。这使得 CNN 在小数据和资源受限场景下依然不可替代。

总结

卷积神经网络的核心就几个概念:

  1. 卷积:用核在图上滑动扫描,提取局部特征
  2. 参数共享:同一个核在全图复用,大幅减少参数量
  3. 池化:保留最强响应,降低空间尺寸
  4. 串联:多层卷积从简单特征逐步组合到复杂语义

理解了这四点,你就理解了 CNN 的本质。