跳到主要内容

内容阅读

AI 底层工程阿新聊ai

CNN 基础:卷积、池化、感受野——为什么图像不用全连接

本文迁移自 mindcarver/91ai · 原始位置 docs/machine learning/stage3 cv/01 cnn basics.md · 由 @阿新聊ai 整理。 <! Stage 3 Article 1: CNN 基础 调研来源: 1. Stanford CS231n 课程笔记 Convolu...

本文迁移自 mindcarver/91ai · 原始位置 docs/machine-learning/stage3-cv/01-cnn-basics.md · 由 @阿新聊ai 整理。

CNN 基础:卷积、池化、感受野——为什么图像不用全连接

TL;DR: 全连接网络处理图像时参数量爆炸——一张 224x224 的图片展平后有 150,528 个像素,第一个隐藏层如果有 1000 个神经元就是 1.5 亿个参数。CNN 通过三个核心设计解决了这个问题:局部连接(每个神经元只看图像的一个小区域)、权重共享(同一个特征检测器在整张图上滑动)、空间下采样(逐步降低分辨率)。这三个设计让参数量减少了几个数量级,同时保留了图像的空间结构信息。

为什么这很重要

想象你要教一台电脑识别照片里是不是猫。一张 224x224 像素的彩色图片包含 224 x 224 x 3 = 150,528 个数字(宽度 x 高度 x RGB 三个颜色通道)。如果用普通的全连接神经网络,第一个隐藏层的每个神经元都要和这 150,528 个输入相连。一个隐藏层 4096 个神经元?那就是 150,528 x 4,096 = 6.16 亿个权重参数。而且你还需要很多层。

这个数字意味着什么?

  • AlexNet(2012年)如果用全连接处理图像,仅第一层的参数量就会超过 GPT-2 的全部参数
  • 训练这样的网络需要海量数据和算力,而且大概率会过拟合
  • 更根本的问题是:全连接层把图像展平成一维向量,完全丢失了空间结构——"猫的耳朵在猫的头顶旁边"这种空间关系信息没了

CNN 的诞生正是为了解决这些问题。它的灵感来源于 1962 年 Hubel 和 Wiesel 对猫视觉皮层的研究——他们发现视觉神经元只对视野中特定小区域的特定模式(如某个方向的边缘)有反应。CNN 模拟了这个机制:每个神经元只关注输入的一个小区域,检测特定的局部特征。

从 LeCun 1989 年用 CNN 识别手写邮编,到 AlexNet 2012 年在 ImageNet 竞赛中把错误率从 26% 降到 16%,再到 ResNet 2015 年超越人类水平(错误率 3.57% vs 人类的 5.1%),CNN 彻底改变了计算机视觉。而且,CNN 的核心思想——局部连接和权重共享——后来也深刻影响了 Transformer 等现代架构的设计。

核心概念

卷积操作:用小窗口扫描图像

直觉解释

想象你手里拿着一个 3x3 的小放大镜,在一张照片上从左到右、从上到下滑动。每到一个位置,你透过放大镜看那 9 个像素,根据某种规则给这个位置打一个分数。这个"打分规则"就是一个卷积核(filter/kernel),它本质上是一组权重值。

如果你拿一个能检测水平边缘的核去扫描图片,扫描完会得到一张"水平边缘地图"——哪里有水平边缘,地图上对应位置的值就高。拿另一个检测垂直边缘的核再扫一遍,又得到一张"垂直边缘地图"。

多拿几个核,就能检测不同方向的边缘、不同颜色的色块、不同纹理的图案。把这些"地图"叠在一起,就得到了卷积层的输出——一个包含了多种特征检测结果的 3D 数据块。

数学定义

对于一个输入特征图 $X$(大小为 $W \times H \times D_{in}$)和一个卷积核 $W$(大小为 $F \times F \times D_{in}$),二维卷积操作定义为:

$$ Y[i,j] = \sum_{m=0}^{F-1} \sum_{n=0}^{F-1} \sum_{c=0}^{D_{in}-1} X[i \cdot S + m - P, j \cdot S + n - P, c] \cdot W[m, n, c] + b $$

其中:

  • $S$ 是步长(stride),控制卷积核每次移动多少像素
  • $P$ 是零填充(padding),在图像边缘补零的圈数
  • $b$ 是偏置项

输出特征图的空间尺寸计算公式:

$$ W_{out} = \frac{W_{in} - F + 2P}{S} + 1 $$

$$ H_{out} = \frac{H_{in} - F + 2P}{S} + 1 $$

如果使用 $K$ 个不同的卷积核,输出特征图的深度就是 $K$。

具体例子:输入 227x227x3 的图片,用 96 个 11x11x3 的卷积核,步长 4,不填充(P=0):

$$ W_{out} = \frac{227 - 11 + 0}{4} + 1 = 55 $$

输出特征图大小为 55x55x96。这就是 AlexNet 第一层的实际配置。

参数共享:同一个特征检测器在整张图上复用

这是 CNN 最关键的设计之一。核心假设是:如果一个水平边缘在图片左上角有用,那它在图片右下角也应该有用

没有参数共享时,55x55x96 的输出体积中有 290,400 个神经元,每个有 11x11x3 = 363 个权重,总共 1.057 亿个参数——仅第一层。

有了参数共享后,同一张特征图(同一个 depth slice)上的所有神经元共享同一组权重。96 个特征图只需要 96 组不同的权重,总共 96 x 11x11x3 = 34,848 个权重加上 96 个偏置 = 34,944 个参数。从 1.057 亿降到 3.5 万,参数量减少了 3,000 倍。

这个设计之所以合理,是因为图像具有平移不变性(translation invariance)——一只猫无论出现在图片的左边还是右边,它的耳朵长得都一样。所以检测"猫耳朵边缘"的特征检测器不需要针对每个位置单独训练。

池化操作:主动丢掉信息来获得不变性

直觉解释

池化就像是对特征地图做"缩略"。想象你有一张 100x100 的温度分布图,你想知道每个 2x2 区域里哪里最热。你对每个 2x2 区域取最大值,得到一张 50x50 的图——这就是最大池化(Max Pooling)。

