📌 本文属于《Python神经网络入门:零基础保姆级路线图》专栏

上一篇:python神经网络编程入门(十一)——CNN卷积层的反向传播—— 为什么要把卷积核转 180°?
下一篇:​​​​​​​​​​​​​​python神经网络编程入门(十三)——CNN纯 NumPy 实现 LeNet-5 全流程拆解与 MNIST 前向验证(上)

完整目录 & 更新记录:
《Python神经网络入门:零基础保姆级路线图(附全系列免费源码)》

一、回顾与痛点

在上一篇中,我们用纯 NumPy 手撕了卷积层的前向与反向传播。你现在已经知道:卷积层通过卷积核在图像上滑动,提取局部特征。

但请你思考一个问题:如果输入是一张 256x256 的图片,经过第一层卷积(假设 stride=1, padding='same'),特征图依然是 256x256。如果接着再做卷积,尺寸几乎不变。这会带来两个灾难性的后果:

  1. 计算量爆炸:特征图越大,下一层卷积需要做的乘加操作就越多。

  2. 过拟合与参数爆炸:如果最后要接全连接层进行分类,假设最终的卷积特征图是 32x32x128,展平后就是 131072 维。全连接层的参数量会轻松突破千万级,模型极易过拟合。

为了解决这个问题,神经网络引入了池化层(Pooling Layer)。它就像一个"压缩器",专门用来缩小特征图的尺寸


二、池化层的三个核心使命

池化层没有需要学习的参数(没有权重和偏置),它只是一个固定的计算操作。它的核心作用有以下三点:

作用一:降维(减少计算量与参数量)
池化层通过对特征图进行下采样(Downsampling),大幅减少空间尺寸。比如 4x4 的特征图,经过 2x2 池化后,变成 2x2——像素数量直接减少到原来的 四分之一。后续层的计算量和参数量也随之大幅下降。

作用二:平移不变性(Translation Invariance)
这是池化层最精妙的地方。假设图片中的猫向左平移了 2 个像素,在卷积层看来,特征图上的激活位置发生了变化。但经过 MaxPooling(取最大值)后,只要猫的特征(比如胡须的强响应)依然在池化窗口内,池化后的输出结果是完全相同的。这就让网络对物体的微小位移不再敏感,大大增强了泛化能力。

作用三:防止过拟合
参数数量减少,模型复杂度降低,自然就不容易过拟合。

三、MaxPooling vs AveragePooling:两种"压缩"方式的本质区别

池化层最常用的两种方式是最大池化(MaxPooling)平均池化(AveragePooling)

3.1 MaxPooling(最大池化)

  • 原理:在池化窗口内,只取最大值作为输出,其余全部丢弃。

  • 特性:提取最显著的特征(如边缘、纹理的强烈响应),相当于做了一个"特征增强",让最强的信号传递下去。

  • 应用:通常放在卷积层之后,用来提取最突出的纹理或轮廓特征。

3.2 AveragePooling(平均池化)

  • 原理:在池化窗口内,取所有数值的算术平均值作为输出。

  • 特性:对输入进行平滑处理,减少噪声干扰,保留的是局部区域的"整体水平"而非"峰值"。

  • 应用:通常用在网络的最后几层(如全局平均池化 GAP),或者需要对背景信息进行平滑的场景。

3.3 直观的数字对比

假设某个池化窗口内的 4 个数值排列如下(为了数据连贯,我们使用固定的 2x2 区域):

1 3
5 6
  • MaxPooling 结果 = max(1, 3, 5, 6) = 6

  • AveragePooling 结果 = (1 + 3 + 5 + 6) / 4 = 15 / 4 = 3.75

可以看到,MaxPooling 只关心"谁最强",而 AveragePooling 关心"大家平均是什么水平"。

四、池化的前向传播:一步一步手算 4x4 矩阵

池化的前向传播和卷积非常相似——都是用窗口在输入上滑动,在每个位置计算一个值。
不同之处在于:卷积做加权和(需要参数),池化做 max 或 mean(不需要参数)。

我们设定输入特征图 A 是一个固定的 4x4 矩阵(为了你能完全跟上,我们不使用随机数)。它在 NumPy 中的表示为:

