资讯动态

手写反向传播:MaxPooling与AvgPooling求导详解

发布时间:2026/10/20 5:58:06 来源:尧图企业网站定制
手写反向传播的时候MaxPooling和AvgPooling的求导往往是两个最容易让人卡住的操作。全连接层和卷积层的梯度都有清晰的矩阵形式可以套池化层没有参数要更新却仍然必须把梯度从输出端完整传回输入端而且传递规则里全是细碎的分支——最大值到底落在哪个坐标平均的梯度按什么比例分窗口有重叠时要不要累加padding边界又该怎么处理。我当年第一次手写这两个层的时候前向很快写完反向却折腾了整整一下午最后发现问题的根源是max操作根本没保存索引。这篇就把MaxPooling和AvgPooling的求导过程完整拆开从链式法则讲起配可直接运行的代码和亲测踩坑记录适合正在手写神经网络、或者想彻底看懂主流框架源码的人。1. 池化层求导为什么值得单独拆出来讲1.1 反向传播链路里池化层到底做了什么在神经网络里池化层通常夹在卷积层之后作用是缩小特征图的空间尺寸。它的前向操作很简单用一个窗口在特征图上滑动每个窗口聚合成一个数。MaxPooling取窗口最大值AvgPooling取窗口平均值。但到了反向传播事情就没那么随意了。标准的链式法则是这样组织的假设池化层输入是X输出是Y损失是L。反向时我们从后一层拿到∂L/∂Y现在要计算出∂L/∂X好把它继续传回更前面的卷积层。公式是∂L/∂X ∂L/∂Y · ∂Y/∂X这里的∂Y/∂X是一个雅可比矩阵池化层的特点决定了这个矩阵极度稀疏。拿MaxPooling来说窗口大小为k的时候每个输出只对窗口里那k个输入中的1个有响应其余k-1个位置的偏导数全是0。所以池化层反向本质上不是计算梯度而是把上游梯度按前向操作的语义分发回输入空间。很多教材讲到这里就停了只说最大值位置的梯度是δ其他位置是0平均池化每个位置是δ/k。但实际上手写实现时你会遇到比这多得多的细节位置怎么存重叠窗口怎么办padding边界怎么处理多通道要不要单独遍历这些细节才是实践里真正绊倒人的地方。我常用的一个类比MaxPooling就像比赛只给冠军发奖没拿第一的选手什么都分不到AvgPooling像团队聚餐AA制总账单平分到每个人头上。前向操作决定了反向发钱的方式理解了这一点后面所有代码都是围绕这个思路在写。1.2 池化层的搬运特性与关键坐标池化层和卷积层有一个重要区别卷积层有权重参数反向传播时除了要把梯度传回输入还要计算权重的梯度用于更新池化层一个参数都没有反向要做的事只有一件——把上游梯度完整地搬运到输入张量的对应位置。为了完成搬运你必须在前向传播时额外记录信息。对MaxPooling来说要记录的是每个窗口内最大值在输入特征图上的坐标对AvgPooling来说要记录的是窗口尺寸、步长以及padding配置因为反向分发梯度时需要知道每份应该分多大。这里有一套坐标映射需要分清楚一个是输出坐标(i, j)决定这是第几个滑动窗口通过它我们能反推出窗口左上角在输入特征图上的位置h_start i × stridew_start j × stride另一个是窗口内的偏移坐标(h_off, w_off)表示梯度应该回到窗口内的哪个格子上。这两个坐标合起来才能唯一确定上游梯度要写回输入特征图的哪个位置也就是 h i × stride h_offw j × stride w_off。这个坐标映射是手写实现的命脉。我见过不少同学把坐标算错最常见的是在计算窗口内偏移时忘了做取模或整除比如把展平索引当成两个坐标直接用结果梯度散布的位置偏了一格数值梯度检查立刻暴露问题。所以我在下面实现里会特意把坐标还原过程写得啰嗦一点避免错位。2. MaxPooling求导梯度只流向赢家2.1 前向传播必须多存一份最大值位置MaxPooling的前向传播如果只关注输出数值一句话就能说完每个小窗口取最大值。但你要是拿这句话去写反向就会像我当时一样卡死。因为反向时想知道上游梯度该传给谁就必须知道前向时窗口里谁赢了。所以正规的前向实现应该返回两个东西池化结果out以及一个记录最大值位置的索引结构。索引有两种常见存储方式方式一是mask构造一个和输入X同形状的0/1矩阵最大值位置置1其余置0。反向时把上游梯度上采样到输入尺寸再乘上mask就得到dX。这种方式的优点是反向实现非常简洁缺点是内存开销大尤其在大特征图上要额外存一整份和X一样大小的矩阵。方式二是indices记录每个输出位置对应的最大值绝对坐标。每个输出存两个整数内存开销远小于mask。反向时遍历输出位置从indices里取出坐标把梯度叠加到dX的对应位置。主流框架的max_pool2d内部用的就是indices方案调用时如果把return_indices设为True拿到的第二个返回值就是它。我自己的经验是手写学习时先用方式二因为它能帮助你明确理解坐标映射等理解透了再看mask实现或框架的向量化实现都不费劲。后面代码我用的也是方式二。2.2 单窗口求导推导与直观理解先看一个最简单的窗口。设窗口里有k个数x_1, x_2, ..., x_k输出 y max(x_1, ..., x_k)。假设第j个元素是最大值也就是对所有i都有 x_j ≥ x_i。那么偏导数是∂y/∂x_j 1∂y/∂x_i 0i ≠ j为什么对最大值所在位置做微小扰动ε只要ε不把其他元素超过y的变化量就等于ε所以敏感性是1对非最大值的位置做扰动y的值完全不变所以敏感性是0。反向时如果上游梯度是δ ∂L/∂y那么 ∂L/∂x_j δ·1 δ∂L/∂x_i δ·0 0。用一个具体数字演示。窗口 X [0.5, -2.1, 1.8, 3.7]最大值为3.7位置是j3从0开始数第3个。上游传来的梯度假设为δ6那么dX [0, 0, 0, 6]。注意梯度被完整传到了最大值所在的位置窗口里的其他元素梯度全是0它们对输出的变化没有任何贡献。这就产生了一个很有意思的现象在网络的训练过程中如果一个神经元从来没能成为某个窗口里的最大值它根本收不到任何梯度权重就一直不会更新。这种梯度冻结在极端情况下会导致部分单元永久失活是MaxPooling本身自带的特性不是bug使用时要心里有数。2.3 重叠窗口时梯度要累加不能覆盖大多数情况下池化的stride等于窗口大小窗口互不重叠每个输入位置只属于一个窗口反向时只收到一份梯度。但有些设计里stride小于窗口大小比如历史上有名的重叠池化窗口之间有重叠一个输入位置可能同时落在多个窗口里。举例说明输入4×4特征图池化窗口2×2stride1。第一个窗口覆盖第0、1行第二个窗口起点在列方向偏移1因此位置(0,1)同时属于这两个窗口。如果(0,1)在两个窗口里恰好都是最大值那么反向时它收到的梯度是两个上游梯度的和如果只在第一个窗口里是最大值就只收到一份梯度如果两个窗口里都不是最大值就收到0。因此实现MaxPooling反向的核心操作是累加回写dX[r, c] dY[i, j]而不是dX[r, c] dY[i, j]。用赋值符号的话后一个窗口的梯度会覆盖前一个窗口的梯度重叠区域的梯度就会算错数值梯度检查会立刻抓出这个问题。2.4 可运行的完整实现代码这里给出一份用NumPy实现的MaxPooling前向和反向。前向把每个窗口的最大值坐标存进coords反向用coords把梯度散回输入。import numpy as np def maxpool_forward(x, pool_h2, pool_w2, stride2): x: (N, C, H, W) 输入特征图 返回: out: (N, C, H_out, W_out) 池化结果 coords: (N, C, H_out, W_out, 2) 每个输出对应的输入绝对坐标 N, C, H, W x.shape H_out (H - pool_h) // stride 1 W_out (W - pool_w) // stride 1 out np.zeros((N, C, H_out, W_out)) coords np.zeros((N, C, H_out, W_out, 2), dtypenp.int32) for n in range(N): for c in range(C): for i in range(H_out): for j in range(W_out): hs i * stride ws j * stride window x[n, c, hs:hs pool_h, ws:ws pool_w] idx np.argmax(window) # 展平后的索引 h_off idx // pool_w # 行偏移 w_off idx % pool_w # 列偏移 out[n, c, i, j] window[h_off, w_off] coords[n, c, i, j, 0] hs h_off coords[n, c, i, j, 1] ws w_off return out, coords def maxpool_backward(dout, coords, x_shape): dout: 上游梯度形状与 forward 的 out 一致 coords: forward 保存的最大值坐标 x_shape: 原始输入形状用于初始化 dX dx np.zeros(x_shape) N, C, H_out, W_out dout.shape for n in range(N): for c in range(C): for i in range(H_out): for j in range(W_out): r coords[n, c, i, j, 0] col coords[n, c, i, j, 1] dx[n, c, r, col] dout[n, c, i, j] # 注意是累加 return dx这段代码的循环写法是为了把坐标逻辑展示清楚np.argmax拿到的是窗口展平后的下标通过整除和取模还原成行偏移、列偏移再叠加窗口左上角坐标得到绝对位置。实际框架会用CUDA核函数或im2col加scatter操作大幅加速但核心语义和这段循环版完全一致。3. AvgPooling求导梯度按人头平分3.1 平均操作的线性本质与求导公式AvgPooling的前向公式是y (1 / k) · Σ_{i1}^{k} x_i其中k kh × kw是窗口内的元素总数。平均是纯粹的线性操作因此求导比max简单得多对窗口内任意位置x_i偏导数都是1/k。这个结论可以从线性函数的基本性质理解y对x_i的斜率固定是1/k不会因为x_i的大小而改变。所以反向传播时上游传来的梯度δ会被均匀分成k份窗口内每个位置都收到δ/k。数字示例输入窗口 X [1, 3, 5, 7]k4前向输出 y (1357)/4 4。假设上游梯度δ8那么dX [2, 2, 2, 2]。对比MaxPooling那种一人独享全部梯度AvgPooling更像是见者有份平均分配。这里有个容易混淆的点AvgPooling的反向虽然人人有份但每个位置分到的梯度是δ/k总和是δ。梯度总量守恒只是被摊薄了。所以当网络使用AvgPooling时浅层模块收到的梯度数值会整体偏小如果后面接的是对梯度幅度敏感的模块要注意观察数值范围。3.2 多窗口下的梯度分配与重叠累加和MaxPooling一样AvgPooling也要区分窗口是否重叠。stride等于窗口大小时每个输入位置只属于一个窗口反向时每个位置收到一份δ/k。stride小于窗口大小时一个输入位置可能同时属于多个窗口反向时要把每个窗口分给它的δ/k累加起来。举个例子。输入一维序列 X [1, 2, 3, 4, 5]窗口大小2stride1。前向输出是三个均值y0 (12)/2 1.5y1 (23)/2 2.5y2 (34)/2 3.5。反向时假设上游梯度δ0、δ1、δ2分别对应y0、y1、y2那么X位置1值为2同时参与了y0和y1的计算它的梯度是δ0/2 δ1/2。位置3同时参与了y1和y2梯度是δ1/2 δ2/2。边界位置0只参与了y0梯度是δ0/2。正是因为存在这种一个输入位置对应多个输出的关系反向回写时用累加运算而不是覆盖赋值几乎成了手写池化层的一条铁律。下面代码里我会继续用。3.3 实现代码与累加细节def avgpool_forward(x, pool_h2, pool_w2, stride2): N, C, H, W x.shape H_out (H - pool_h) // stride 1 W_out (W - pool_w) // stride 1 out np.zeros((N, C, H_out, W_out)) for n in range(N): for c in range(C): for i in range(H_out): for j in range(W_out): hs i * stride ws j * stride window x[n, c, hs:hs pool_h, ws:ws pool_w] out[n, c, i, j] window.mean() return out def avgpool_backward(dout, x_shape, pool_h2, pool_w2, stride2): N, C, H, W x_shape dx np.zeros((N, C, H, W)) N, C, H_out, W_out dout.shape scale 1.0 / (pool_h * pool_w) for n in range(N): for c in range(C): for i in range(H_out): for j in range(W_out): hs i * stride ws j * stride # 必须用 窗口重叠时一个位置会收到多份梯度 dx[n, c, hs:hs pool_h, ws:ws pool_w] dout[n, c, i, j] * scale return dx我特意在注释里强调。第一次实现AvgPooling反向时我图省事用了结果stride小于窗口的场景下梯度检查误差特别大排查半天才发现是后面窗口把前面窗口在重叠区域写入的梯度覆盖掉了。这个错误非常隐蔽因为不重叠的场景一切正常一旦重叠就出错。3.4 padding边界会让分母变成变量AvgPooling但凡涉及padding反向的分母就不是一个固定值了。以主流框架里常见的配置为例count_include_padTrue默认前向计算均值时padding位置按0参与平均分母固定是窗口面积kh×kw。反向时每个位置拿到δ/(kh×kw)保持一致。count_include_padFalse前向计算均值时只统计窗口内的有效像素数n_valid分母变成n_valid。窗口越靠近边界n_valid越小。反向时每个位置拿到的梯度是δ/n_valid不同窗口使用不同的分母。举例说明差异。输入是一个1×1特征图唯一元素X[0,0]5周围padding一圈0池化窗口2×2stride1。count_include_padTrue时前向输出是(5000)/41.25反向梯度是δ/4。count_include_padFalse时前向输出是5/15反向梯度直接是δ。同一个输入两种配置的梯度差了4倍。这个坑在实现框架兼容代码时尤其要小心。如果你前向用的是有效像素数当分母反向却写成固定除以窗口面积梯度检查在边界位置会报错前向用固定面积反向却除以有效数量同样会出错。我建议实现前先明确目标框架的配置把分母这个变量单独存下来反向时原样使用不要凭感觉写。4. 两种求导的梯度特性对比与框架落地4.1 梯度密度与数值差异对照MaxPooling和AvgPooling的求导虽然只是位置不同和平分的区别但带来的梯度分布特性差异很大。我整理了一个简单对照表对比项MaxPoolingAvgPooling每个窗口的梯度流向只流向最大值所在位置流向窗口内所有位置窗口内单个位置的梯度量δδ / (kh×kw)梯度密度稀疏大量位置梯度为0稠密每个位置都能分到位置信息依赖依赖前向保存的argmax坐标不依赖位置信息只依赖窗口几何容易出现的现象部分神经元长期无梯度更新梯度整体数值被摊薄这两种特性各有优劣。MaxPooling的稀疏梯度让网络天然带有关注局部最强信号的倾向但也可能导致一些单元永久性失活AvgPooling梯度均匀训练过程更平稳但强特征被平均掉之后下采样带来的信息损失也更均匀不容易出现极端激活。从数值稳定性角度看AvgPooling反向相当于给上游梯度乘了一个小于1的系数层数一多梯度会同步缩小MaxPooling反向则完全保留梯度数值不引入额外的缩放。所以在深层网络里如果你观察到浅层梯度异常小除了看激活函数也要检查是不是池化方式选的Avg且层数过多。4.2 主流框架在反向里保存了什么看懂框架源码之前先猜一下它要保存什么MaxPooling必须保存argmaxAvgPooling必须保存除数或输入几何信息。事实也是这样。在PyTorch里max_pool2d的反向是通过前向保存的indices完成的。autograd的每个算子节点会把forward所需的信息通过save_for_backward缓存下来反向时直接读取indices把上游梯度散回输入的对应位置和我在2.4里写的coords思路一模一样。avg_pool2d的反向则保存了输入shape和除数的计算方式通过把上游梯度乘上scale后均匀写回窗口区域实现。TensorFlow框架里对应的算子也采用类似思路MaxPooling的梯度计算需要原始输入和输出内部通过argmax定位最大值位置再进行scatterAvgPooling的梯度则是把上游梯度先放大到输入尺寸再按重叠次数归一化。这里不展开源码细节但核心都是前向记录信息反向按规则分发。我建议读者有时间去读一读框架里pooling的反向实现。你不用看太复杂的kernel只看它如何组织索引和累加就会发现自己手写的那套循环逻辑框架只是换成了更高效率的写法而已。4.3 从求导规则理解两种池化的适用场景反向传播的规则其实反映了前向操作的信息保留方式这也直接影响到两种池化的选型。MaxPooling适合特征本身有强局部响应的场景。比如边缘检测、关键点定位这类任务网络希望后续层能聚焦到响应最强的位置忽略周围弱响应。配合它稀疏的梯度前层会被训练成做出明确选择的模式。AvgPooling适合需要保留整体统计信息的场景。比如全局平均池化把每个通道的特征图压缩成一个平均值再送进分类器梯度能够均匀回传到特征图的各个位置。相比MaxPooling全局平均池化对通道内所有空间位置都保持敏感训练更稳定这也是它常被用于分类网络末端的原因。从求导规则反推选型有一个很实用的判断方法如果你的网络出现大量死神经元且它们集中出现在MaxPooling之后可以考虑换成AvgPooling或者给池化前的特征图加一些随机扰动让不同神经元都有机会成为窗口内的最大值。5. 手写池化反向的常见错误与验证方法5.1 坑一只存结果不存位置这是新手最容易犯的错误也是我当年卡了一下午的根源。很多人写前向时图省事直接np.max一取就返回完全没想过反向需要知道最大值在哪。等到写反向函数时发现手里只有out和dout根本不知道梯度该往哪儿写。解决思路很简单从设计函数签名的那一刻起就把反向需要什么信息考虑进去。MaxPooling的前向返回值看作(out, coords)AvgPooling的前向虽然不需要坐标但要明确窗口尺寸、stride、padding配置这些几何信息。先想反向再写前向能避免很多返工。5.2 坑二回写用了赋值而不是累加前面的代码里我已经强调过多次。勾稽一下原理只要窗口之间存在重叠一个输入位置就对应多个输出位置反向推导时梯度必须按链式法则的多路径求和规则做累加。写成赋值操作等于是把多路径梯度砍得只剩最后一条数学上完全错误。这个错误的特点是stride等于窗口大小时代码看起来一切正常一旦改小stride解析梯度和数值梯度的差异立刻出现且差集中在窗口重叠区域。如果遇到这种情况优先检查所有回写张量的操作符是不是用的。5.3 坑三除数和框架配置没对齐AvgPooling的除数问题在padding场景下特别突出。如果你在实现前向时用了count_include_padFalse的语义即分母是有效像素数反向就必须用同一个有效像素数作为除数。如果你实现前向时用了固定窗口面积反向也跟着用固定面积。我见过一种隐蔽的错误写法前向里为了通用性写了个动态的n_valid反向里却图省事写死了scale1/(kh×kw)。这样内部区域的梯度都正常只有边界窗口的梯度偏大或偏小。数值梯度检查在边界上的误差会明显大于内部顺着这个现象能很快定位到除数问题。5.4 用数值梯度检查一次性找出问题手写基础算子数值梯度检查是性价比最高的验证手段。思路是用导数的定义去逼近解析梯度给输入x的某个位置加一个微小量eps计算前向输出变化量用中心差分格式求出该位置的数值梯度再和你的反向函数结果对比。def numerical_gradient(x, forward_fn, eps1e-6): grad np.zeros_like(x) it np.nditer(x, flags[multi_index], op_flags[readwrite]) while not it.finished: idx it.multi_index x_plus x.copy() x_minus x.copy() x_plus[idx] eps x_minus[idx] - eps grad[idx] (forward_fn(x_plus) - forward_fn(x_minus)) / (2 * eps) it.iternext() return grad要验证池化层构造一个输入x再构造一个固定的上游梯度dout把前向输出与dout做内积作为loss。因为dL/dX dout对输出的线性组合系数这个loss对x的梯度应该正好等于你的解析梯度。对比流程如下np.random.seed(0) x np.random.randn(1, 1, 5, 5).astype(np.float32) dout np.random.randn(1, 1, 2, 2).astype(np.float32) # 5x5输入2x2窗口2步长 - 2x2输出 # MaxPooling 验证 out, coords maxpool_forward(x, 2, 2, 2) dx_analytic maxpool_backward(dout, coords, x.shape) def loss_fn_max(x_in): out_tmp, _ maxpool_forward(x_in, 2, 2, 2) return np.sum(out_tmp * dout) dx_numeric numerical_gradient(x, loss_fn_max, eps1e-6) print(MaxPooling max abs diff:, np.abs(dx_analytic - dx_numeric).max()) # AvgPooling 验证 out_avg avgpool_forward(x, 2, 2, 2) dx_avg_analytic avgpool_backward(dout, x.shape, 2, 2, 2) def loss_fn_avg(x_in): out_tmp avgpool_forward(x_in, 2, 2, 2) return np.sum(out_tmp * dout) dx_avg_numeric numerical_gradient(x, loss_fn_avg, eps1e-6) print(AvgPooling max abs diff:, np.abs(dx_avg_analytic - dx_avg_numeric).max())判断标准一般看相对误差。梯度量级在1附近时解析梯度和数值梯度的最大绝对误差在1e-5以内基本可以接受如果差到1e-2甚至更大逐条排查5.1到5.3的坑。我个人还会特意把stride改成小于窗口大小再跑一遍专门验证重叠累加逻辑再把输入改成带padding的尺寸验证除数一致性。这两组补充测试通过池化层的反向基本就可以放心用了。我自己每次写完pooling反向都会固定跑一遍这套数值梯度检查尤其把stride设成比窗口小的情况专门验证重叠区域。这个习惯帮我省了无数次排错时间。如果你也在手写这些基础算子建议直接把检查流程加到工具箱里它能用最直接的方式告诉你数学推导对没对、坐标映射错没错、累加写没写全比盯着代码干瞪眼高效得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