为什么要主动丢掉 75% 的信息?三个原因:

  1. 降低计算量:后续层的计算量减少到 1/4
  2. 增加感受野:让后面的神经元能看到更大范围的图像区域
  3. 获得平移不变性:如果一个特征在 2x2 区域内稍微移动了位置,最大池化的结果不变

数学定义

最大池化(以 2x2, stride=2 为例):

$$ Y[i,j] = \max_{0 \le m < F, 0 \le n < F} X[i \cdot S + m, j \cdot S + n] $$

其中 $F=2, S=2$。

输出尺寸:

$$ W_{out} = \frac{W_{in} - F}{S} + 1 $$

池化层不引入任何参数——它只是一个固定的数学操作。

感受野:每个神经元到底"看到"了多大的区域

直觉解释

感受野(Receptive Field)就是输出特征图上的一个像素,对应到原始输入图像上的区域大小。

想象你通过一个望远镜看远处的风景。第一层卷积就像一个 3x3 的小窗户——你每次只能看到 3x3 的区域。第二层卷积又在第一层的输出上看 3x3 的区域,但因为第一层每个位置已经覆盖了输入的 3x3,所以第二层每个位置实际覆盖了输入的 5x5 区域。第三层就覆盖了 7x7。层数越深,"视野"越大。

数学计算

单层卷积的感受野就等于卷积核大小 $F$。

多层堆叠时的感受野计算(递推公式):

$$ RF_1 = F $$

$$ RF_l = RF_{l-1} + (F_l - 1) \times \prod_{i=1}^{l-1} S_i $$

其中 $RF_l$ 是第 $l$ 层的感受野,$F_l$ 是第 $l$ 层的卷积核大小,$S_i$ 是第 $i$ 层的步长。

具体计算:3 层 3x3 卷积,stride=1,padding=1

  • 第 1 层:$RF_1 = 3$
  • 第 2 层:$RF_2 = 3 + (3-1) \times 1 = 5$
  • 第 3 层:$RF_3 = 5 + (3-1) \times 1 = 7$

三层 3x3 卷积的感受野等于一层 7x7 卷积。但参数量差异巨大:

  • 一层 7x7:$C \times 7 \times 7 \times C = 49C^2$ 个参数
  • 三层 3x3:$3 \times C \times 3 \times 3 \times C = 27C^2$ 个参数

参数减少了 45%,而且三层之间有 ReLU 激活函数,非线性表达能力更强。这就是 VGGNet 的核心设计哲学。

加入池化层后,感受野增长更快。2x2 最大池化(stride=2)会让下一层的每个步长在原始输入上跳过 2 个像素,感受野迅速扩大。典型的 CNN 经过 5 层 2x2 池化后,空间尺寸从 224 降到 7(224 / 2^5 = 7),最后一层的每个神经元已经能看到整张图片的大部分区域。

工作原理(简化的心智模型)

把 CNN 想象成一个分层的目标搜索系统

第一层(底层特征):想象你是交通摄像头监控员,你的第一反应是找"线条"。垂直的线、水平的线、45度的斜线。你不关心这些线条组成什么,你只报告"这里有条竖线"。

中间层(零件组合):你把线条信息交给上级。上级把线条组合成几何图形——圆形、三角形、矩形。他也不关心这些图形是什么,只报告"左上角有个弧形"。

高层(语义概念):再上级把几何图形组合成物体部件——"两个三角形在顶部"可能是耳朵,"一个椭圆在中间"可能是鼻子。最终的指挥官根据这些部件组合做出判断:"耳朵 + 鼻子 + 眼睛 = 猫"。

这个过程有两个关键特点:

  1. 从局部到全局:底层看细节(边缘),高层看整体(物体)。每一步都在扩大视野。
  2. 从具体到抽象:底层的特征很通用(线条哪里都有),高层的特征很具体("猫耳朵形状"只有猫才有)。

为什么全连接做不到这些?

全连接层就像一个官僚,他要求每份报告都包含所有信息——每个像素的精确位置和数值。问题是:

  1. 参数太多:224x224x3 到 4096 个神经元 = 6.16 亿参数。仅存储这些参数的浮点数就需要 2.3 GB 显存。而且这些参数大部分是冗余的。
  2. 没有空间概念:展平后的向量不包含"像素 A 在像素 B 旁边"的信息。如果你把图片向右平移一个像素,展平后的向量会完全不同,但全连接层不知道它们是同一张图片。
  3. 不共享知识:全连接层在左上角学到的"边缘检测"知识,无法自动应用到右下角。每个位置都要从头学习。

CNN 通过局部连接、参数共享和池化,系统性地解决了这三个问题。

工作原理(详细机制)

卷积层的完整工作流程

让我们追踪一张 32x32x3 的 CIFAR-10 图片通过一个卷积层的完整过程。

输入:32x32x3(宽x高xRGB三通道)

超参数设定

  • 卷积核数量 $K = 16$
  • 卷积核大小 $F = 5$
  • 步长 $S = 1$
  • 零填充 $P = 2$

Step 1:零填充

在 32x32 的图片周围补 2 圈零,变成 36x36x3。填充的目的是保持输出尺寸和输入一致:

$$ W_{out} = \frac{32 - 5 + 2 \times 2}{1} + 1 = 32 $$

不填充的话,输出就变成 28x28,每经过一层卷积就缩小一圈,信息在边界处快速丢失。

Step 2:卷积核滑动

第 1 个卷积核(大小 5x5x3,共 75 个权重 + 1 个偏置)从左上角 (0,0) 开始:

$$ \text{output}[0,0,0] = \sum_{m=0}^{4}\sum_{n=0}^{4}\sum_{c=0}^{2} \text{input}[m,n,c] \cdot W_0[m,n,c] + b_0 $$

然后向右移动 1 个像素(stride=1):

$$ \text{output}[1,0,0] = \sum_{m=0}^{4}\sum_{n=0}^{4}\sum_{c=0}^{2} \text{input}[m+1,n,c] \cdot W_0[m,n,c] + b_0 $$

注意 $W_0$ 是同一组权重——这就是参数共享。

扫描完整个 36x36 的填充图(步长 1,每个方向 32 个位置),得到一张 32x32 的特征图。

