# 3.6. 池化层
池化对特征图做下采样。它把每个局部窗口归约成一个数,空间维度随之缩小,通道数保持不变。池化是[卷积层](./3.5._卷积层.md)里那些卷积的廉价对照物:没有权重,没有矩阵乘法,只是在滑动窗口上做一次归约。
RustyML 把池化实现成一个由 12 个层组成的家族,全部建立在同一套维度无关的引擎之上。本页依次讲解构造函数、输出形状、最大池化与平均池化的取舍,以及梯度如何往回路由。本页还会讲清楚“没有可学习参数”对 `summary()` 和模型持久化意味着什么。最后一节讲 3 个上采样层。它们把同一个想法反着跑,给解码器一条回到输入分辨率的路。
## 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`,所以各种失效模式都是明确的。错误分类体系见[错误处理](../Chapter-01/1.6._错误处理.md):
| 情形 | 错误 |
| --- | --- |
| `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`](./3.1._Sequential模型.md) 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`](./3.2._全连接层与激活函数.md) 在构造时就把 `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();
}
```
完整的序列化来龙去脉见[权重保存与加载](./3.9._权重保存与加载.md)和[深入模型持久化](../Chapter-07/7.2._深入模型持久化.md),包括 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 的任务开销。
想挪动这道闸门、并度量它的效果,见[性能调优与并行](../Chapter-07/7.3._性能调优与并行.md)。
有一条来自 [`Layer`](./3.1._Sequential模型.md) 约定的告诫要带上。反向传播是纯数学运算,不会清洗非有限值。最大池化还会故意传播 NaN。所以池化输入里的一个 NaN 会原样直接穿过去。要控制那些数值很大但仍是有限的梯度,请用优化器层面的全局范数裁剪。不要指望池化会把它们夹住。
## 3.6.9. 上采样:池化的逆操作
池化把空间轴缩小,上采样再把它们放大回来。`UpSampling1D`、`UpSampling2D` 和 `UpSampling3D` 把每个空间轴的长度乘上一个整数倍率。每一层都让 batch 轴和通道轴原封不动,并且不持有任何参数。这是写解码器的无参数做法:自编码器,或者必须回到输入分辨率的分割头。如果你要的是一个自己学习如何放大的解码器,见 [3.5. 卷积层](./3.5._卷积层.md) 里的转置卷积。
| 层 | 输入张量 | 倍率参数 | 输出 |
| --- | --- | --- | --- |
| `UpSampling1D` | `[N, L, C]` | `new(size)`,一个普通整数 | `[N, L * size, C]` |
| `UpSampling2D` | `[N, H, W, C]` | `new(size, interpolation)`,整数或 `(h, w)` | `[N, H * h, W * w, C]` |
| `UpSampling3D` | `[N, D, H, W, C]` | `new(size)`,整数或 `(d, h, w)` | `[N, D * d, H * h, W * w, C]` |
2D 和 3D 的倍率参数接收 `impl Into<Factor2D>` 和 `impl Into<Factor3D>`。整数给每个空间轴同一个倍率,元组则逐轴指定倍率。这里的元组要按[卷积层](./3.5._卷积层.md)里边界元组的读法来读。举例来说,`UpSampling2D::new((2, 3), ...)` 是高变成 2 倍、宽变成 3 倍。3 个构造函数都返回 `Result`,倍率为 0 会得到 `Error::InvalidParameter`,因为那会把张量清空。
默认模式是重复。每个输入位置被复制成 `factor` 个输出位置组成的块,所以 2 倍上采样出来的图像,就是原图里每个像素长成了一个 2x2 的方块。在形状上,这正是 `(2, 2)` 池化窗口的逆操作。在数值上则不是,因为池化把信息丢掉了,而任何上采样都编不回来。
```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_shape_fn((2, 8, 8, 3), |(_, i, j, c)| (i + j + c) as f32 * 0.1).into_dyn();
let mut model = Sequential::new();
model
.add(MaxPooling2D::new((2, 2), vec![2, 8, 8, 3]).unwrap()) // -> [2, 4, 4, 3]
.add(UpSampling2D::new(2, Interpolation::Nearest).unwrap()) // -> [2, 8, 8, 3]
.compile(SGD::new(0.01, 0.0, false, 0.0).unwrap(), MeanSquaredError::new());
let out = model.predict(&x).unwrap();
assert_eq!(out.shape(), &[2, 8, 8, 3]);
// 形状回来了,但大多数数值没有。池化每 4 个位置只留下 1 个。
let exact = out.iter().zip(x.iter()).filter(|&(a, b)| (a - b).abs() < 1e-6).count();
assert!(exact < x.len());
}
```
只有 `UpSampling2D` 接收插值模式。除重复之外的那 4 种模式都用可分离核重采样。因此一个新像素是它在每个轴上邻居的加权和:
| 模式 | 核 | 每轴读取的位置数 | 特点 |
| --- | --- | --- | --- |
| `Interpolation::Nearest` | 无,纯粹的重复 | 1 | 块状,也是唯一不编造数值的模式 |
| `Interpolation::Bilinear` | 三角核 | 3 | 平滑,且永远不会超出输入范围 |
| `Interpolation::Bicubic` | Keys 三次核,`a = -0.5` | 5 | 比双线性更锐,带一点点过冲 |
| `Interpolation::Lanczos3` | Lanczos,半径 3 | 7 | 更锐,带有肉眼可见的振铃 |
| `Interpolation::Lanczos5` | Lanczos,半径 5 | 11 | 最锐,振铃也最重 |
每种模式都把输出位置放在它所覆盖的源区域的中心,用的是半像素约定。输出位置 `j` 读取的源坐标是 `(j + 0.5) / factor - 0.5`。靠近边缘时,核有一部分落在图像之外,那些权重会被丢掉,剩下的权重再除以它们自己的和。因此任何一个输出位置的权重总是加起来等于 1。常量图像保持为常量,边缘也不例外。
```rust
use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;
use ndarray::Array;
fn main() {
// 一张 2x2 的图,用三角核放大 2 倍。
let x = Array::from_shape_vec((1, 2, 2, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap().into_dyn();
let mut smooth = UpSampling2D::new(2, Interpolation::Bilinear).unwrap();
let out = smooth.forward(&x).unwrap();
// 角上的位置保留源值,因为那里的核被裁到只剩 1 个位置。
assert!((out[[0, 0, 0, 0]] - 1.0).abs() < 1e-6);
assert!((out[[0, 3, 3, 0]] - 4.0).abs() < 1e-6);
// 内部位置混合 4 个邻居:0.5625*1 + 0.1875*2 + 0.1875*3 + 0.0625*4。
assert!((out[[0, 1, 1, 0]] - 1.75).abs() < 1e-6);
// 一个输出位置的权重加起来是 1,所以常量图像保持为常量。
let flat = Array::from_elem((1, 3, 4, 2), 7.5f32).into_dyn();
for mode in [Interpolation::Bicubic, Interpolation::Lanczos3, Interpolation::Lanczos5] {
let mut layer = UpSampling2D::new(3, mode).unwrap();
let same = layer.forward(&flat).unwrap();
assert!(same.iter().all(|v| (v - 7.5).abs() < 1e-5));
}
}
```
三次核和 Lanczos 核都有负的旁瓣,所以它们的输出可以跑到输入范围之外。暗背景上的一个亮点回来时会带一圈暗晕,一张概率图回来时也可能略微低于 0。范围要紧的话,就在这一层之后做一次夹取。重复模式和双线性不会过冲,因为它们的权重全是正的。
反向传播就是这同一次加权聚集的转置。每个输入位置按同样的权重,收集它喂过的每个输出位置的梯度。对重复模式来说这就是一次求和:喂过 `factor` 个输出位置的那个位置,拿到的是这 `factor` 个梯度的总和。
Lanczos 核有会相互抵消的大旁瓣,所以用 `f32` 构建权重表会在碰到任何一个像素之前就损失精度。本层用 `f64` 构建这张表,只在最后取整一次。以一个独立的 `f64` 参考实现为准,在 250 组形状、倍率与模式组合上,`Lanczos5` 的最大偏差是 `2.5e-7`。
代价是每个输出元素、每个读取位置 1 次乘加,而且是每个空间轴付一次,不是每个输出像素付一次。重复模式只读 1 个位置,按整段通道来复制,跑在内存速度上,而 `Lanczos5` 读 11 个,代价大约是 11 倍。当 `输出元素数 x 每轴读取位置数` 越过 `tuning::upsampling::set_parallel_min_ops`(默认 2,000,000)之后,每个轴的那一趟就走 rayon 并行。这道闸门数的是元素运算次数而不是元素个数,因为重复模式每轴只读 1 个位置而 `Lanczos5` 读 11 个。光看元素个数,会把代价相差极大的两趟放到闸门的同一侧。各个位置按固定顺序相加,所以并行路径返回的比特与串行路径完全一致。