【RustyML入门】3.6. 池化层

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_shapeoutput_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 缓存,forwardpredict 之间的分界就变得重要。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 会原样直接穿过去。要控制那些数值很大但仍是有限的梯度,请用优化器层面的全局范数裁剪。不要指望池化会把它们夹住。

相关推荐
uzong1 小时前
业务新老系统数据迁移-负责人经验总结和复盘
后端
ctlover1 小时前
Python文件操作
开发语言·python
luj_17681 小时前
塔防牌:策略与卡牌的智慧碰撞
服务器·c语言·开发语言·经验分享·算法
dogstarhuang2 小时前
大模型 API 停服怎么办:用 API 网关实现多模型统一接入与可切换架构
人工智能·后端·架构·大模型·api·数字化转型·ai应用
wuyk5552 小时前
3.链表:用指针串联的动态数据结构
c语言·开发语言·数据结构·链表
苏灿烤鱼2 小时前
公司**不可计算,就自己做操作系统
rust·typescript·agent
程序猿DD2 小时前
OctaFuse Gateway 2.5.0:接入 Responses 端点、更易理解的路由配置
后端·agent
weixin_383196472 小时前
java基础面试题@Autowired和@Resource区别
java·开发语言
言乐63 小时前
Python游戏水平测试辅助系统2
开发语言·windows·python·游戏·django
我不是疯子是傻子3 小时前
Qt 实时曲线卡顿优化:从QPainter到OpenGL的3级加速实战
开发语言·qt