Step 3:重复使用不同卷积核

用第 2 个卷积核 $W_1$(另一组 75 个权重 + 1 个偏置)重复 Step 2,得到第 2 张 32x32 的特征图。16 个卷积核,16 张特征图。

Step 4:堆叠

16 张 32x32 的特征图沿深度方向堆叠,得到 32x32x16 的输出体积。

Step 5:激活函数

对输出体积的每个元素应用 ReLU:$f(x) = \max(0, x)$。负值变零,正值不变。这引入了非线性——没有激活函数,多层卷积等价于一层卷积(因为线性变换的线性组合还是线性变换)。

参数统计

  • 每个 5x5x3 卷积核:75 个权重 + 1 个偏置 = 76 个参数
  • 16 个卷积核:16 x 76 = 1,216 个参数
  • 对比全连接:32x32x3 到 32x32x16 = 1,572,864 个参数

参数量差异:1,216 vs 1,572,864,CNN 少了约 1,300 倍。

池化层的完整工作流程

接上面的 32x32x16 输出。

超参数:2x2 最大池化,步长 2

操作

对 16 个通道(depth slices),每个通道独立操作。在 32x32 的特征图上,每个 2x2 区域取最大值:

通道 0 的特征图 (32x32):
┌─────┬─────┬─────┬─────┐
│ 1 3 │ 2 0 │ ... │     │
│ 4 2 │ 5 1 │ ... │     │  →  max(1,3,4,2) = 4
├─────┼─────┼─────┼─────┤     max(2,0,5,1) = 5
│ 0 1 │ 3 6 │ ... │     │
│ 2 7 │ 1 4 │ ... │     │  →  max(0,1,2,7) = 7
└─────┴─────┴─────┴─────┘     max(3,6,1,4) = 6
    ↓ 2x2 Max Pooling
┌─────┬─────┐
│  4  │  5  │
│  7  │  6  │
└─────┴─────┘

16 个通道都这样做,输出 16x16x16。

空间尺寸缩小一半,深度不变。注意:池化层没有可学习的参数。

为什么用最大池化而不是平均池化?

直觉上,最大池化保留的是"有没有检测到这个特征",平均池化保留的是"这个区域的平均激活水平"。实践证明,最大池化在分类任务上效果更好,原因是:如果特征检测器在某个区域内检测到了目标特征(输出值高),我们关心的是"存在性"而非"平均强度"。一个区域的某个位置有猫耳朵边缘,取最大值能保留这个信号,取平均可能被周围没有边缘的位置稀释掉。

但平均池化在某些场景下有优势。GoogLeNet 用全局平均池化替代了全连接层,大幅减少了参数量。在需要更平滑的特征表示时(如风格迁移中的 Gram 矩阵计算),平均池化更合适。

一个完整的 CNN 前向传播过程

以一个简化的 CIFAR-10 分类器为例:

INPUT:   32x32x3   (原始图片)
  ↓
CONV1:   32x32x16  (16个5x5卷积核, pad=2, stride=1) → 1,216 参数
  ↓
RELU:    32x32x16  (不变)
  ↓
POOL1:   16x16x16  (2x2 max pooling, stride=2)
  ↓
CONV2:   16x16x32  (32个5x5卷积核, pad=2, stride=1) → 12,832 参数
  ↓
RELU:    16x16x32  (不变)
  ↓
POOL2:   8x8x32    (2x2 max pooling, stride=2)
  ↓
FLATTEN: 2048      (8x8x32 = 2048)
  ↓
FC1:     128       (全连接) → 262,272 参数
  ↓
RELU:    128
  ↓
FC2:     10        (10个类别) → 1,290 参数
  ↓
SOFTMAX: 10        (概率分布)

总参数:1,216 + 12,832 + 262,272 + 1,290 = 277,610

对比全连接网络(32x32x3 → 128 → 10):307,200 + 1,280 = 308,480

这个例子中差距不大,因为图片很小(32x32)。当图片变大时,差距呈指数级增长:

图片尺寸 全连接第一层参数量 (输入→4096) CNN第一层参数量 (64个3x3核) 比率
32x32x3 1,258,368 1,792 702x
64x64x3 5,033,984 1,792 2,809x
224x224x3 61,665,280 1,792 34,414x
1024x1024x3 1,294,967,296 1,792 722,639x

对于 1024x1024 的高清图片,全连接网络仅第一层就有 12.9 亿参数,而 CNN 第一层始终只有 1,792 个参数——无论图片多大。

im2col:卷积的矩阵乘法实现

虽然概念上卷积是"滑动窗口",但实际实现时,框架(如 PyTorch、TensorFlow)会把卷积转化为矩阵乘法,利用 GPU 的高效矩阵运算。

im2col(image to column)步骤

  1. 把输入中所有被卷积核覆盖的局部区域展开成列向量
  2. 把卷积核展开成行向量
  3. 做一次矩阵乘法

例如,输入 4x4x1,卷积核 3x3x1,stride=1,padding=0:

  • 输出 2x2,共 4 个位置
  • 每个位置展开成 9 个元素(3x3)
  • im2col 矩阵:9x4(9个元素 x 4个位置)
  • 权重矩阵:1x9(1个卷积核 x 9个元素)
  • 矩阵乘法:1x9 乘 9x4 = 1x4,reshape 成 2x2

多个卷积核、多个输入通道时就是更大的矩阵乘法。GPU 做矩阵乘法的速度远远超过逐像素的循环计算,所以 im2col 虽然消耗更多内存(因为重叠区域被重复展开),但整体更快。

反向传播中的卷积

卷积层的反向传播有一个优雅的性质:卷积的反向传播也是卷积

前向传播:$Y = X * W$($*$ 表示卷积运算)

反向传播(对输入的梯度):$\frac{\partial L}{\partial X} = \frac{\partial L}{\partial Y} *{\text{full}} W{\text{flipped}}$

反向传播(对权重的梯度):$\frac{\partial L}{\partial W} = X * \frac{\partial L}{\partial Y}$

其中 $W_{\text{flipped}}$ 是卷积核在两个空间维度上翻转(180度旋转),$*_{\text{full}}$ 是全卷积(full convolution,padding 更大)。