import numpy as np
A = np.array([[1, 3, 2, 4],
              [5, 6, 8, 7],
              [2, 1, 3, 5],
              [4, 6, 2, 3]])

设定池化参数:窗口大小 2x2步长 Stride = 2(无重叠,最常用的方式)。

第一步:定位第一个窗口(行索引 0-1,列索引 0-1)
窗口内的数据矩阵为:

1 3
5 6
  • MaxPooling 计算结果 = max = 6

  • AveragePooling 计算结果 = mean = 3.75

  • 输出位置 (0, 0) 分别为 6 和 3.75。

第二步:窗口向右移动 2 步(行索引 0-1,列索引 2-3)
窗口内的数据矩阵为:

2 4
8 7
  • MaxPooling 计算结果 = max = 8

  • AveragePooling 计算结果 = mean = (2+4+8+7)/4 = 5.25

  • 输出位置 (0, 1) 分别为 8 和 5.25。

第三步:窗口向下移动 2 步(行索引 2-3,列索引 0-1)
窗口内的数据矩阵为:

2 1
4 6
  • MaxPooling 计算结果 = max = 6

  • AveragePooling 计算结果 = mean = (2+1+4+6)/4 = 3.25

  • 输出位置 (1, 0) 分别为 6 和 3.25。

第四步:窗口移动到右下角(行索引 2-3,列索引 2-3)
窗口内的数据矩阵为:

3 5
2 3
  • MaxPooling 计算结果 = max = 5

  • AveragePooling 计算结果 = mean = (3+5+2+3)/4 = 3.25

  • 输出位置 (1, 1) 分别为 5 和 3.25。

最终前向传播结果汇总
MaxPooling 输出矩阵 (2x2) 为:

[[6, 8],
 [6, 5]]

AveragePooling 输出矩阵 (2x2) 为:

[[3.75, 5.25],
 [3.25, 3.25]]

看到没有?4x4 的输入被"压缩"成了 2x2 的输出——尺寸缩小了一半!

五、池化的反向传播:梯度如何"传回去"?(重点难点)

这是池化层最核心、也最容易混淆的地方。请打起精神,跟着我的步骤一步步理解。

在反向传播中,梯度要从输出层传回输入层。池化层把 4 个像素压缩成了 1 个像素,那么反向传播时,1 个像素的梯度要"分"给 4 个像素。

关键铁律是:传递前后,梯度的总和必须保持不变(梯度守恒)

5.1 MaxPooling 的反向传播:最大值"独享"梯度

MaxPooling 在前向传播时,只把窗口内的最大值传递给了下一层,其他值直接被丢弃了。

那么在反向传播时:

  • 最大值所在的位置独享全部梯度(梯度原封不动地传给它)。

  • 其他位置梯度为 0(因为它们在前向传播时没有贡献)。

这就引出了一个关键问题:反向传播时,我们必须知道前向传播时最大值在哪个位置
所以在实现 MaxPooling 时,前向传播必须记录下最大值的位置(通常称为 mask 或 argmax)。

具体反向传播手算例子

还是用刚才的第一个 2x2 窗口(左上角):

1 3
5 6 ← 最大值 6 在右下角

假设反向传播时,上层传下来的梯度 dOut = 0.5

那么传回给这个窗口内每个位置的梯度 dA 分配如下:

0 0
0 0.5 ← 独享全部梯度

验证梯度守恒:输入的梯度总和 = 0+0+0+0.5 = 0.5,等于输出的梯度 0.5。

5.2 AveragePooling 的反向传播:平均值"均分"梯度

AveragePooling 在前向传播时,把窗口内所有值的平均值传递给了下一层。

那么在反向传播时:

  • 窗口内的每个位置均分梯度。

具体反向传播手算例子

同样的 2x2 窗口(左上角):

1 3
5 6

前向传播输出平均值 = 3.75。

假设反向传播时,上层传下来的梯度 dOut = 0.5

窗口内有 4 个像素,所以每个像素分得的梯度 = 0.5 / 4 = 0.125

传回给这个窗口内每个位置的梯度 dA 分配如下:

0.125 0.125
0.125 0.125

