python神经网络编程入门(十二)——CNN池化层(Pooling)—— 为什么要“压缩“图像?
📌 本文属于《Python神经网络入门:零基础保姆级路线图》专栏
上一篇:python神经网络编程入门(十一)——CNN卷积层的反向传播—— 为什么要把卷积核转 180°?
下一篇:python神经网络编程入门(十三)——CNN纯 NumPy 实现 LeNet-5 全流程拆解与 MNIST 前向验证(上)完整目录 & 更新记录:
《Python神经网络入门:零基础保姆级路线图(附全系列免费源码)》
一、回顾与痛点
在上一篇中,我们用纯 NumPy 手撕了卷积层的前向与反向传播。你现在已经知道:卷积层通过卷积核在图像上滑动,提取局部特征。
但请你思考一个问题:如果输入是一张 256x256 的图片,经过第一层卷积(假设 stride=1, padding='same'),特征图依然是 256x256。如果接着再做卷积,尺寸几乎不变。这会带来两个灾难性的后果:
-
计算量爆炸:特征图越大,下一层卷积需要做的乘加操作就越多。
-
过拟合与参数爆炸:如果最后要接全连接层进行分类,假设最终的卷积特征图是 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)

七、本章总结与下篇预告
在本篇中,你学会了:
-
池化的意义:降维、平移不变性、防止过拟合。
-
两种池化方式:MaxPooling(取最大值,特征增强)和 AveragePooling(取均值,平滑)。
-
前向传播:用固定窗口滑动计算,并手算了 4x4 矩阵的完整过程。
-
反向传播(重点):
-
MaxPooling:梯度独享给最大值位置,其余为 0。
-
AveragePooling:梯度均分给窗口内所有位置。
-
-
代码实现:纯 NumPy 实现了 MaxPooling 的前向与反向,并封装为类。
八、下篇预告
我们已经有了卷积层和池化层,下一章我们将把它们组合起来,构建一个完整的卷积神经网络(CNN),并使用反向传播联合训练。你将看到这些层是如何协同工作,完成图像分类任务的!
第13篇:组装经典 LeNet-5 —— 用纯 NumPy 跑通第一个 CNN 模型
-
LeNet-5 结构详解:Conv1 → Pool1 → Conv2 → Pool2 → FC1 → FC2 → 输出(逐层拆解参数和尺寸变化)
-
将卷积层、池化层、全连接层组装起来:实现一个可训练的 CNN 类,前向与反向的完整数据流
-
在 MNIST 上验证 CNN 的威力:用纯 NumPy 训练 LeNet-5,对比 MLP 的准确率,亲眼见证卷积+池化带来的巨大提升
我们下一篇见!
更多推荐



所有评论(0)