池化层的反向传播更简单:最大池化只把梯度传给前向传播时取到最大值的那个位置,其他位置梯度为零。

代码示例(完整可运行的 Python)

从零实现卷积操作

import numpy as np

def conv2d_forward(input_data, weight, bias, stride=1, padding=0):
    """
    从零实现二维卷积前向传播
    
    参数:
        input_data: 输入数据, shape (N, C_in, H, W)
        weight: 卷积核权重, shape (C_out, C_in, kH, kW)
        bias: 偏置, shape (C_out,)
        stride: 步长
        padding: 零填充
    
    返回:
        output: 卷积输出, shape (N, C_out, H_out, W_out)
    """
    N, C_in, H, W = input_data.shape
    C_out, _, kH, kW = weight.shape
    
    # 计算输出尺寸
    H_out = (H + 2 * padding - kH) // stride + 1
    W_out = (W + 2 * padding - kW) // stride + 1
    
    # 零填充
    if padding > 0:
        input_padded = np.pad(
            input_data, 
            ((0, 0), (0, 0), (padding, padding), (padding, padding)),
            mode='constant'
        )
    else:
        input_padded = input_data
    
    output = np.zeros((N, C_out, H_out, W_out))
    
    # 卷积计算
    for i in range(H_out):
        for j in range(W_out):
            h_start = i * stride
            w_start = j * stride
            # 提取局部区域 (N, C_in, kH, kW)
            receptive_field = input_padded[
                :, :, h_start:h_start+kH, w_start:w_start+kW
            ]
            # 对每个输出通道计算点积
            for c_out in range(C_out):
                # (N, C_in, kH, kW) * (C_in, kH, kW) -> (N,) 求和
                output[:, c_out, i, j] = np.sum(
                    receptive_field * weight[c_out], axis=(1, 2, 3)
                ) + bias[c_out]
    
    return output


def max_pool2d_forward(input_data, kernel_size=2, stride=2):
    """
    从零实现最大池化
    
    参数:
        input_data: 输入数据, shape (N, C, H, W)
        kernel_size: 池化窗口大小
        stride: 步长
    
    返回:
        output: 池化输出, shape (N, C, H_out, W_out)
    """
    N, C, H, W = input_data.shape
    
    H_out = (H - kernel_size) // stride + 1
    W_out = (W - kernel_size) // stride + 1
    
    output = np.zeros((N, C, H_out, W_out))
    
    for i in range(H_out):
        for j in range(W_out):
            h_start = i * stride
            w_start = j * stride
            window = input_data[
                :, :, h_start:h_start+kernel_size, w_start:w_start+kernel_size
            ]
            output[:, :, i, j] = np.max(window, axis=(2, 3))
    
    return output


def relu_forward(input_data):
    """ReLU 激活函数"""
    return np.maximum(0, input_data)


def softmax_forward(logits):
    """Softmax 函数"""
    exp_logits = np.exp(logits - np.max(logits, axis=1, keepdims=True))
    return exp_logits / np.sum(exp_logits, axis=1, keepdims=True)


# ========================================
# 完整的 CNN 前向传播示例
# ========================================
np.random.seed(42)

# 模拟一个 batch 的 8 张 32x32 RGB 图片
batch_size = 8
images = np.random.randn(batch_size, 3, 32, 32).astype(np.float32)

print("=" * 60)
print("从零实现的 CNN 前向传播")
print("=" * 60)
print(f"\n输入图片: {images.shape}")

# --- 第一个卷积层 ---
# 16 个 5x5 卷积核,3 个输入通道
conv1_weight = np.random.randn(16, 3, 5, 5) * 0.01
conv1_bias = np.zeros(16)

conv1_out = conv2d_forward(images, conv1_weight, conv1_bias, stride=1, padding=2)
relu1_out = relu_forward(conv1_out)
pool1_out = max_pool2d_forward(relu1_out, kernel_size=2, stride=2)

print(f"\nCONV1 (16个5x5核, pad=2, stride=1): {conv1_out.shape}")
print(f"RELU1: {relu1_out.shape}")
print(f"POOL1 (2x2, stride=2): {pool1_out.shape}")
print(f"CONV1 参数量: {conv1_weight.size + conv1_bias.size}")

# --- 第二个卷积层 ---
# 32 个 5x5 卷积核,16 个输入通道
conv2_weight = np.random.randn(32, 16, 5, 5) * 0.01
conv2_bias = np.zeros(32)

conv2_out = conv2d_forward(pool1_out, conv2_weight, conv2_bias, stride=1, padding=2)
relu2_out = relu_forward(conv2_out)
pool2_out = max_pool2d_forward(relu2_out, kernel_size=2, stride=2)

print(f"\nCONV2 (32个5x5核, pad=2, stride=1): {conv2_out.shape}")
print(f"RELU2: {relu2_out.shape}")
print(f"POOL2 (2x2, stride=2): {pool2_out.shape}")
print(f"CONV2 参数量: {conv2_weight.size + conv2_bias.size}")

# --- 展平 + 全连接层 ---
flattened = pool2_out.reshape(batch_size, -1)
print(f"\nFlatten: {flattened.shape}")

# 全连接层 1: 2048 -> 128
fc1_weight = np.random.randn(128, flattened.shape[1]) * 0.01
fc1_bias = np.zeros(128)
fc1_out = flattened @ fc1_weight.T + fc1_bias
fc1_out = relu_forward(fc1_out)

print(f"FC1 (->128): {fc1_out.shape}")
print(f"FC1 参数量: {fc1_weight.size + fc1_bias.size}")

# 全连接层 2: 128 -> 10 (10个类别)
fc2_weight = np.random.randn(10, 128) * 0.01
fc2_bias = np.zeros(10)
logits = fc1_out @ fc2_weight.T + fc2_bias

print(f"FC2 (->10): {logits.shape}")
print(f"FC2 参数量: {fc2_weight.size + fc2_bias.size}")

# Softmax 得到概率
probs = softmax_forward(logits)
print(f"\nSoftmax 输出: {probs.shape}")
print(f"概率和 (应接近1.0): {probs[0].sum():.6f}")
print(f"预测类别: {np.argmax(probs, axis=1)}")

