3.6. 池化层
池化对特征图做下采样。它把每个局部窗口归约成一个数,空间维度随之缩小,通道数保持不变。池化是卷积层里那些卷积的廉价对照物:没有权重,没有矩阵乘法,只是在滑动窗口上做一次归约。
RustyML 把池化实现成一个由 12 个层组成的家族,全部建立在同一套维度无关的引擎之上。本页依次讲解构造函数、输出形状、最大池化与平均池化的取舍,以及梯度如何往回路由。本页还会讲清楚"没有可学习参数"对 summary() 和模型持久化意味着什么。
3.6.1. 层家族:2 种归约、3 种维度,外加全局变体
每个池化层都要选定 2 种归约("最大"或"平均")之一,并作用在 3 种空间维度(1D、2D、3D)之一上。每一种组合又都有一个"全局"变体,一次性把整个空间范围塌缩掉。这就是 2 x 3 x 2 = 12 个具体类型。这 12 个类型全部归结到一个私有 pooling_engine 里的 4 个函数。
引擎在运行时从 input.ndim() - 2 推导出空间维度,于是同一个循环就能服务 1D、2D 和 3D。各层之间唯一的差别,只在于归约的种类,以及公开的 pool_size/strides 元组如何摊平成切片。
张量沿用卷积层那套 channels-last 约定 [batch, spatial..., channels]。1D 层要的是三维张量,2D 层要四维,3D 层要五维。带窗口的池化保持维度不变,只缩小空间轴。全局池化则彻底丢掉空间轴,返回 [batch, channels]。
通道轴在最内层,于是一个位置的所有通道是一起归约的。窗口的几何------边界判断,以及 Same 填充下求平均要除的"真实元素个数"------每个输出位置只算一次,被所有通道共享。
| 层 | 输入张量 | 窗口控制 | 输出 |
|---|---|---|---|
MaxPooling1D / AveragePooling1D |
[N, L, C] |
new(pool_size, input_shape) + with_stride |
[N, L', C] |
MaxPooling2D / AveragePooling2D |
[N, H, W, C] |
new((ph, pw), input_shape) + with_strides |
[N, H', W', C] |
MaxPooling3D / AveragePooling3D |
[N, D, H, W, C] |
new((pd, ph, pw), input_shape) + with_strides |
[N, D', H', W', C] |
GlobalMaxPooling{1,2,3}D |
3 / 4 / 5 维 | new() |
[N, C] |
GlobalAveragePooling{1,2,3}D |
3 / 4 / 5 维 | new() |
[N, C] |
命名上有一处不对称:1D 带窗口层暴露的是 with_stride,一个单独的 usize;2D 和 3D 层暴露的是 with_strides,一个元组。这正好对应各维度下 pool_size 的形态。
3.6.2. 构造函数、池化窗口、步长与填充
带窗口的层接收池化窗口和声明的输入形状,例如:MaxPooling2D::new((2, 2), vec![batch, height, width, channels])。构造函数会立即校验窗口能否放进声明的空间维度里。有 2 个 builder 方法可以覆盖默认值,都是可选的:
with_stride/with_strides设定窗口之间的步进。如果你从不调用它,步长默认等于池化窗口大小,也就是窗口互不重叠,跟 Keras 的默认行为一样。想要重叠窗口,就传一个更小的步长。with_padding设定PaddingType::Valid(默认,不填充)或PaddingType::Same。这就是卷积层用的那个PaddingType枚举。
全局层完全不接收参数:GlobalMaxPooling2D::new()。没有窗口、步长或填充需要配置,因为窗口就是整个空间平面。
rust,ignore
// 带窗口的 2D 最大池化,3x3 窗口,步长 2,Same 填充:
let layer = MaxPooling2D::new((3, 3), vec![1, 32, 32, 16])
.unwrap()
.with_strides((2, 2))
.unwrap()
.with_padding(PaddingType::Same);
// 全局 2D 平均池化什么都不需要:
let head = GlobalAveragePooling2D::new();
构造时会校验声明的 input_shape,output_shape() 报告的也是这个声明的形状。前向传播本身只检查传入张量的秩 。所以在 Sequential 模型里,真实的空间维度是运行时从上游层传下来的。
要保证运行时的空间维度不小于池化窗口。引擎用无符号算术计算输出大小,喂进一个比窗口还小的平面会导致算术下溢,而不是给出一个干净的报错。
每个构造函数都会校验输入并返回 Result,所以各种失效模式都是明确的。错误分类体系见错误处理:
| 情形 | 错误 |
|---|---|
input_shape 的秩不对(例如把 3D 形状传给 2D 层) |
Error::DimensionMismatch |
input_shape 任意一维为零(包括 batch 或 channels) |
Error::InvalidInput |
| 某个池化维度为零,或超过了对应的输入维度 | Error::InvalidParameter |
步长为零(来自 with_stride/with_strides) |
Error::InvalidParameter |
对零 batch 和零 channel 的检查是有意加上的。早先某个版本让 [0, 1, 4, 4] 这样的形状通过了构造函数。那个形状直到第一次前向传播时才报错。现在校验会在构造时就把它挡下来,那里的调用栈才真正指向问题所在。
3.6.3. 输出形状公式
对 Valid 填充,每个空间轴都按卷积层用的同一个公式缩小。这里是向下取整,因为尾部的余数会被丢掉:
text
out = (in - pool) / stride + 1
对 Same 填充,输出向上 取整到 ceil(in / stride)。引擎对称填充,多出来的那格补在尾部一侧,和卷积引擎的做法一致。全局池化对填充和窗口大小都不理会,永远产出 [batch, channels]。
output_shape() 把这些尺寸格式化成字符串返回。带窗口的层能立刻算出自己的输出形状,因为它们在构造时就存下了 input_shape。全局层在跑过一次前向传播之前会返回 "Unknown" ,因为它们只有在张量流过时才知道输入形状。这个差异会直接体现在 summary() 里。
rust
use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;
use ndarray::Array;
fn main() {
// 带窗口的层在构造时就从 `input_shape` 知道了自己的输出形状。
let mp = MaxPooling2D::new((2, 2), vec![1, 6, 6, 3]).unwrap();
println!("MaxPooling2D: {}", mp.output_shape()); // (1, 3, 3, 3)
let ap = AveragePooling1D::new(2, vec![1, 6, 1]).unwrap();
println!("AveragePooling1D: {}", ap.output_shape()); // (1, 3, 1)
// 全局层把每个空间轴都归约成每通道一个值,但只有在前向传播缓存了输入形状之后,
// 才会报告一个具体形状。
let mut gap = GlobalAveragePooling2D::new();
println!("before forward: {}", gap.output_shape()); // Unknown
let x = Array::from_elem(ndarray::IxDyn(&[3, 5, 5, 4]), 1.0f32);
let out = gap.forward(&x).unwrap();
assert_eq!(out.shape(), &[3, 4]);
println!("after forward: {}", gap.output_shape()); // (3, 4)
}
Same 填充不只是调整一下形状。被填充的那些格子是虚拟的。前向和反向传播都会跳过越界的位置,而不是拿零去补。这对平均池化很关键:边缘窗口除以的是真实、在界内的元素个数,而不是窗口面积。
这就是 Keras 的 count_include_pad=False 行为。Same 填充的平均池化不会把边缘往零的方向稀释。
rust
use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;
use ndarray::Array;
fn main() {
// 3x3 输入,2x2 窗口,步长 2。Valid 填充会丢掉最后一行和最后一列;
// Same 填充把输出向上取整到 ceil(3/2) = 2,让尾部的窗口只看到自己在界内的格子
//(填充是虚拟的)。
let x = Array::from_shape_vec((1, 3, 3, 1), (1..=9).map(|v| v as f32).collect())
.unwrap()
.into_dyn();
let mut max_same = MaxPooling2D::new((2, 2), vec![1, 3, 3, 1])
.unwrap()
.with_strides((2, 2))
.unwrap()
.with_padding(PaddingType::Same);
let m = max_same.forward(&x).unwrap();
assert_eq!(m.shape(), &[1, 2, 2, 1]);
// [[max(1,2,4,5), max(3,6)], [max(7,8), 9]] = [[5, 6], [8, 9]]
assert_eq!(m.iter().copied().collect::<Vec<_>>(), vec![5.0, 6.0, 8.0, 9.0]);
let mut avg_same = AveragePooling2D::new((2, 2), vec![1, 3, 3, 1])
.unwrap()
.with_strides((2, 2))
.unwrap()
.with_padding(PaddingType::Same);
let a = avg_same.forward(&x).unwrap();
// 平均值除以的是真实格子的个数,而不是窗口面积(count_include_pad = False):
// [[(1+2+4+5)/4, (3+6)/2], [(7+8)/2, 9/1]] = [[3.0, 4.5], [7.5, 9.0]]
assert_eq!(a.iter().copied().collect::<Vec<_>>(), vec![3.0, 4.5, 7.5, 9.0]);
}
3.6.4. 最大与平均:语义与选型
两种归约跑在完全相同的窗口上,区别只在于保留什么。最大池化记下单个最大的激活值,丢掉其余的。它是一个"这个特征在窗口里是否在某处触发过"的检测器,并且对平移不敏感。平均池化保留均值,因而保住了这块区域整体的幅度,是在平滑而非挑选。
rust
use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;
use ndarray::Array;
fn main() {
// 同一个 4x4 平面,用互不重叠的 2x2 窗口做池化(步长默认为 2)。
let data: Vec<f32> = (0..16).map(|v| v as f32).collect();
let x = Array::from_shape_vec((1, 4, 4, 1), data).unwrap().into_dyn();
let mut max_pool = MaxPooling2D::new((2, 2), vec![1, 4, 4, 1]).unwrap();
let m = max_pool.forward(&x).unwrap();
// 各窗口的最大值:[[5, 7], [13, 15]]
assert_eq!(m.iter().copied().collect::<Vec<_>>(), vec![5.0, 7.0, 13.0, 15.0]);
let mut avg_pool = AveragePooling2D::new((2, 2), vec![1, 4, 4, 1]).unwrap();
let a = avg_pool.forward(&x).unwrap();
// 各窗口的均值:[[2.5, 4.5], [10.5, 12.5]]
assert_eq!(a.iter().copied().collect::<Vec<_>>(), vec![2.5, 4.5, 10.5, 12.5]);
}
在判别式模型的卷积堆叠内部,用最大池化。它能让最强的响应挺过下采样,而它具体出现在窗口的哪个位置并不重要。当幅度比峰值更重要时,用平均池化。网络末端的全局平均归约也是同样的道理:对整个平面取平均,能给出一个稳定、平滑的每通道摘要。
有 2 个引擎细节在边界情况下会冒出来,值得了解。最大池化用严格的 > 比较,遇到并列时取第一个 (下标最小的)最大值。最大池化还会故意传播 NaN:一旦 NaN 进了某个窗口,它就赢下来并一直占着。这跟 PyTorch/TensorFlow 的行为一致,而不是把 NaN 悄悄丢掉。
3.6.5. 梯度路由:池化的反向传播
池化没有参数需要更新,但它仍然得把上游梯度路由回那些产生了它输出的输入上。2 种归约的路由方式截然不同。
最大池化是赢家通吃梯度。前向传播时,每个输出都记下它选中的那个输入元素的扁平下标,这就是 "arg-max",层会把它缓存下来。反向传播时,每个上游梯度只散射到那一个位置上,其余输入全部留零。当窗口重叠、同一个输入赢下好几个窗口时,这些贡献会累加。
平均池化则把每个输出梯度均摊 到它的窗口上:窗口里每个在界内的元素都拿到 grad / count,重叠部分同样累加。
全局最大池化把每个通道的梯度路由到它那唯一的 arg-max 元素。全局平均池化把每个通道的梯度均匀铺满整个平面,即 grad / spatial_size。
rust
use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;
use ndarray::Array;
fn main() {
let data: Vec<f32> = (0..16).map(|v| v as f32).collect();
let x = Array::from_shape_vec((1, 4, 4, 1), data).unwrap().into_dyn();
let grad = Array::ones((1, 2, 2, 1)).into_dyn();
// 最大池化:每个窗口的梯度只到达获胜的格子(这里是扁平下标 5、7、13、15 处的 4 个最大值)。
// 其余每个格子都拿到 0。
let mut max_pool = MaxPooling2D::new((2, 2), vec![1, 4, 4, 1]).unwrap();
max_pool.forward(&x).unwrap();
let gmax = max_pool.backward(&grad).unwrap();
let expected_max = vec![
0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0,
];
assert_eq!(gmax.iter().copied().collect::<Vec<_>>(), expected_max);
// 平均池化:每个窗口的梯度均匀铺在它的格子上(1.0 / 4 = 0.25)。
let mut avg_pool = AveragePooling2D::new((2, 2), vec![1, 4, 4, 1]).unwrap();
avg_pool.forward(&x).unwrap();
let gavg = avg_pool.backward(&grad).unwrap();
assert!(gavg.iter().all(|&g| (g - 0.25).abs() < 1e-6));
}
因为最大池化依赖 arg-max 缓存,forward 和 predict 之间的分界就变得重要。forward(训练模式)会记录缓存。predict(推理模式,&self)不写任何缓存,正如 Layer trait 上写明的那样。所以 backward 只有在 forward 之后才管用。先调用 backward,或者在一次不带缓存的 predict 之后调用它,都会返回 Error::NeuralNetwork(NnError::ForwardPassNotRun(_))。
在 Sequential::fit 内部,这种情况不会发生,因为训练总会先跑 forward。只有你手动驱动一个裸层时,才会踩到这个错误。平均池化只缓存输入形状,因为它需要的是几何结构,不是具体数值。全局平均池化也只缓存形状。"反向之前先跑前向"这个约定,在全部 12 个层上都一模一样。
3.6.6. 全局池化:现代网络的输出头
传统的卷积分类器以一个 Flatten 收尾,后面跟一个庞大的 Dense 层。这种设计把网络绑死在某个确切的输入分辨率上,还把大部分参数都放进了最后那个矩阵。
全局平均池化用一个无参数的归约替换掉那个输出头,Network-in-Network 和 ResNet 让这种设计流行起来。这个层把每个通道塌缩成它的均值,于是每个通道得到一个特征,再喂给一个小小的 Dense 分类器。池化这一步本身没有参数,所以它不会过拟合,这就给输出头带来了免费的正则化效果。分类器的输入宽度只取决于通道数,跟 H x W 无关。
这种尺寸无关性确实存在,但只在池化层自身这一个层面成立。GlobalAveragePooling2D 只校验输入是不是 4D,所以同一个 池化层在运行时能接受任意的高和宽。模型堆叠的其余部分并不具备这种性质。RustyML 的 Dense 在构造时就把 input_dim 定死了,上游的卷积层也钉死了各自的 input_shape。所以一个保存下来的模型端到端仍然期望同一个分辨率。
全局池化输出头的收益,是那个无参数、抗过拟合的归约,以及干净的 [N, C] -> Dense 接口,而不是自动的可变分辨率推理。
rust
use rustyml::neural_network::sequential::Sequential;
use rustyml::neural_network::layers::*;
use rustyml::neural_network::optimizers::*;
use rustyml::neural_network::losses::*;
use ndarray::Array;
fn main() {
// 一个小型分类器输出头:Conv -> pool -> global pool -> Dense。
let x = Array::from_shape_fn((2, 8, 8, 1), |(b, i, j, _)| {
(i + j) as f32 * 0.1 + b as f32
})
.into_dyn();
let y = Array::ones((2, 3)).into_dyn();
let mut model = Sequential::new();
model
.add(Conv2D::new(4, (3, 3), vec![2, 8, 8, 1], (1, 1), Activation::ReLU).unwrap()) // -> [2, 6, 6, 4]
.add(MaxPooling2D::new((2, 2), vec![2, 6, 6, 4]).unwrap()) // -> [2, 3, 3, 4]
.add(GlobalAveragePooling2D::new()) // -> [2, 4]
.add(Dense::new(4, 3, Activation::ReLU).unwrap()) // -> [2, 3]
.compile(RMSprop::new(0.001, 0.9, 1e-8, 0.0).unwrap(), MeanSquaredError::new());
model.summary();
model.fit(&x, &y, 2).unwrap();
let prediction = model.predict(&x).unwrap();
assert_eq!(prediction.shape(), &[2, 3]);
}
上面这次 summary() 调用发生在 fit 之前 。这正是 3.6.3 里那个全局池化行为出现的时刻。它的输出形状那一列写着 Unknown,因为还没有任何张量流过该层。带窗口的层则已经报出了具体形状:
text
Model: "sequential"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type) ┃ Output Shape ┃ Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ conv2d (Conv2D) │ (2, 6, 6, 4) │ 40 │
│ maxpooling2d (MaxPooling2D) │ (2, 3, 3, 4) │ 0 │
│ globalaveragepooling2d (GlobalAveragePooling2D) │ Unknown │ 0 │
│ dense (Dense) │ (None, 3) │ 15 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
Total params: 55 (220 B)
Trainable params: 55 (220 B)
Non-trainable params: 0 (0 B)
两个池化行在 Param # 那一列都显示 0。它们都不计入那 3 个合计中的任何一个。在 fit 之后,或者任何一次前向传播之后,再调一次 summary(),全局池化那一行就会稳定成 (2, 4)。
3.6.7. 没有可学习参数:这对持久化意味着什么
池化层报告 TrainingParameters::NoTrainable,暴露 LayerWeight::Empty。优化器会跳过它们,因为它们不产生任何 ParamGrad 条目。这就是为什么它们从不出现在上面的可训练或不可训练合计里,也不给保存文件增加任何体积。
持久化才是"没有参数"带来具体后果的地方。save_to_path 会把每个层的类型名、它报告的输出形状,以及它的权重,作为元数据写下来。对池化层来说,权重是 LayerWeight::Empty,所以文件里并没有存下任何实质内容。
load_from_path 不会 重建架构。你得先用相同的层把模型搭回来,再加载权重。加载过程会逐个位置遍历各层,核对层数是否一致,也核对每个存下来的类型字符串是否等于同一下标处的类型,然后才施加权重。池化层没有权重可供恢复,但它仍然必须以确切的类型占住它确切的位置,否则加载会以 Error::Io(IoError::ModelStructureMismatch) 失败。
你不能为了"省空间"就把重建模型里的某个 MaxPooling2D 拿掉。那个结构校验点,就是靠这个层在场才能通过。
rust
use rustyml::neural_network::sequential::Sequential;
use rustyml::neural_network::layers::*;
use rustyml::neural_network::optimizers::*;
use rustyml::neural_network::losses::*;
use ndarray::Array;
fn main() {
let x = Array::from_elem((2, 4, 4, 3), 0.5f32).into_dyn();
let y = Array::ones((2, 2)).into_dyn();
let mut model = Sequential::new();
model
.add(GlobalAveragePooling2D::new())
.add(Dense::new(3, 2, Activation::ReLU).unwrap())
.compile(RMSprop::new(0.001, 0.9, 1e-8, 0.0).unwrap(), MeanSquaredError::new());
model.fit(&x, &y, 2).unwrap();
model.save_to_path("pool_head.bin").unwrap();
// 用完全相同的架构重建,再加载。池化层不携带任何权重。
// 它仍然必须以相同的类型,占住相同的位置,结构校验才能通过。
let mut restored = Sequential::new();
restored
.add(GlobalAveragePooling2D::new())
.add(Dense::new(3, 2, Activation::ReLU).unwrap());
restored.load_from_path("pool_head.bin").unwrap();
let out = restored.predict(&x).unwrap();
assert_eq!(out.shape(), &[2, 2]);
std::fs::remove_file("pool_head.bin").unwrap();
}
完整的序列化来龙去脉见权重保存与加载和深入模型持久化,包括 postcard 格式、为什么优化器和损失函数不被保存,以及加载时权重形状如何校验。
3.6.8. 性能与开销
引擎在设计上就避免多余分配。它把整个输入当成一段连续的 &[f32] 来读。它的工作单元是单个输出位置 :对每一次窗口取样,一个串行循环把相邻的 channels 个浮点数折进一个 channels 宽的累加器。引擎最后用一次 from_shape_vec 拼出输出,而不是逐个标量下标去写。
开销随输出位置数、窗口体积、通道数三者相乘而增长。重叠窗口(步长小于池化窗口)比默认的不重叠情形做的活成比例地多。全局池化是所有情形里最便宜的:每个 batch 样本只需一次线性扫描。
前向传播用 rayon 按 (batch 样本, 输出位置块) 并行,各块写出的输出切片互不相交。按位置切分,而不是只按 batch 切分,即便 batch == 1 也能喂满所有线程。
反向传播则改按 (batch 样本, 通道条带) 切分。一条持有通道 [j0, j1) 的条带,只会写到对 channels 取模后落在该区间内的输入地址。于是这次散射既不需要 halo,也不需要归并或原子操作。
两条路径都只有在估算的总工作量 越过某个阈值之后才走并行。这个估算值是 batch * output_positions * channels * window_volume 次元素运算。阈值 POOL_PARALLEL_MIN_OPS 默认是 12,000,可以通过调优模块覆盖。这道闸门数的是总的取样次数,而不是任务数,这样无论工作量是压在少数几个宽通道位置上,还是摊在许多窄通道位置上,闸门的判断都保持公道。小张量会串行跑,免得白白付出 rayon 的任务开销。
想挪动这道闸门、并度量它的效果,见性能调优与并行。
有一条来自 Layer 约定的告诫要带上。反向传播是纯数学运算,不会清洗非有限值。最大池化还会故意传播 NaN。所以池化输入里的一个 NaN 会原样直接穿过去。要控制那些数值很大但仍是有限的梯度,请用优化器层面的全局范数裁剪。不要指望池化会把它们夹住。