验证梯度守恒:输入的梯度总和 = 0.125 * 4 = 0.5,等于输出的梯度 0.5。✅

⚠️ 致命错误提醒:千万不要把 0.5 复制 4 份传回去!那样总和变成 2.0,是原来的 4 倍,会造成梯度爆炸

六、用 NumPy 完整实现 MaxPooling 的前向与反向

理论讲完了,现在我们来写代码!

6.1 准备工作:设置固定数据

为了保证数据的连贯性,我们使用和手算例子中完全相同的输入数据(4x4 矩阵)。

import numpy as np

# 固定的输入数据(4x4),和手算例子完全一致
A = np.array([[1, 3, 2, 4],
              [5, 6, 8, 7],
              [2, 1, 3, 5],
              [4, 6, 2, 3]], dtype=np.float64)

print("输入特征图 A (4x4):")
print(A)

输出:

6.2 前向传播实现(带掩码记录)

def max_pooling_forward(A, pool_size=2, stride=2):
    """
    MaxPooling 前向传播
    参数:
        A: 输入特征图,形状 (H, W)
        pool_size: 池化窗口大小
        stride: 步长
    返回:
        out: 池化后的输出
        mask: 记录最大值位置的掩码 (用于反向传播)
    """
    H, W = A.shape
    # 计算输出尺寸(假设整除)
    out_h = (H - pool_size) // stride + 1
    out_w = (W - pool_size) // stride + 1
    
    out = np.zeros((out_h, out_w))
    # mask 形状:(out_h, out_w, pool_size, pool_size)
    mask = np.zeros((out_h, out_w, pool_size, pool_size), dtype=bool)
    
    for i in range(out_h):
        for j in range(out_w):
            h_start = i * stride
            h_end = h_start + pool_size
            w_start = j * stride
            w_end = w_start + pool_size
            
            # 提取当前窗口 (这是一个 2x2 切片)
            window = A[h_start:h_end, w_start:w_end]
            
            # 找最大值并赋值
            max_val = np.max(window)
            out[i, j] = max_val
            
            # 找到最大值在窗口内的平面索引,并还原为二维坐标
            flat_idx = np.argmax(window)      # 例如 3 代表第 3 个位置
            row_idx = flat_idx // pool_size   # 行坐标
            col_idx = flat_idx % pool_size    # 列坐标
            mask[i, j, row_idx, col_idx] = True  # 标记该位置为最大值来源
            
    return out, mask

# 测试前向传播
out, mask = max_pooling_forward(A, pool_size=2, stride=2)
print("MaxPooling 输出 (2x2):")
print(out)
print("\n掩码 mask (记录最大值位置,True 代表该位置是最大值):")
print(mask)

输出结果验证:

6.3 反向传播实现

def max_pooling_backward(dout, mask, pool_size=2, stride=2):
    """
    MaxPooling 反向传播
    参数:
        dout: 来自上层的梯度,形状 (out_h, out_w)
        mask: 前向传播时记录的最大值位置掩码
    返回:
        dA: 传递给输入的梯度,形状与输入相同 (H, W)
    """
    out_h, out_w = dout.shape
    # 恢复输入尺寸(假设是正方形)
    H = out_h * stride
    W = out_w * stride
    
    dA = np.zeros((H, W))
    
    for i in range(out_h):
        for j in range(out_w):
            # 取出当前输出位置的梯度值(标量)
            grad = dout[i, j]
            
            # 在当前窗口的 mask 中找到最大值的位置 (True 所在位置)
            # mask[i, j] 是一个 pool_size x pool_size 的布尔矩阵
            rows, cols = np.where(mask[i, j])
            
            # 由于我们只记录了一个最大值,取第一个 (0)
            if len(rows) > 0:
                r = rows[0]
                c = cols[0]
                # 计算在原始输入 A 中的绝对坐标
                abs_r = i * stride + r
                abs_c = j * stride + c
                # 将梯度原封不动传给这个位置(独享)
                dA[abs_r, abs_c] = grad
                
    return dA

# 测试反向传播
# 假设从上层传下来的梯度全是 1(方便我们验证结果)
dout = np.ones((2, 2))
dA = max_pooling_backward(dout, mask, pool_size=2, stride=2)