total_params = (conv1_weight.size + conv1_bias.size + 
                conv2_weight.size + conv2_bias.size +
                fc1_weight.size + fc1_bias.size +
                fc2_weight.size + fc2_bias.size)
print(f"\n总参数量: {total_params:,}")

输出:

============================================================
从零实现的 CNN 前向传播
============================================================

输入图片: (8, 3, 32, 32)

CONV1 (16个5x5核, pad=2, stride=1): (8, 16, 32, 32)
RELU1: (8, 16, 32, 32)
POOL1 (2x2, stride=2): (8, 16, 16, 16)
CONV1 参数量: 1216

CONV2 (32个5x5核, pad=2, stride=1): (8, 32, 16, 16)
RELU2: (8, 32, 16, 16)
POOL2 (2x2, stride=2): (8, 32, 8, 8)
CONV2 参数量: 12832

Flatten: (8, 2048)

FC1 (->128): (8, 128)
FC1 参数量: 262272
FC2 (->10): (8, 10)
FC2 参数量: 1290

Softmax 输出: (8, 10)
概率和 (应接近1.0): 1.000000
预测类别: [2 2 2 2 2 2 2 2]
总参数量: 277,610

PyTorch 实现与对比

import torch
import torch.nn as nn
import time

class SimpleCNN(nn.Module):
    """用 PyTorch 构建相同的 CNN 架构"""
    
    def __init__(self):
        super().__init__()
        # 特征提取部分
        self.features = nn.Sequential(
            # 第一个卷积块
            nn.Conv2d(3, 16, kernel_size=5, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2),
            # 第二个卷积块
            nn.Conv2d(16, 32, kernel_size=5, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2),
        )
        # 分类器部分
        self.classifier = nn.Sequential(
            nn.Linear(32 * 8 * 8, 128),
            nn.ReLU(inplace=True),
            nn.Linear(128, 10),
        )
    
    def forward(self, x):
        x = self.features(x)
        x = x.view(x.size(0), -1)  # Flatten
        x = self.classifier(x)
        return x


# 创建模型
model = SimpleCNN()

# 统计参数量
total_params = sum(p.numel() for p in model.parameters())
print(f"PyTorch CNN 总参数量: {total_params:,}")

# 各层参数量
for name, param in model.named_parameters():
    print(f"  {name}: {param.shape} = {param.numel():,} 参数")

# 前向传播测试
x = torch.randn(8, 3, 32, 32)
output = model(x)
print(f"\n输入: {x.shape}")
print(f"输出: {output.shape}")
print(f"预测类别: {output.argmax(dim=1)}")

# ========================================
# 性能对比:卷积的 im2col 实现 vs 朴素实现
# ========================================
print("\n" + "=" * 60)
print("性能对比:卷积操作的不同实现")
print("=" * 60)

def conv2d_im2col(input_data, weight, bias, stride=1, padding=0):
    """使用 im2col 技巧的卷积实现"""
    N, C_in, H, W = input_data.shape
    C_out, _, kH, kW = weight.shape
    
    H_out = (H + 2 * padding - kH) // stride + 1
    W_out = (W + 2 * padding - kW) // stride + 1
    
    # Padding
    if padding > 0:
        input_padded = np.pad(
            input_data,
            ((0, 0), (0, 0), (padding, padding), (padding, padding)),
            mode='constant'
        )
    else:
        input_padded = input_data
    
    # im2col: 展开所有局部区域
    cols = np.zeros((N, C_in * kH * kW, H_out * W_out))
    for i in range(H_out):
        for j in range(W_out):
            h_start = i * stride
            w_start = j * stride
            patch = input_padded[
                :, :, h_start:h_start+kH, w_start:w_start+kW
            ]
            cols[:, :, i * W_out + j] = patch.reshape(N, -1)
    
    # 权重展成矩阵
    W_col = weight.reshape(C_out, -1)  # (C_out, C_in*kH*kW)
    
    # 批量矩阵乘法
    output = np.zeros((N, C_out, H_out * W_out))
    for n in range(N):
        output[n] = W_col @ cols[n] + bias.reshape(-1, 1)
    
    return output.reshape(N, C_out, H_out, W_out)


# 小规模测试
test_input = np.random.randn(4, 3, 16, 16).astype(np.float32)
test_weight = np.random.randn(8, 3, 3, 3).astype(np.float32) * 0.01
test_bias = np.zeros(8, dtype=np.float32)

# 验证结果一致
out_naive = conv2d_forward(test_input, test_weight, test_bias, stride=1, padding=1)
out_im2col = conv2d_im2col(test_input, test_weight, test_bias, stride=1, padding=1)

print(f"\n朴素实现输出形状: {out_naive.shape}")
print(f"im2col 实现输出形状: {out_im2col.shape}")
print(f"两种实现结果差异: {np.max(np.abs(out_naive - out_im2col)):.2e}")

输出:

PyTorch CNN 总参数量: 277,610
  features.0.weight: torch.Size([16, 3, 5, 5]) = 1,200 参数
  features.0.bias: torch.Size([16]) = 16 参数
  features.3.weight: torch.Size([32, 16, 5, 5]) = 12,800 参数
  features.3.bias: torch.Size([32]) = 32 参数
  classifier.0.weight: torch.Size([128, 2048]) = 262,144 参数
  classifier.0.bias: torch.Size([128]) = 128 参数
  classifier.2.weight: torch.Size([10, 128]) = 1,280 参数
  classifier.2.bias: torch.Size([10]) = 10 参数

输入: torch.Size([8, 3, 32, 32])
输出: torch.Size([8, 10])
预测类别: tensor([2, 5, 2, 2, 5, 2, 5, 2])

============================================================
性能对比:卷积操作的不同实现
============================================================

朴素实现输出形状: (4, 8, 16, 16)
im2col 实现输出形状: (4, 8, 16, 16)
两种实现结果差异: 1.19e-06

可视化卷积核学到的特征

import matplotlib.pyplot as plt

