rustyml 0.15.0

A high-performance machine learning & deep learning library in pure Rust, offering ML algorithms and neural network support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
# 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 个。光看元素个数,会把代价相差极大的两趟放到闸门的同一侧。各个位置按固定顺序相加,所以并行路径返回的比特与串行路径完全一致。