print("来自上层的梯度 dout (2x2,全部为 1):")
print(dout)
print("\n反向传播后传递给输入的梯度 dA (4x4):")
print(dA)

输出结果验证:

验证结果分析
对照我们手算的四个窗口最大值位置,反向传播后,只有四个位置获得了梯度(值为1),其他位置全部为0。这完美符合 MaxPooling "最大值独享梯度" 的原则!

6.4 面向对象的完整封装(实战标准)

最后,我们将上述功能封装成标准的 MaxPooling2D 类,方便在神经网络中直接调用。

class MaxPooling2D:
    """MaxPooling 层(2D),纯 NumPy 实现,支持前向与反向传播"""
    
    def __init__(self, pool_size=2, stride=2):
        self.pool_size = pool_size
        self.stride = stride
        self.mask = None      # 存储最大值位置
        self.input_shape = None  # 存储输入尺寸,用于反向传播
    
    def forward(self, A):
        """前向传播"""
        self.input_shape = A.shape
        H, W = A.shape
        pool_size = self.pool_size
        stride = self.stride
        
        out_h = (H - pool_size) // stride + 1
        out_w = (W - pool_size) // stride + 1
        out = np.zeros((out_h, out_w))
        mask = np.zeros((out_h, out_w, pool_size, pool_size), dtype=bool)
        
        for i in range(out_h):
            for j in range(out_w):
                h_start = i * stride
                h_end = h_start + pool_size
                w_start = j * stride
                w_end = w_start + pool_size
                
                window = A[h_start:h_end, w_start:w_end]
                out[i, j] = np.max(window)
                
                flat_idx = np.argmax(window)
                row_idx = flat_idx // pool_size
                col_idx = flat_idx % pool_size
                mask[i, j, row_idx, col_idx] = True
                
        self.mask = mask
        return out
    
    def backward(self, dout):
        """反向传播"""
        H, W = self.input_shape
        pool_size = self.pool_size
        stride = self.stride
        out_h, out_w = dout.shape
        
        dA = np.zeros((H, W))
        
        for i in range(out_h):
            for j in range(out_w):
                grad = dout[i, j]
                rows, cols = np.where(self.mask[i, j])
                if len(rows) > 0:
                    r, c = rows[0], cols[0]
                    abs_r = i * stride + r
                    abs_c = j * stride + c
                    dA[abs_r, abs_c] = grad
                    
        return dA

# 使用示例
pool = MaxPooling2D(pool_size=2, stride=2)
output = pool.forward(A)
print("类方法前向输出:\n", output)
grad_input = pool.backward(dout)
print("类方法反向输出:\n", grad_input)

七、本章总结与下篇预告

在本篇中,你学会了:

  1. 池化的意义:降维、平移不变性、防止过拟合。

  2. 两种池化方式:MaxPooling(取最大值,特征增强)和 AveragePooling(取均值,平滑)。

  3. 前向传播:用固定窗口滑动计算,并手算了 4x4 矩阵的完整过程。

  4. 反向传播(重点)

    • MaxPooling:梯度独享给最大值位置,其余为 0。

    • AveragePooling:梯度均分给窗口内所有位置。

  5. 代码实现:纯 NumPy 实现了 MaxPooling 的前向与反向,并封装为类。

八、下篇预告

我们已经有了卷积层和池化层,下一章我们将把它们组合起来,构建一个完整的卷积神经网络(CNN),并使用反向传播联合训练。你将看到这些层是如何协同工作,完成图像分类任务的!

第13篇:组装经典 LeNet-5 —— 用纯 NumPy 跑通第一个 CNN 模型

  • LeNet-5 结构详解:Conv1 → Pool1 → Conv2 → Pool2 → FC1 → FC2 → 输出(逐层拆解参数和尺寸变化)

  • 将卷积层、池化层、全连接层组装起来:实现一个可训练的 CNN 类,前向与反向的完整数据流

  • 在 MNIST 上验证 CNN 的威力:用纯 NumPy 训练 LeNet-5,对比 MLP 的准确率,亲眼见证卷积+池化带来的巨大提升

我们下一篇见!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