def visualize_first_layer_filters(model):
    """可视化第一层卷积核学到的特征"""
    # 获取第一层卷积核
    filters = model.features[0].weight.data.numpy()
    
    fig, axes = plt.subplots(2, 8, figsize=(16, 4))
    fig.suptitle('第一层卷积核可视化 (16个5x5x3滤波器)', fontsize=14)
    
    for i in range(16):
        ax = axes[i // 8, i % 8]
        # 归一化到 [0, 1]
        f = filters[i].transpose(1, 2, 0)  # (5, 5, 3)
        f = (f - f.min()) / (f.max() - f.min())
        ax.imshow(f)
        ax.set_title(f'Filter {i}', fontsize=8)
        ax.axis('off')
    
    plt.tight_layout()
    plt.savefig('cnn_filters.png', dpi=150, bbox_inches='tight')
    print("卷积核可视化已保存到 cnn_filters.png")

# 可视化特征图
def visualize_feature_maps(model, input_image):
    """可视化各层特征图"""
    activations = {}
    
    def get_activation(name):
        def hook(model, input, output):
            activations[name] = output.detach()
        return hook
    
    # 注册 hook
    model.features[0].register_forward_hook(get_activation('conv1'))
    model.features[2].register_forward_hook(get_activation('pool1'))
    model.features[3].register_forward_hook(get_activation('conv2'))
    
    # 前向传播
    with torch.no_grad():
        model(input_image)
    
    fig, axes = plt.subplots(2, 8, figsize=(16, 4))
    fig.suptitle('第一个卷积层后的特征图', fontsize=14)
    
    conv1_act = activations['conv1'][0]  # 取第一张图
    for i in range(16):
        ax = axes[i // 8, i % 8]
        ax.imshow(conv1_act[i].numpy(), cmap='viridis')
        ax.set_title(f'Ch {i}', fontsize=8)
        ax.axis('off')
    
    plt.tight_layout()
    plt.savefig('cnn_feature_maps.png', dpi=150, bbox_inches='tight')
    print("特征图可视化已保存到 cnn_feature_maps.png")

# 运行可视化
visualize_first_layer_filters(model)

sample_image = torch.randn(1, 3, 32, 32)
visualize_feature_maps(model, sample_image)

感受野计算器

def calculate_receptive_field(architecture):
    """
    计算堆叠卷积层的感受野大小
    
    参数:
        architecture: 列表,每个元素是一个字典,描述一层
            {'type': 'conv', 'kernel': 3, 'stride': 1, 'padding': 1}
            {'type': 'pool', 'kernel': 2, 'stride': 2}
    
    返回:
        每层的感受野大小和跳跃(jump)
    """
    rf = 1      # 当前感受野
    jump = 1    # 当前跳跃(相邻像素在输入中对应多少像素距离)
    
    print(f"{'层':&#x3C;10} {'核大小':&#x3C;10} {'步长':&#x3C;10} {'感受野':&#x3C;10} {'跳跃':&#x3C;10}")
    print("-" * 50)
    
    for i, layer in enumerate(architecture):
        if layer['type'] in ['conv', 'pool']:
            k = layer['kernel']
            s = layer['stride']
            rf = rf + (k - 1) * jump
            jump = jump * s
            print(f"{layer['type']+'_'+str(i):&#x3C;10} {k:&#x3C;10} {s:&#x3C;10} {rf:&#x3C;10} {jump:&#x3C;10}")
    
    return rf, jump


# VGG-16 的前几层架构
vgg_architecture = [
    {'type': 'conv', 'kernel': 3, 'stride': 1, 'padding': 1},
    {'type': 'conv', 'kernel': 3, 'stride': 1, 'padding': 1},
    {'type': 'pool', 'kernel': 2, 'stride': 2},
    {'type': 'conv', 'kernel': 3, 'stride': 1, 'padding': 1},
    {'type': 'conv', 'kernel': 3, 'stride': 1, 'padding': 1},
    {'type': 'pool', 'kernel': 2, 'stride': 2},
    {'type': 'conv', 'kernel': 3, 'stride': 1, 'padding': 1},
    {'type': 'conv', 'kernel': 3, 'stride': 1, 'padding': 1},
    {'type': 'conv', 'kernel': 3, 'stride': 1, 'padding': 1},
    {'type': 'pool', 'kernel': 2, 'stride': 2},
    {'type': 'conv', 'kernel': 3, 'stride': 1, 'padding': 1},
    {'type': 'conv', 'kernel': 3, 'stride': 1, 'padding': 1},
    {'type': 'conv', 'kernel': 3, 'stride': 1, 'padding': 1},
    {'type': 'pool', 'kernel': 2, 'stride': 2},
    {'type': 'conv', 'kernel': 3, 'stride': 1, 'padding': 1},
    {'type': 'conv', 'kernel': 3, 'stride': 1, 'padding': 1},
    {'type': 'conv', 'kernel': 3, 'stride': 1, 'padding': 1},
    {'type': 'pool', 'kernel': 2, 'stride': 2},
]

print("VGG-16 感受野计算:")
print("=" * 50)
rf, jump = calculate_receptive_field(vgg_architecture)
print(f"\n最终感受野: {rf} x {rf} 像素")
print(f"最终跳跃: {jump}")
print(f"输入 224x224 经5次2x池化后: {224 // (2**5)} x {224 // (2**5)}")

输出:

VGG-16 感受野计算:
==================================================
层         核大小     步长       感受野     跳跃
--------------------------------------------------
conv_0     3          1          3          1
conv_1     3          1          5          1
pool_2     2          2          6          2
conv_3     3          1          10         2
conv_4     3          1          14         2
pool_5     2          2          16         4
conv_6     3          1          24         4
conv_7     3          1          32         4
conv_8     3          1          40         4
pool_9     2          2          44         8
conv_10    3          1          60         8
conv_11    3          1          76         8
conv_12    3          1          92         8
pool_13    2          2          100        16
conv_14    3          1          132        16
conv_15    3          1          164        16
conv_16    3          1          196        16
pool_17    2          2          212        32

最终感受野: 212 x 212 像素
最终跳跃: 32
输入 224x224 经5次2x池化后: 7 x 7

VGG-16 最后一层的每个神经元"看到"了原始输入的 212x212 区域——几乎是整张 224x224 的图片。这就是 CNN 如何从局部特征逐步构建全局理解的过程。

训练一个真正的 CNN 分类器

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
import torchvision
import torchvision.transforms as transforms

# 数据预处理
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616))
])

# 加载 CIFAR-10
train_dataset = torchvision.datasets.CIFAR10(
    root='./data', train=True, download=True, transform=transform
)
test_dataset = torchvision.datasets.CIFAR10(
    root='./data', train=False, download=True, transform=transform
)

train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=2)
test_loader = DataLoader(test_dataset, batch_size=100, shuffle=False, num_workers=2)


class CNNScratch(nn.Module):
    """从头构建的 CNN 分类器"""
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            # Block 1: 32x32 -> 16x16
            nn.Conv2d(3, 32, 3, padding=1),
            nn.BatchNorm2d(32),
            nn.ReLU(inplace=True),
            nn.Conv2d(32, 32, 3, padding=1),
            nn.BatchNorm2d(32),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2, 2),
            nn.Dropout2d(0.25),
            
            # Block 2: 16x16 -> 8x8
            nn.Conv2d(32, 64, 3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True),
            nn.Conv2d(64, 64, 3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2, 2),
            nn.Dropout2d(0.25),
            
            # Block 3: 8x8 -> 4x4
            nn.Conv2d(64, 128, 3, padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(inplace=True),
            nn.Conv2d(128, 128, 3, padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(2, 2),
            nn.Dropout2d(0.25),
        )
        self.classifier = nn.Sequential(
            nn.Linear(128 * 4 * 4, 256),
            nn.ReLU(inplace=True),
            nn.Dropout(0.5),
            nn.Linear(256, 10),
        )
    
    def forward(self, x):
        x = self.features(x)
        x = x.view(x.size(0), -1)
        x = self.classifier(x)
        return x


# 训练
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = CNNScratch().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4)
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5)

print(f"\n训练设备: {device}")
print(f"模型参数量: {sum(p.numel() for p in model.parameters()):,}")
print(f"训练样本数: {len(train_dataset)}")
print(f"测试样本数: {len(test_dataset)}")

num_epochs = 25
for epoch in range(num_epochs):
    model.train()
    running_loss = 0.0
    correct = 0
    total = 0
    
    for inputs, labels in train_loader:
        inputs, labels = inputs.to(device), labels.to(device)
        
        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        
        running_loss += loss.item()
        _, predicted = outputs.max(1)
        total += labels.size(0)
        correct += predicted.eq(labels).sum().item()
    
    scheduler.step()
    
    # 测试
    model.eval()
    test_correct = 0
    test_total = 0
    with torch.no_grad():
        for inputs, labels in test_loader:
            inputs, labels = inputs.to(device), labels.to(device)
            outputs = model(inputs)
            _, predicted = outputs.max(1)
            test_total += labels.size(0)
            test_correct += predicted.eq(labels).sum().item()
    
    train_acc = 100. * correct / total
    test_acc = 100. * test_correct / test_total
    print(f'Epoch {epoch+1:2d}/{num_epochs} | '
          f'Loss: {running_loss/len(train_loader):.4f} | '
          f'Train Acc: {train_acc:.2f}% | '
          f'Test Acc: {test_acc:.2f}%')

print(f'\n最终测试准确率: {test_acc:.2f}%')

典型输出(25 个 epoch 后):

训练设备: cpu
模型参数量: 582,858
训练样本数: 50000
测试样本数: 10000

Epoch  1/25 | Loss: 1.5432 | Train Acc: 43.29% | Test Acc: 53.76%
Epoch  2/25 | Loss: 1.1243 | Train Acc: 59.47% | Test Acc: 64.28%
Epoch  3/25 | Loss: 0.9531 | Train Acc: 65.82% | Test Acc: 70.12%
...
Epoch 23/25 | Loss: 0.2876 | Train Acc: 90.31% | Test Acc: 87.65%
Epoch 24/25 | Loss: 0.2798 | Train Acc: 90.54% | Test Acc: 87.91%
Epoch 25/25 | Loss: 0.2734 | Train Acc: 90.78% | Test Acc: 88.12%

最终测试准确率: 88.12%

一个 58 万参数的简单 CNN,在 CIFAR-10 上能到 88% 左右的准确率。这个架构只有 6 个卷积层,远比 ResNet 简单,但已经能学到有用的视觉特征。

真实案例

案例 1:自动驾驶中的目标检测

Tesla 的 FSD(Full Self-Driving)系统大量使用 CNN 来处理摄像头输入。从 8 个摄像头同时输入的视频流中,CNN 需要实时检测车辆、行人、车道线、交通标志等。这里的核心挑战是实时性——CNN 必须在几十毫秒内处理完所有 8 路视频。CNN 的参数共享设计使得这个计算量变得可控:同一个"检测行人"的卷积核可以扫描所有摄像头输入,不需要为每个摄像头单独训练参数。

案例 2:医学图像分析

Google Health 的乳腺癌检测系统使用 CNN 分析乳腺 X 光片。系统在 2020 年发表于 Nature 的论文中报告,CNN 在检测乳腺癌方面的表现超过了放射科医生(假阳性率降低 5.7%,假阴性率降低 9.4%)。CNN 在这里的优势是:通过深层卷积,它能捕捉到人眼难以察觉的微小组织纹理变化。

案例 3:人脸识别

FaceNet(Google, 2015)使用 CNN 将人脸图片映射到 128 维的向量空间。同一个人的两张照片的向量距离很近,不同人的距离很远。核心是 CNN 提取的人脸特征具有很好的判别性——经过几层卷积后,人脸的局部特征(眼睛形状、鼻梁角度、下颌线条)被编码成紧凑的数值表示。

案例 4:艺术风格迁移

Gatys 等人 2015 年提出的 Neural Style Transfer 使用 CNN 的中间层特征来分离图像的"内容"和"风格"。内容来自高层特征(捕捉了图像的语义结构),风格来自特征之间的 Gram 矩阵(捕捉了纹理和颜色分布)。这个方法展示了 CNN 各层学到的特征有明确的物理含义:底层是边缘和颜色,中层是纹理和形状,高层是物体和语义。

权衡取舍以及何时不该使用

CNN 的局限

1. 旋转和尺度不变性有限

标准 CNN 通过池化获得了一定的平移不变性,但对旋转和尺度变化仍然敏感。一只猫倒过来看(旋转180度),CNN 可能认不出来。解决方案包括数据增强(训练时随机旋转图片)和特殊架构(如 Spatial Transformer Networks)。但根本上,CNN 的卷积操作本身不是旋转等变的——这是它和人类视觉系统的一个重要区别。

2. 缺乏全局上下文

CNN 的每个神经元只关注局部区域。虽然深层神经元的感受野很大,但信息需要经过很多层才能传播。这意味着 CNN 在需要全局理解的任务上(如场景理解、长距离依赖关系)不如 Transformer 有效。这也是 Vision Transformer (ViT) 在 2020 年后开始挑战 CNN 地位的原因。

3. 计算密集

卷积操作虽然比全连接参数少,但计算量仍然很大。一张 224x224 的图片,经过一个有 64 个 3x3 核的卷积层,需要做 224 x 224 x 3 x 3 x 3 x 64 = 86.7M 次乘法运算。VGG-16 处理一张图片需要约 155 亿次浮点运算(FLOPs)。在边缘设备(手机、IoT)上,这可能是不可接受的。MobileNet 等轻量级架构通过深度可分离卷积(depthwise separable convolution)将计算量降低了 8-9 倍。

4. 对输入尺寸敏感

标准 CNN 要求固定大小的输入。虽然 FC 层可以转化为 CONV 层来处理任意尺寸的输入(Fully Convolutional Network),但大多数预训练模型还是绑定在特定的输入尺寸上。

何时不该使用 CNN

1. 序列数据(文本、时间序列)

虽然 1D CNN 可以处理序列数据(通过一维卷积核在时间轴上滑动),但对于长距离依赖关系强的任务,RNN/Transformer 通常更好。CNN 的感受野增长是线性的(每层增加 k-1),而 Transformer 的自注意力机制可以一步到位地连接所有位置。

2. 图结构数据

社交网络、分子结构、知识图谱等图结构数据不适合用 CNN 处理。Graph Neural Networks (GNN) 是更合适的选择——它们在图的邻域上做"卷积",而不是在规则网格上。

3. 全局信息至关重要的任务

如果任务需要同时考虑输入的所有部分之间的关系(如文档级别的文本理解、全景场景理解),Transformer 的全局注意力机制比 CNN 的局部感受野更有优势。

4. 数据量很少时

CNN 有大量超参数需要调整(卷积核数量、大小、步长、填充、池化方式、层数等)。当训练数据少于几千张图片时,从头训练 CNN 很容易过拟合。此时更好的策略是使用迁移学习——拿 ImageNet 上预训练的模型,冻结前面的卷积层,只微调最后的分类层。

现代 CNN 的演进方向

CNN 并没有因为 Transformer 的出现而过时。2023 年的研究(如 ConvNeXt)表明,通过对标准 ResNet 进行现代化的设计调整(使用 7x7 大核深度卷积、LayerNorm 替代 BatchNorm、GELU 替代 ReLU、减少激活和归一化层的数量),纯 CNN 架构可以达到和 Swin Transformer 相当的性能。这说明 CNN 的核心设计(局部连接、权重共享)依然有效,需要调整的是具体的工程细节。

关键要点

1. 三个核心设计解决三个核心问题

问题 设计 效果
参数爆炸 局部连接 + 权重共享 参数量减少数千倍
丢失空间信息 保持 3D 体积结构 空间关系得以保留
平移敏感性 参数共享 + 池化 获得平移不变性

2. 卷积核大小、步长、填充的常见配置

  • 最常用:3x3 卷积核,stride=1,padding=1(保持尺寸不变)
  • 第一层偶尔用 7x7,stride=2(快速降分辨率)
  • 池化:2x2 最大池化,stride=2(尺寸减半)
  • 一个实用规律:$P = (F-1)/2$ 时,stride=1 的卷积保持输入尺寸不变

3. 感受野决定了每个神经元能"看到"多少信息

  • 堆叠小卷积核(多个 3x3)比一个大卷积核(一个 7x7)参数更少、非线性更强
  • 池化层加速感受野增长
  • VGG-16 最终感受野约 212x212,几乎覆盖整张 224x224 的输入

4. CNN 的本质是归纳偏置(inductive bias)

  • 局部性假设:图像的局部区域包含有用的特征
  • 平移不变性假设:相同的特征出现在不同位置含义相同
  • 层次性假设:简单特征组合成复杂特征
  • 这些假设在图像上非常有效,但在其他数据类型上不一定成立

5. 从 CNN 到现代架构的启示

  • CNN 的局部连接思想延续到了 Transformer 的局部注意力(如 Swin Transformer 的窗口注意力)
  • 参数共享的思想是所有高效架构的基础
  • 感受野的概念在 Transformer 中对应于注意力的覆盖范围

延伸阅读

  • CS231n: Convolutional Neural Networks for Visual Recognition — Stanford 大学的经典课程,本文的很多技术细节参考了其课程笔记(cs231n.github.io)
  • "Convolutional Neural Networks, Explained" — Towards Data Science 上的优秀综述文章
  • LeCun, Y. et al. (1998). "Gradient-Based Learning Applied to Document Recognition" — LeNet-5 的原始论文,CNN 的奠基之作
  • Krizhevsky, A. et al. (2012). "ImageNet Classification with Deep Convolutional Neural Networks" — AlexNet 论文,开启了深度学习时代
  • Springenberg, J. et al. (2014). "Striving for Simplicity: The All Convolutional Net" — 提出用 stride>1 的卷积替代池化层
  • Liu, Z. et al. (2022). "A ConvNet for the 2020s" (ConvNeXt) — 展示了纯 CNN 架构经过现代化设计后可以达到 Transformer 级别的性能
  • Olah, C. "Understanding Convolutions" — colah's blog 上关于卷积的直觉解释,从信号处理角度理解卷积操作

评论

0
登录后可以参与评论和讨论。
💬

还没有评论

欢迎留下第一条评论,帮助这篇内容更快形成讨论。