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
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
# 3.5. 卷积层

RustyML 提供 10 种卷积层。`Conv1D`、`Conv2D`、`Conv3D` 是普通层。`Conv1DTranspose`、`Conv2DTranspose`、`Conv3DTranspose` 是它们的 3 个转置对应层。`DepthwiseConv1D`、`DepthwiseConv2D`、`SeparableConv1D` 和 `SeparableConv2D` 是深度卷积与可分离卷积这两对层。这 10 种层都位于 `rustyml::neural_network::layers`,并由 layers 的 glob 导入统一重新导出。

它们共享同一套设计:层结构体持有权重、偏置、激活函数,以及前向和反向传播的缓存。层把普通卷积的实际数值运算交给一个维度泛型的引擎完成。

如果你用过 Keras,这里的布局和权重形状你已经很熟悉:张量是 channels-last,kernel 的抽头排在最前。有一点容易让新手在一开始就踩坑:你要把完整的输入形状传进构造函数。这样层就能提前确定权重尺寸,而不是等到第一个 batch 到来时才惰性推断。

本页依次介绍这些布局、构造函数与填充规则、驱动标准卷积的 im2col + GEMM 引擎,深度可分离卷积的分解和它的开销计算,把卷积反着跑从而把张量放大的转置卷积,手动改动空间轴尺寸的边界层,重排轴或复制向量的那两个层,什么都不做的那个层,以及这些层会返回的错误类型。

## 3.5.1. 张量布局与层家族

这里的每一种卷积都是 channels-last:空间轴跟在 batch 轴之后,通道轴排在最末。`Conv2D` 读入 `[batch, height, width, channels]`,输出 `[batch, out_height, out_width, filters]`。这就是 Keras 和 TensorFlow 的 `NHWC` 约定。

为 Keras 准备的张量拿过来就能用,不需要任何置换。如果你用 `ndarray` 手工构造输入,记得把通道放在最后一个轴。权重张量用的也是 Keras 的 kernel 形状:先是 kernel 的各个抽头,然后是输入通道,最后是 filters。

这不只是接口上的选择。通道轴在最内层时,某个 kernel 抽头在某个输出位置上对应的就是 `Cin` 个连续的 float。这让引擎可以用整段拷贝来搭 im2col 矩阵,而不必逐标量 gather。这也让扁平的权重矩阵不经任何置换就能和它对齐。

| 层 | 输入张量 | 权重张量 | 偏置 | 输出张量 |
| --- | --- | --- | --- | --- |
| `Conv1D` | `[N, L, Cin]` | `[k, Cin, F]` | `[F]` | `[N, L', F]` |
| `Conv2D` | `[N, H, W, Cin]` | `[kh, kw, Cin, F]` | `[F]` | `[N, H', W', F]` |
| `Conv3D` | `[N, D, H, W, Cin]` | `[kd, kh, kw, Cin, F]` | `[F]` | `[N, D', H', W', F]` |
| `Conv1DTranspose` | `[N, L, Cin]` | `[k, F, Cin]` | `[F]` | `[N, L', F]` |
| `Conv2DTranspose` | `[N, H, W, Cin]` | `[kh, kw, F, Cin]` | `[F]` | `[N, H', W', F]` |
| `Conv3DTranspose` | `[N, D, H, W, Cin]` | `[kd, kh, kw, F, Cin]` | `[F]` | `[N, D', H', W', F]` |
| `DepthwiseConv1D` | `[N, L, C]` | `[k, C, dm]` | `[C*dm]` | `[N, L', C*dm]` |
| `DepthwiseConv2D` | `[N, H, W, C]` | `[kh, kw, C, dm]` | `[C*dm]` | `[N, H', W', C*dm]` |
| `SeparableConv1D` | `[N, L, Cin]` | depthwise `[k, Cin, dm]`,pointwise `[1, Cin*dm, F]` | `[F]` | `[N, L', F]` |
| `SeparableConv2D` | `[N, H, W, Cin]` | depthwise `[kh, kw, Cin, dm]`,pointwise `[1, 1, Cin*dm, F]` | `[F]` | `[N, H', W', F]` |

这些形状和 Keras 用的完全一致,所以从 Keras 导出的 kernel 不需要任何置换就能直接使用。注意这 3 个转置 kernel 把 filter 轴排在输入通道轴前面,和普通 kernel 正好相反,原因见 3.5.5 节。

深度卷积和可分离卷积这 4 个层需要多说几句。深度卷积根本没有 `filters` 参数:它给每个输入通道配 `depth_multiplier` 个 kernel,产出 `C * dm` 个通道。输入通道 `c` 的第 `m` 个 kernel 落在输出通道 `c * dm + m` 上。

可分离卷积持有两个权重张量,因为它把两个卷积融进了一层。depthwise 阶段吐出通道的顺序正好就是 `c * dm + m`。这意味着 pointwise 权重的行天然就和它对得上,两个阶段之间不需要任何重排。细节见 3.5.4 节。

前向计算是互相关(cross-correlation),这与 Keras 和 PyTorch 的约定一致。引擎不会翻转 kernel。偏置在最后加上,每个 filter 加一次,发生在乘累加之后。权重按 Xavier/Glorot 均匀分布初始化。偏置从零开始。

下面这个例子手动设置权重,跑一次前向传播,直观地展示布局和 Valid 填充下的输出尺寸。

```rust
use ndarray::{Array, Array1, Array4};
use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;

fn main() {
    // channels-last:[batch, height, width, channels]。
    let mut layer = Conv2D::new(1, (2, 2), vec![1, 4, 4, 1], (1, 1), Activation::Linear).unwrap();

    // 权重是 [kernel_h, kernel_w, channels, filters],偏置是 [filters]。
    let weights = Array4::from_elem((2, 2, 1, 1), 1.0f32);
    let bias = Array1::zeros(1);
    layer.set_weights(weights, bias).unwrap();

    let pixels: Vec<f32> = (1..=16).map(|v| v as f32).collect();
    let x = Array::from_shape_vec((1, 4, 4, 1), pixels).unwrap().into_dyn();

    let out = layer.forward(&x).unwrap();
    // Valid 填充:每个空间轴 (4 - 2)/1 + 1 = 3 -> [1, 3, 3, 1]。
    assert_eq!(out.shape(), &[1, 3, 3, 1]);
    // 全 1 的 2x2 核就是窗口求和:第一个窗口是 1 + 2 + 5 + 6 = 14。
    assert_eq!(out[[0, 0, 0, 0]], 14.0);
    println!("output shape: {:?}", out.shape());
}
```

## 3.5.2. 构造函数、填充与输出形状

构造函数按位置接收各个超参数。kernel 和 stride 参数的形状随秩变化。所有 1D 的层都用标量的 `kernel_size` 和 `stride`。2D 和 3D 的层则用元组。

可分离卷积在激活函数之前插入了一个 `depth_multiplier` 参数。深度卷积没有 `filters` 参数。和 Keras 一样,它的输出宽度由输入推出来,深度倍数则通过一个 builder 方法设置。

```rust,ignore
Conv1D::new(filters, kernel_size: usize, input_shape: Vec<usize>, stride: usize, activation) -> Result<Conv1D, Error>
Conv2D::new(filters, kernel_size: (usize, usize), input_shape: Vec<usize>, strides: (usize, usize), activation) -> Result<Conv2D, Error>
Conv3D::new(filters, kernel_size: (usize, usize, usize), input_shape: Vec<usize>, strides: (usize, usize, usize), activation) -> Result<Conv3D, Error>
DepthwiseConv1D::new(kernel_size: usize, input_shape: Vec<usize>, stride: usize, activation) -> Result<DepthwiseConv1D, Error>
DepthwiseConv1D::with_depth_multiplier(self, depth_multiplier: usize) -> Result<DepthwiseConv1D, Error>   // builder,默认 1
DepthwiseConv2D::new(kernel_size: (usize, usize), input_shape: Vec<usize>, strides: (usize, usize), activation) -> Result<DepthwiseConv2D, Error>
DepthwiseConv2D::with_depth_multiplier(self, depth_multiplier: usize) -> Result<DepthwiseConv2D, Error>   // builder,默认 1
SeparableConv1D::new(filters, kernel_size: usize, input_shape: Vec<usize>, stride: usize, depth_multiplier: usize, activation) -> Result<SeparableConv1D, Error>
SeparableConv2D::new(filters, kernel_size: (usize, usize), input_shape: Vec<usize>, strides: (usize, usize), depth_multiplier: usize, activation) -> Result<SeparableConv2D, Error>
```

`activation` 的类型是 `impl Into<Activation>`。你可以传一个 `Activation` 枚举变体(`Activation::ReLU`、`Activation::Sigmoid`、`Activation::Tanh`、`Activation::Softmax`、`Activation::Linear`),也可以传一个独立的激活层,比如 `ReLU::new()`。你传入的 `input_shape` 是包含 batch 维度在内的完整期望输入。真正决定权重尺寸的只有 `input_shape[1..]`(通道和各个空间尺寸)。你写的 batch 值只起说明作用。激活函数集合的详细介绍见 [3.2. 全连接层与激活函数](./3.2._全连接层与激活函数.md)。

有 3 个 builder 方法可以进一步调整已构造的层。每个方法都消费并返回 `self`,因此可以链式调用。`with_padding(PaddingType)` 切换填充模式。`with_random_state(u64)` 用一个种子确定性地重跑 Xavier 初始化。在设置自定义权重或开始训练之前调用它(见 [7.1. 可复现性与随机种子](../Chapter-07/7.1._可复现性与随机种子.md))。

`set_weights(...)` 装入你自己控制的权重和偏置。`set_weights` 会用层的期望形状校验每一个数组。注意深度卷积的 `set_weights` 接收一个 kernel 和一个 `Array1` 偏置。可分离卷积的则接收 3 个数组:depthwise 权重、pointwise 权重,然后是偏置。

填充用的是 `PaddingType` 枚举,它有 2 个变体:`Valid`(默认)和 `Same`。它按下面的规则决定输出尺寸:

- `Valid` 不做任何填充。它只在 kernel 完全覆盖输入的位置计算输出值,公式是 `out = (in - k) / stride + 1`(整数向下取整),在每个空间轴上都适用。对普通卷积层来说,每个输入轴都必须不小于 kernel 尺寸。
- `Same` 在边界补零,使得 `out = ceil(in / stride)`。当 `stride == 1` 时,空间尺寸原样保留。stride 更大时,输出等于输入长度除以 stride 再向上取整。层把总填充量平均拆开,多出来的一格放在尾部(`pad_before = pad_total / 2`),这与 TensorFlow 的 `SAME` 一致。

下面这组数字展示了这条规则的实际效果。一个 `[N, 8, 8, 1]` 输入经过 `3x3` 的 kernel,可以得到 3 种结果。Valid、stride 1 得到 `(8-3)/1+1 = 6`,输出是 `[N, 6, 6, F]`。Same、stride 1 得到 `8`,输出是 `[N, 8, 8, F]`。Same、stride 2 得到 `ceil(8/2) = 4`。一个 `Conv1D` 处理长度 6、kernel 3、stride 2,在 Valid 填充下得到 `(6-3)/2+1 = 2`。

`Conv3D` 在深度、高度、宽度上各自独立套用同一条规则。3 个转置层接收同样的参数和同样的 `PaddingType`。它们的两条输出规则都是把轴放大而不是缩小,具体见 3.5.5 节。`model.summary()` 会打印这些输出形状和每一层的参数量。用它在训练之前检查一个网络栈。完整的 `summary` 说明见 [3.1. Sequential 模型](./3.1._Sequential模型.md)。

## 3.5.3. im2col + GEMM 引擎

`Conv1D`、`Conv2D` 和 `Conv3D` 并不各自维护一套循环嵌套。普通卷积在任何秩上都是同一个操作,变的只是空间轴的数量。这 3 种层都把前向和反向的数值计算交给 `convolution_engine.rs` 里的同一份实现。

这份实现对空间秩 `R = ndim - 2` 泛型。层这一层包装保留公开 API、权重存储、激活函数和缓存。引擎负责真正的算术。

引擎的做法是 im2col 加 GEMM,这也是主流框架用的策略。前向时,它把每个输出窗口收集成一行。这样就拼出一个 `[out_plane, k_plane*Cin]` 矩阵,它的列与展平后的权重矩阵 `[k_plane*Cin, F]` 对齐。一次矩阵乘法就能产出 `[out_plane, F]`,引擎再按 filter 加上偏置。

在 channels-last 布局下,这次收集是一连串宽度为 `Cin` 的 `copy_from_slice`,而不是逐标量地走一遍。乘法结果也直接落在输出的一整段连续内存上,不需要任何散射。用一次矩阵乘法换掉 6 层深的循环嵌套,让这个层能依托 crate 内部调优过的 GEMM,而不必退回朴素三重循环。关于这个 GEMM 的更多内容见 [6.2. 矩阵乘法](../Chapter-06/6.2._矩阵乘法.md)。

反向时,每个 batch 元素跑 2 次 GEMM。一次算权重梯度。另一次算输入梯度的列,引擎再把它散射回(col2im)输入梯度张量。

是否并行由估算的 FLOPs 决定,而不是元素个数。`7x7x512` 的卷积和 `3x3x3` 的卷积可能有相同的输出元素数,但开销相差很大。前向的判据是把 `2 * batch * F * out_plane * Cin*k` 与 `CONV_PARALLEL_MIN_FLOPS`(默认 4,000,000,可以通过 `rustyml::tuning` 在运行时调节)作比较。

低于这个阈值,前向就串行执行。高于阈值,前向便按 `(batch 元素, 输出位置块)` 划分任务并行。这样即便 `batch == 1`,单张大图也能占满每一个核。每个任务构建自己的 im2col 块,并把 GEMM 结果写进互不相交的输出区域。

反向按 batch 元素并行。它按 batch 顺序归约权重和偏置的部分和,这样同一台机器上多次运行的结果就能逐位可复现。它还让每个元素的 GEMM 走一个开关。当 batch 太短、填不满线程池时,这个开关让 GEMM 保持并行。一旦 batch 本身就已占满线程池,它就把 GEMM 切成串行。这样各个 batch 任务就不会各自在 GEMM 内部再 fork 一次 rayon。

并行路径和串行路径给出的数值完全一致,所以你几乎不需要去动这个阈值。如果你分析的某个负载恰好卡在阈值之下,[7.3. 性能调优与并行](../Chapter-07/7.3._性能调优与并行.md)会讲怎么调整它。

## 3.5.4. 深度卷积与可分离卷积

标准卷积一步就同时在通道和空间两个维度上做混合:每个输出通道都是所有输入通道、所有 kernel 抽头上的加权和。参数正是藏在这种耦合里。权重张量的规模是 `F * Cin * kh * kw`。

深度可分离卷积把这个操作拆成 2 个更便宜的阶段。第一阶段是一个 depthwise 卷积,独立地过滤每个输入通道(只做空间混合,不做跨通道混合)。第二阶段是一个 pointwise 的 `1x1` 卷积,把通道重新组合(只做跨通道混合,没有空间范围)。这就是 MobileNet 和 Xception 的思路。RustyML 把这两个阶段都暴露了出来。

`DepthwiseConv2D` 就是单独的第一阶段。它为每个输入通道各携带 `depth_multiplier` 个 `kh x kw` 的 kernel,产出 `C * depth_multiplier` 个输出通道。这就是它没有 `filters` 参数的原因,和 Keras 完全一样。它不做任何通道重组,所以单靠自己无法混合通道。实践中,你几乎总是在下游给它搭配一个 `1x1` 卷积。

`depth_multiplier` 默认为 1。用 `with_depth_multiplier` 设置它,它返回 `Result`,因为 0 会被拒绝。可分离卷积按位置接收自己的 `depth_multiplier`。它先把中间通道数扩展到 `Cin * depth_multiplier`,再由 pointwise 阶段把它收缩回 `filters`。

参数量的算账才是这种分解的重点。设 `Cin` 个输入通道、`F` 个输出 filter、一个 `kh x kw` 的 kernel:

- 标准 `Conv2D`:`F * Cin * kh * kw + F`。
- `DepthwiseConv2D`:`C * dm * kh * kw + C * dm`(在默认 `dm = 1` 时,就是 `C * kh * kw + C`)。
- `SeparableConv2D`:`dm * Cin * kh * kw`(depthwise)`+ F * Cin * dm`(pointwise)`+ F`(偏置)。

忽略偏置项,可分离与标准的参数比是 `1/F + 1/(kh*kw)`。filter 数越多、kernel 面积越大,省下的就越多。由于每个参数在每个输出位置上都对应一次乘累加,这个比值同时也是计算量(FLOP)之比。

举个例子,取一个作用于 3 个通道、64 个 filter 的 `3x3` 卷积。标准层有 `64*3*3*3 + 64 = 1792` 个参数。等价的可分离版本有 `27 + 192 + 64 = 283` 个参数,少了大约 6.3 倍。只做 depthwise、作用于这 3 个通道的层只有 `30` 个参数。下面这段程序把这 3 种层都建出来,核对参数量。

```rust
use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;

fn count(p: TrainingParameters) -> usize {
    match p {
        TrainingParameters::Trainable(n) | TrainingParameters::NonTrainable(n) => n,
        TrainingParameters::NoTrainable => 0,
    }
}

fn main() {
    let input_shape = vec![1, 32, 32, 3]; // [batch, H, W, channels]

    // 作用于 3 个输入通道的标准 64-filter 3x3 卷积。
    let standard =
        Conv2D::new(64, (3, 3), input_shape.clone(), (1, 1), Activation::ReLU).unwrap();

    // 等价的可分离版本:depthwise(depth_multiplier = 1)后接一个到 64 filter 的 pointwise 1x1。
    let separable =
        SeparableConv2D::new(64, (3, 3), input_shape.clone(), (1, 1), 1, Activation::ReLU).unwrap();

    // 只做 depthwise:产出 C * depth_multiplier = 3 个通道,不做跨通道混合。
    let depthwise = DepthwiseConv2D::new((3, 3), input_shape, (1, 1), Activation::ReLU).unwrap();

    println!("standard  Conv2D params: {}", count(standard.param_count()));
    println!("separable Conv2D params: {}", count(separable.param_count()));
    println!("depthwise Conv2D params: {}", count(depthwise.param_count()));

    assert_eq!(count(standard.param_count()), 1792); // 64*3*3*3 + 64
    assert_eq!(count(separable.param_count()), 283); // 27 + 192 + 64
    assert_eq!(count(depthwise.param_count()), 30); // 3*3*3 + 3
}
```

同一套算术在序列上同样成立,而序列里通道数很宽也一样常见。1D 的 kernel 少一根高度轴,所以 depthwise 阶段是 `[k, C, dm]`,pointwise 阶段是 `[1, Cin*dm, F]`。其余部分原样照搬:`c * dm + m` 的通道顺序、填充规则,以及那几个 builder 方法。下面这个程序在一条 128 步、每步 32 通道的序列上跑同样的对比,可分离形式大约小 4.5 倍。

```rust
use ndarray::Array3;
use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;

fn count(p: TrainingParameters) -> usize {
    match p {
        TrainingParameters::Trainable(n) | TrainingParameters::NonTrainable(n) => n,
        TrainingParameters::NoTrainable => 0,
    }
}

fn main() {
    // [batch, length, channels]:128 个时间步,每步携带 32 个通道。
    let input_shape = vec![1, 128, 32];

    // 标准卷积:64 个 filter,宽度 5,输入 32 个通道。
    let standard = Conv1D::new(64, 5, input_shape.clone(), 1, Activation::ReLU).unwrap();

    // 等价的可分离版本:先做宽度 5 的 depthwise,再用 1 抽头的 pointwise 收到 64 个 filter。
    let mut separable =
        SeparableConv1D::new(64, 5, input_shape.clone(), 1, 1, Activation::ReLU).unwrap();

    // 只做 depthwise:产出 C * depth_multiplier = 32 个通道,通道之间不做任何混合。
    let depthwise = DepthwiseConv1D::new(5, input_shape, 1, Activation::ReLU).unwrap();

    println!("standard  Conv1D params: {}", count(standard.param_count()));
    println!("separable Conv1D params: {}", count(separable.param_count()));
    println!("depthwise Conv1D params: {}", count(depthwise.param_count()));

    assert_eq!(count(standard.param_count()), 10304); // 64*32*5 + 64
    assert_eq!(count(separable.param_count()), 2272); // 160 + 2048 + 64
    assert_eq!(count(depthwise.param_count()), 192); // 32*5 + 32

    // Valid 填充:(128 - 5)/1 + 1 = 124 个位置,每个位置携带 64 个 filter。
    let x = Array3::<f32>::zeros((1, 128, 32)).into_dyn();
    assert_eq!(separable.forward(&x).unwrap().shape(), &[1, 124, 64]);
}
```

在通道数很高、你想削减参数和计算量的场景中使用这些层,比如很宽的特征图,或者一个打算跑在普通硬件上的模型。作为交换,你要接受一个建模上的取舍:在相同的 `F` 和 kernel 下,这种分解形式的表达能力严格弱于完整卷积。

它们的引擎和标准层不同。depthwise 那一趟用的是直接的循环嵌套,因为各通道相互独立,im2col 在这里省不了多少。它按 `(batch 元素, 输出行)` 划分任务并行。各输出行互不相交,所以这样切分不需要任何归并。它的判据是 `NAIVE_CONV_PARALLEL_MIN_FLOPS`(默认 1,000,000)。

可分离卷积的 depthwise 阶段走的是同一条朴素路径。pointwise 阶段则重新交给共享的 im2col + GEMM 引擎。1 抽头的卷积恰好就是一次逐位置的跨通道矩阵乘法。

这 4 个层在两种秩上共用同 1 套 depthwise 循环嵌套。一个 `[batch, length, channels]` 张量,存放数值的顺序和 `[batch, 1, length, channels]` 完全一样;一个 `[k, C, dm]` kernel,顺序也和 `[1, k, C, dm]` 完全一样。于是 1D 的层把共享几何结构里的高度项设成 1,再把自己那些秩为 3 的数组的扁平切片交出去。没有任何重排发生,1D 和 2D 两种形态也就不会各自跑偏。

## 3.5.5. 转置卷积

`Conv1DTranspose`、`Conv2DTranspose` 和 `Conv3DTranspose` 在空间轴上反着跑一次卷积。卷积把张量变小,所以它的转置把张量变大。解码器或生成器要爬回与它对应的那个 `Conv2D` 当初吃进去的分辨率,用的就是这个层。和 `UpSampling2D` 不同,它自己学习怎么放大,因为它携带一个 kernel 和一个偏置。

这个名字是字面意思。卷积是一个线性映射,所以它有转置,而这个层算的正是那个转置。这里的前向传播恰好就是普通卷积对自己输入算出的梯度。这里的反向传播恰好就是一次普通的前向传播。RustyML 就是照这个思路实现的。转置引擎复用标准引擎的每一个几何辅助函数,只把那 2 次矩阵乘法的方向反过来。

所以一次转置卷积的开销,和它所转置的那次卷积差不多。请把“反卷积”(deconvolution)当成一个要避开的同义词。这个层恢复的是形状,永远不是数值。

每个输入位置都把自己的值乘上整个 kernel 写进输出,起点在 `position * stride`。凡是 stride 小于 kernel 尺寸的地方,相邻窗口会重叠,重叠处写入的值累加起来。凡是 stride 大于 kernel 尺寸的地方,有些输出位置什么都收不到,它们的值就正好等于偏置。偏置在最后加上,每个输出位置加一次。

输出尺寸每种填充模式各有 1 条规则,在每个空间轴上各自独立套用:

| 填充 | 1 个轴的输出尺寸 | stride 为 1 时 |
| --- | --- | --- |
| `Valid` | `input * stride + max(kernel - stride, 0)` | `input + kernel - 1` |
| `Same` | `input * stride` | `input` |

这几个构造函数和普通卷积的构造函数逐个参数一一对应,`with_padding` 和 `with_random_state` 这 2 个 builder 方法也可以进一步调整它们:

```rust,ignore
Conv1DTranspose::new(filters, kernel_size: usize, input_shape: Vec<usize>, stride: usize, activation) -> Result<Conv1DTranspose, Error>
Conv2DTranspose::new(filters, kernel_size: (usize, usize), input_shape: Vec<usize>, strides: (usize, usize), activation) -> Result<Conv2DTranspose, Error>
Conv3DTranspose::new(filters, kernel_size: (usize, usize, usize), input_shape: Vec<usize>, strides: (usize, usize, usize), activation) -> Result<Conv3DTranspose, Error>
```

权重布局上有 1 处差别容易让人栽跟头。kernel 是 `[k..., filters, channels]`,也就是 filter 轴排在输入通道轴**前面**。这和普通卷积的 kernel 正好相反。这也正是 Keras 为这几个层采用的顺序。原因在于传播的方向:转置卷积读入 `channels`,写出 `filters`。

因此,当 filter 数和通道数不同时,`set_weights` 会拒绝一个按对应 `Conv2D` 布局排好的 kernel。当这 2 个数相等时,两种布局的形状完全一样,任何形状检查都分不出它们。`param_count` 不受影响,因为不论哪种顺序,乘积都一样。

第二处差别是,这几个层对输入的空间尺寸不设下限。`Conv2D::new` 会拒绝比自己 kernel 还小的输入,因为这样的卷积找不到任何一个合法窗口。转置卷积的问题正好反过来,所以 `3x3` 的 kernel 配一个 `1x1` 的输入是合法的。这正是一个从 `Dense` 头出发的解码器的常规第一步。

下面这个例子把一张 `2x2` 的特征图放大成 `4x4`,并手工核对 16 个输出值里的 2 个。

```rust
use ndarray::{Array, Array1, Array4};
use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;

fn main() {
    // channels-last:[batch, height, width, channels]。
    let mut layer =
        Conv2DTranspose::new(1, (3, 3), vec![1, 2, 2, 1], (2, 2), Activation::Linear)
            .unwrap()
            .with_padding(PaddingType::Same);

    // 权重是 [kernel_h, kernel_w, filters, channels]:filter 轴排在前面。
    let taps: Vec<f32> = (1..=9).map(|v| v as f32).collect();
    let weights = Array4::from_shape_vec((3, 3, 1, 1), taps).unwrap();
    layer.set_weights(weights, Array1::zeros(1)).unwrap();

    let x = Array::from_shape_vec((1, 2, 2, 1), vec![1.0f32, 2.0, 3.0, 4.0])
        .unwrap()
        .into_dyn();
    let out = layer.forward(&x).unwrap();

    // Same 填充:每个空间轴 2 * 2 = 4 -> [1, 4, 4, 1]。
    assert_eq!(out.shape(), &[1, 4, 4, 1]);
    // 输入 (0, 0) 从输出 (0, 0) 起缩放整个 kernel,所以 out[0, 0] 是 1 * w[0, 0]。
    assert_eq!(out[[0, 0, 0, 0]], 1.0);
    // 输出 (2, 2) 是 4 个输入都能到达的那个位置:1*9 + 2*7 + 3*3 + 4*1 = 36。
    assert_eq!(out[[0, 2, 2, 0]], 36.0);
    println!("output shape: {:?}", out.shape());
}
```

这种重叠有肉眼可见的代价。当 stride 不能整除 kernel 尺寸时,有些输出位置收到的 kernel 抽头比邻居更多。训练好的模型会把这种不均衡变成一片明暗相间的规则网格。这就是棋盘格伪影。

让 stride 能整除 kernel 尺寸就能消除它。stride 2 配 `4x4` 的 kernel,是比 stride 2 配 `3x3` 的 kernel 更安全的默认选择。另一条路是 `UpSampling2D` 后接一个普通 `Conv2D`,这种组合根本不可能产生这种伪影。

只有当卷积保住了每一个输入位置时,回到原始尺寸的这趟来回才是精确的。在 `Valid` 下,这意味着 kernel 不小于 stride,并且 stride 能整除 `input - kernel`。否则卷积会丢掉最后一个窗口够不着的那些尾部位置,而转置卷积没有任何依据把它们重建出来。一个宽度为 8 的轴,在 kernel 3、stride 2 下卷积得到 3 个位置,而这 3 个位置转置回去是 7,不是 8。

在 `Same` 下,条件是 stride 能整除 `input`,整除不了则不是变短,而是涨到下一个 stride 的整数倍。Keras 用一个 `output_padding` 参数来兜住这一点。RustyML 没有这个参数。要么选一个能整除的 stride,要么事后用 3.5.7 节里的边界层调整尺寸。

这 3 个层共用标准引擎那道并行闸门 `tuning::conv::set_parallel_min_flops`,因为两个引擎跑的是同样那 2 种矩阵乘法形状。转置的前向传播只按 batch 元素并行。它的散射写入会累加到互相重叠的输出位置上,所以把 1 张图切给多个线程就需要一次归并,而 batch 元素之间从不需要这种归并。batch 填不满线程池时,改由每个元素各自的矩阵乘法并行,所以 batch 为 1 也照样能用上每一个核。

## 3.5.6. 搭建一个小型 CNN

卷积输出的是 3 阶、4 阶或 5 阶张量,但 `Dense` 分类头要的是 2 阶的 `[batch, features]` 矩阵。`Flatten` 就是这两者之间的桥梁。`Flatten::new(input_shape: Vec<usize>)` 构造一个无参数的层,把 `[batch, ...]` reshape 成 `[batch, 其余维度的乘积]`。它在前向时接受 3D、4D 或 5D 输入。你把进入它的张量形状交给它,也就是卷积的输出形状。它就据此算出展平后的特征数。

`Reshape` 是同一个思路的通用形式。`Reshape::new(target_shape: Vec<isize>)` 构造一个无参数的层,它改写 batch 轴之后的所有轴。`target_shape` 从不描述 batch 轴:0 号轴原样穿过,所以一个实例就能服务任意 batch 大小。`Flatten::new` 在这一点上不同,它接收一个固定的输入形状,并在构造时就绑定到这个形状上。

`target_shape` 里最多只能有 1 项是 `-1`。这个轴会取一个能让元素总数对上的长度。`Reshape::new(vec![-1, 2])` 作用在 `[batch, 4]` 输入上得到 `[batch, 2, 2]`,因为 `4 / 2 = 2`。`Reshape::new(vec![-1])` 把 batch 轴之后的所有轴压成 1 个轴,所以它和 `Flatten` 完全等价。空的 `target_shape` 也是合法的,它给出 1 阶的输出形状 `[batch]`。

这 2 个层服务于不同的方向。`Flatten` 是从卷积栈走向 `Dense` 头。`Reshape` 还能反着走,把 `Dense` 的输出重新变回一个立体的张量。解码器需要的正是这个方向。

接下来,3.5.5 节的转置卷积用一个学出来的 kernel 把空间轴放大回去。无参数的 `UpSampling1D`、`UpSampling2D`、`UpSampling3D` 不用 kernel 也能做到同样的事。上采样的形状和 `Interpolation` 模式见 [3.6. 池化层](./3.6._池化层.md)。

构造函数会拒绝多于 1 个 `-1`、拒绝 `0`,也拒绝任何小于 `-1` 的值,每一种都返回 `Error::InvalidParameter`。元素总数对不上则是前向时的错误,返回 `Error::ShapeMismatch`。举个例子,`vec![2, 3]` 要求每个样本有 6 个元素,而 `[5, 4]` 的输入只有 4 个。

`Reshape::new` 有意拒绝 `target_shape` 里的 `0`。任何非空输入都不可能匹配一个长度为 `0` 的轴。构造函数把这个错误提前抛出,而不是等到第一次前向传播。合法程序的集合并没有改变,变的只是报错的时刻提前了。

和 `Flatten` 一样,`Reshape` 不搬动任何数据。它按 C 序读写,也就是最后一个轴变化最快。channels-last 布局把通道轴放在最内层,所以一个拆开或合并末尾若干轴的 reshape,是先重组通道,再轮到空间位置。`Flatten` 用的是同一套顺序,因此这 2 个层对每个元素落在哪里的看法是一致的。读一个目标形状时,请带着这个顺序去读。

下面这段程序借助 1 个推断出来的轴,把一个 2 阶的 batch 折成一个立体的张量:

```rust
use ndarray::Array2;
use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;

fn main() {
    // 5 个样本,每个样本 4 个特征。
    let x = Array2::<f32>::from_shape_fn((5, 4), |(n, k)| (n * 4 + k) as f32).into_dyn();

    // 目标形状描述的是 batch 轴之后的各个轴。-1 取 4 / 2 = 2。
    let mut layer = Reshape::new(vec![-1, 2]).unwrap();
    let folded = layer.forward(&x).unwrap();

    assert_eq!(folded.shape(), &[5, 2, 2]);
    // C 序:最后一个轴变化最快,所以样本 0 折成 [[0, 1], [2, 3]]。
    assert_eq!(folded[[0, 1, 0]], 2.0);

    // 形状与输出一致的梯度会还原出输入的形状。
    let grad = layer.backward(&folded).unwrap();
    assert_eq!(grad.shape(), &[5, 4]);

    println!("folded shape: {:?}", folded.shape());
}
```

接下来这个例子搭建一个完整的网络栈。一个 `Conv2D` 作用于合成的单通道 `8x8` 图像。`Flatten` 再把结果送入一个 `Dense` 回归头。[Sequential 模型](./3.1._Sequential模型.md)负责训练这个网络栈几个 epoch。

```rust
use ndarray::{Array2, Array4};
use rustyml::neural_network::layers::*;
use rustyml::neural_network::losses::*;
use rustyml::neural_network::optimizers::*;
use rustyml::neural_network::sequential::Sequential;

fn main() {
    // 合成「图像」:6 个样本,8x8,1 个通道。
    let mut x = Array4::<f32>::zeros((6, 8, 8, 1));
    for n in 0..6 {
        for i in 0..8 {
            for j in 0..8 {
                x[[n, i, j, 0]] = ((n + i + j) as f32 * 0.1).sin();
            }
        }
    }
    let x = x.into_dyn();

    // 每个样本 3 个回归目标。
    let y = Array2::<f32>::from_shape_fn((6, 3), |(n, k)| (n as f32) * 0.01 + (k as f32) * 0.1)
        .into_dyn();

    let mut model = Sequential::new();
    model
        // [6, 8, 8, 1] -> Conv2D(4 filters, 3x3, Valid) -> [6, 6, 6, 4]
        .add(
            Conv2D::new(4, (3, 3), vec![6, 8, 8, 1], (1, 1), Activation::ReLU)
                .unwrap()
                .with_random_state(42),
        )
        // [6, 6, 6, 4] -> Flatten -> [6, 144]
        .add(Flatten::new(vec![6, 6, 6, 4]).unwrap())
        // [6, 144] -> Dense -> [6, 3]
        .add(Dense::new(6 * 6 * 4, 3, Activation::Linear).unwrap().with_random_state(7))
        .compile(SGD::new(0.01, 0.0, false, 0.0).unwrap(), MeanSquaredError::new());

    model.summary();
    model.fit(&x, &y, 3).unwrap();

    let pred = model.predict(&x).unwrap();
    assert_eq!(pred.shape(), &[6, 3]);
    println!("prediction shape: {:?}", pred.shape());
}
```

`Dense` 的输入维度不是猜出来的。它就是展平后的特征数 `6 * 6 * 4 = 144`,可以直接从卷积的输出形状读出来。写错了,`Dense` 层的矩阵乘法就会拒绝这个 batch。`summary()` 会打印每个阶段的形状和参数预算。这是发现分类头尺寸写错的最快办法:

```text
Model: "sequential"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                    ┃ Output Shape           ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ conv2d (Conv2D)                 │ (6, 6, 6, 4)           │            40 │
│ flatten (Flatten)               │ (None, 144)            │             0 │
│ dense (Dense)                   │ (None, 3)              │           435 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 475 (1900 B)
 Trainable params: 475 (1900 B)
 Non-trainable params: 0 (0 B)
```

如果想在进入 dense 头之前先缩小空间尺寸,就在卷积和 flatten 之间插入一个[池化层](./3.6._池化层.md)。训练好的网络栈可以用 [3.9. 权重保存与加载](./3.9._权重保存与加载.md)里的工具保存下来。

## 3.5.7. 显式边界:ZeroPadding 与 Cropping

`PaddingType::Same` 是一条策略。它替你算出填充总量,并且按固定规则拆分奇数总量:`pad_before = pad_total / 2`,多出来的那一格落在尾侧。如果你想要别的拆法,或者想要一段后面并不跟着卷积的填充,那就改用边界层。

这个家族有 6 个成员,分成两半。`ZeroPadding1D`、`ZeroPadding2D` 和 `ZeroPadding3D` 在空间轴的两端补零。`Cropping1D`、`Cropping2D` 和 `Cropping3D` 在同样的位置裁掉数据。每一半都是另一半的反向传播。这 6 个层都不动 batch 轴和通道轴,也都不持有任何参数。

| 层 | 输入张量 | 输出张量 |
| --- | --- | --- |
| `ZeroPadding1D` | `[N, L, C]` | `[N, L + before + after, C]` |
| `ZeroPadding2D` | `[N, H, W, C]` | `[N, H + top + bottom, W + left + right, C]` |
| `ZeroPadding3D` | `[N, D, H, W, C]` | 每个空间轴按它自己的两个量增长 |
| `Cropping1D` | `[N, L, C]` | `[N, L - before - after, C]` |
| `Cropping2D` | `[N, H, W, C]` | `[N, H - top - bottom, W - left - right, C]` |
| `Cropping3D` | `[N, D, H, W, C]` | 每个空间轴按它自己的两个量收缩 |

每个构造函数只接受 1 个参数,并且直接返回层本身,不带 `Result`。边界量的类型是 `usize`,所以构造时不存在非法取值。裁得太多的 cropping 层改为在前向时报错,那时输入的实际尺寸才已知。

这个参数接受 3 种形式,具体哪几种可用取决于层的秩:

| 层的秩 | 整数 `n` | 元组 | 成对元组 |
| --- | --- | --- | --- |
| 1D | 两端各 `n` | `(before, after)` | 不适用 |
| 2D | 4 条边各 `n` | `(height, width)`,每个轴两端相等 | `((top, bottom), (left, right))` |
| 3D | 6 个面各 `n` | `(dim1, dim2, dim3)`,每个轴两端相等 | 3 组 `(before, after)` |

2D 的元组形式要看仔细。`ZeroPadding2D::new((1, 2))` 表示上边*和*下边各 1 行,左边*和*右边各 2 列。它不表示上边 1 行、下边 2 行。只有 1D 的成对形式才是在指定同一个轴的两端。

cropping 层必须在每个空间轴上至少留下 1 个位置。`Cropping1D::new((2, 3))` 作用在长度为 5 的输入上会把 5 步全部裁光,于是 `forward` 返回 `Error::InvalidInput`。同样的输入上 `Cropping1D::new((2, 2))` 还剩 1 步,可以正常通过。

这两半都不会让数据跨越通道轴。padding 会申请一个全零张量,再把输入拷进它的中间。cropping 则把内部拷出来。因为每一半都是另一半的反向传播,所以一个 `ZeroPadding2D` 接上一个用同样边界量的 `Cropping2D`,在数值上就是恒等变换。

下面这段程序先跑一遍这个来回,再把一个 padding 放到 Valid 卷积前面,让卷积保持空间尺寸不变:

```rust
use ndarray::{Array2, Array4};
use rustyml::neural_network::layers::*;
use rustyml::neural_network::losses::*;
use rustyml::neural_network::optimizers::*;
use rustyml::neural_network::sequential::Sequential;
use rustyml::neural_network::traits::Layer;

fn main() {
    // 4 个样本,6x6 像素,1 个通道。所有取值都大于 0。
    let x = Array4::<f32>::from_shape_fn((4, 6, 6, 1), |(n, i, j, _)| {
        ((n + i + j) as f32 + 1.0) * 0.05
    })
    .into_dyn();

    // 只在上边补 1 行零,左右各补 1 列零:6x6 -> 7x8。
    let mut pad = ZeroPadding2D::new(((1, 0), (1, 1)));
    let padded = pad.forward(&x).unwrap();
    assert_eq!(padded.shape(), &[4, 7, 8, 1]);
    assert_eq!(padded[[0, 0, 0, 0]], 0.0);

    // 用同样边界量的 cropping 正好把 padding 抵消掉。
    let mut crop = Cropping2D::new(((1, 0), (1, 1)));
    assert_eq!(crop.forward(&padded).unwrap(), x);

    // 放进模型里:6x6 先涨到 8x8,3x3 的 Valid 卷积再把它变回 6x6。
    let y = Array2::<f32>::from_shape_fn((4, 2), |(n, k)| (n as f32) * 0.1 + (k as f32) * 0.01)
        .into_dyn();

    let mut model = Sequential::new();
    model
        .add(ZeroPadding2D::new(1))
        .add(Conv2D::new(2, (3, 3), vec![4, 8, 8, 1], (1, 1), Activation::ReLU).unwrap())
        .add(Flatten::new(vec![4, 6, 6, 2]).unwrap())
        .add(Dense::new(6 * 6 * 2, 2, Activation::Linear).unwrap())
        .compile(SGD::new(0.01, 0.0, false, 0.0).unwrap(), MeanSquaredError::new());

    model.fit(&x, &y, 3).unwrap();

    let pred = model.predict(&x).unwrap();
    assert_eq!(pred.shape(), &[4, 2]);
    println!("prediction shape: {:?}", pred.shape());
}
```

在第一次前向之前,`summary()` 对边界层打印的是 `Unknown`。边界层不接受 `input_shape` 参数,它的输出形状要从收到的张量推出来,所以在那之前它答不上来。`Flatten` 和池化层则在构造时就绑定了声明的 `input_shape`,因此可以立刻打印出输出形状。

## 3.5.8. Permute 与 RepeatVector

形状家族还有两个不带参数的成员。`Permute` 重排 batch 轴之后的各个轴。`RepeatVector` 把每个样本的一个向量变成一串完全相同的步。

`Permute::new(dims)` 指定新的顺序。`dims` 从 1 开始数,并且永远不包含 batch 轴。`Permute::new(vec![2, 1])` 作用在 `[batch, steps, features]` 上得到 `[batch, features, steps]`。这些取值必须构成 `1..=dims.len()` 的一个排列,每个轴恰好出现一次。输入的秩必须是 `dims.len() + 1`,秩对不上是前向时的 `Error::InvalidInput`。

`Permute::new(vec![])` 返回 `Error::InvalidParameter`。这样的层什么都不重排,而这里其他每一个层都需要一个 batch 轴外加至少一个轴。

`Permute` 和 `Reshape` 的区别值得说清楚。reshape 是用新形状按同样的顺序读同一块缓冲区,每个值在内存序里的位置都不变。permute 是按新的顺序读那块缓冲区,值会落到新的位置上。两个层都会申请一块新张量,但只有 permute 要付出一次散乱拷贝的代价。

如果 permute 让最后一个轴仍然留在最后,整行数据保持连续,速度接近一次普通拷贝。如果 permute 把最后一个轴挪走,连续段就缩到 1 个元素,开销要高出好几倍。模型结构允许选择时,把 permute 放在张量较小的位置。

`RepeatVector::new(n)` 接受 `[batch, features]` 的输入,给出 `[batch, n, features]`。这 `n` 步里每一步装的都是同一个向量。`n` 必须大于 0,传 `0` 会在构造时得到 `Error::InvalidParameter`。输入的秩必须是 2。

`RepeatVector` 是为 encoder-decoder 模型的 decoder 一侧准备的。这里的循环层只返回最后一个隐藏状态,是个二维张量,而循环层需要三维输入。`RepeatVector` 把两者接上,于是一个 `LSTM` 能喂给另一个 `LSTM`。这一层每一步都吐出同一个向量,这是用固定上下文引导 decoder 的标准做法。循环层返回什么、以及为什么需要这座桥,见 [3.7. 循环层](./3.7._循环层.md)。

下面这段程序手动跑一遍这两个层:

```rust
use ndarray::{Array2, Array3};
use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;

fn main() {
    // 2 个样本,2 个步,3 个特征。
    let x = Array3::<f32>::from_shape_fn((2, 2, 3), |(n, t, f)| (n * 6 + t * 3 + f) as f32)
        .into_dyn();

    // dims 从 1 开始数并跳过 batch 轴,所以 (2, 1) 交换步和特征。
    let mut permute = Permute::new(vec![2, 1]).unwrap();
    let swapped = permute.forward(&x).unwrap();
    assert_eq!(swapped.shape(), &[2, 3, 2]);
    // 样本 0 原本是 [[0, 1, 2], [3, 4, 5]],转置后是 [[0, 3], [1, 4], [2, 5]]。
    assert_eq!(swapped[[0, 1, 0]], 1.0);
    assert_eq!(swapped[[0, 0, 1]], 3.0);

    // 梯度按逆序传回去。
    let grad = permute.backward(&swapped).unwrap();
    assert_eq!(grad.shape(), &[2, 2, 3]);

    // RepeatVector 把每个样本的一个向量变成一串相同的步。
    let state = Array2::<f32>::from_shape_fn((2, 3), |(n, f)| (n * 3 + f) as f32).into_dyn();
    let mut repeat = RepeatVector::new(4).unwrap();
    let sequence = repeat.forward(&state).unwrap();
    assert_eq!(sequence.shape(), &[2, 4, 3]);
    assert_eq!(sequence[[0, 0, 2]], sequence[[0, 3, 2]]);

    println!("swapped {:?}, sequence {:?}", swapped.shape(), sequence.shape());
}
```

在第一次前向之前,`summary()` 对这两个层打印的都是 `Unknown`,原因和边界层一样:它们都不接受输入形状,所以在那之前都说不出自己的输出形状。

## 3.5.9. Identity

`Identity` 就是什么都不做的那个层。它在任意秩、任意形状下都原样返回输入,反向传播则原样返回收到的梯度。`Identity::new()` 不接受任何参数,直接返回层本身而不是 `Result`,因为它没有任何地方能配置错。它同时实现了 `Default`。

一个什么都不做的层听上去毫无用处,直到模型不再靠手写、而是靠代码搭出来。一个在若干个层之间做选择的函数,在选中「不做任何操作」时总得返回点什么。一个深度是运行期变量的堆叠需要一个占位。一个要对比「有某层」和「没某层」两种结构的实验,需要两个模型的层数保持一致。这样一来,`summary()` 和保存下来的权重文件才对得上。这 3 种情形用一个什么都不做的层来写,都比在每个位置放一个 `Option` 更好读。

这个层会拷贝。它无法只借用,因为层要返回一个拥有所有权的张量。这次拷贝是 1 趟线性遍历,跑在内存带宽上,但它并不免费。模型定稿之后请把这个层删掉,而不是留着。

秩为 0 的张量没有 batch 轴,会得到 `Error::InvalidInput`。某个轴尺寸为零的张量会得到 `Error::EmptyInput`。`Reshape`、`Permute`、`RepeatVector` 和边界层划的是同样这 2 条边界。

```rust
use ndarray::Array2;
use rustyml::neural_network::layers::*;
use rustyml::neural_network::losses::*;
use rustyml::neural_network::optimizers::*;
use rustyml::neural_network::sequential::Sequential;
use rustyml::neural_network::traits::Layer;

fn main() {
    // 2 个样本,3 个特征。
    let x = Array2::<f32>::from_shape_fn((2, 3), |(n, f)| (n * 3 + f) as f32).into_dyn();

    let mut identity = Identity::new();
    let out = identity.forward(&x).unwrap();
    assert_eq!(out, x);

    // 梯度怎么来的就怎么回去。
    let grad = identity.backward(&out).unwrap();
    assert_eq!(grad, out);

    // 深度是运行期变量的堆叠:占位层让层数保持固定。
    let depth = 2;
    let mut model = Sequential::new();
    for step in 0..3 {
        if step < depth {
            model.add(Dense::new(3, 3, Linear::new()).unwrap());
        } else {
            model.add(Identity::new());
        }
    }
    model.compile(
        SGD::new(0.01, 0.0, false, 0.0).unwrap(),
        MeanSquaredError::new(),
    );
    model.summary();

    println!("output shape {:?}", model.predict(&x).unwrap().shape());
}
```

在第一次前向之前,`summary()` 对这个层打印的是 `Unknown`,原因和边界层一样:它不接受输入形状,所以在那之前说不出自己的输出形状。

## 3.5.10. 错误与规避方法

卷积层和形状相关的层都会严格校验自己的配置。遇到不合法的输入,它们返回带类型的错误,而不是 panic。下表列出常见情形和对应的错误变体。

| 情形 | 错误 |
| --- | --- |
| `filters` 为 `0`(`Conv1D`、`Conv2D`、`Conv3D`、3 个转置层中的任意一个,或 `SeparableConv2D`;`DepthwiseConv2D` 没有 `filters` 参数) | `Error::InvalidParameter` |
| 某个 kernel 维度或某个 stride 为 `0` | `Error::InvalidParameter` |
| `depth_multiplier == 0`(`SeparableConv2D::new`、`DepthwiseConv2D::with_depth_multiplier`) | `Error::InvalidParameter` |
| `input_shape` 秩不对、通道数为零,或(仅限普通卷积)小于 kernel(构造时) | `Error::InvalidInput` |
| `forward` 收到秩不对的张量 | `Error::InvalidInput` |
| 普通 Valid 卷积在运行时空间维小于 kernel | `Error::InvalidInput` |
| `DepthwiseConv2D` 运行时通道数与声明的通道数不符 | `Error::DimensionMismatch` |
| 转置卷积收到 kernel 并非为之构建的运行时通道数,或者某个空间轴长度为 0 的张量 | `Error::InvalidInput` |
| 转置卷积的 `backward` 收到形状与前向输出不符的梯度 | `Error::ShapeMismatch` |
| 边界层收到秩不对的张量,或某个轴尺寸为零的张量 | `Error::InvalidInput` 或 `Error::EmptyInput` |
| `Cropping*` 的边界量把某个空间轴裁得一个位置都不剩 | `Error::InvalidInput` |
| `Permute::new` 收到空的 `dims`,或者不构成 `1..=dims.len()` 排列的 `dims` | `Error::InvalidParameter` |
| `RepeatVector::new(0)` | `Error::InvalidParameter` |
| `Identity` 收到秩为 0 的张量,或某个轴尺寸为零的张量 | `Error::InvalidInput` 或 `Error::EmptyInput` |
| `set_weights` 形状不匹配 | `Error::NeuralNetwork(NnError::WeightShape)` |
| 在 `forward` 之前调用 `backward` | `Error::NeuralNetwork(NnError::ForwardPassNotRun)` |

这里有 2 条错误路径需要多说几句。「Valid 填充下 kernel 比输入大」这一情形,引擎在 2 个地方都会拦下。它在构造时对照声明的 `input_shape` 检查一次。它在前向时又对照实际张量检查一次。引擎用 `usize` 计算 `in - k`,与其让这个计算下溢,不如直接返回 `InvalidInput`。

通道数不匹配只有在 `DepthwiseConv2D` 上才是可恢复的错误。这一层会直接检查运行时的通道数,数量对不上就返回 `DimensionMismatch`。标准的 `Conv1D`、`Conv2D`、`Conv3D` 层根据声明的 `input_shape` 里的通道数确定权重矩阵的尺寸。它们不会在每次前向时重新检查这个数量。

3 个转置层会检查,不符就返回 `InvalidInput`,因为它们的计算过程要从 kernel 里读出通道数。你必须始终提供当初声明的那个通道数。把声明的通道数当成一份契约。

下面这段程序把这些可恢复的路径都跑了一遍:

```rust
use ndarray::Array;
use rustyml::error::Error;
use rustyml::neural_network::layers::*;
use rustyml::neural_network::traits::Layer;

fn main() {
    // 1. Valid 填充下 kernel 比声明的输入大:在构造时就被拒绝。
    let too_big = Conv2D::new(1, (3, 3), vec![1, 2, 2, 1], (1, 1), Activation::Linear);
    assert!(matches!(too_big, Err(Error::InvalidInput(_))));

    // 2. depth_multiplier 为 0 时,由 builder 拒绝,而不是 panic。
    let bad_dm = DepthwiseConv2D::new((2, 2), vec![1, 4, 4, 2], (1, 1), Activation::Linear)
        .unwrap()
        .with_depth_multiplier(0);
    assert!(matches!(bad_dm, Err(Error::InvalidParameter { .. })));

    // 3. 运行时张量比 kernel 小:Valid 的几何计算返回错误,而不是 panic。
    let mut conv = Conv2D::new(1, (3, 3), vec![1, 5, 5, 1], (1, 1), Activation::Linear).unwrap();
    let small = Array::ones((1, 2, 5, 1)).into_dyn(); // 高度 2 < kernel 3
    assert!(matches!(conv.forward(&small), Err(Error::InvalidInput(_))));

    // 4. DepthwiseConv2D 把运行时通道数不匹配转成可恢复的错误。
    let mut dw =
        DepthwiseConv2D::new((2, 2), vec![1, 4, 4, 2], (1, 1), Activation::Linear).unwrap();
    let wrong_channels = Array::ones((1, 4, 4, 3)).into_dyn(); // 3 个通道,层期望 2 个
    assert!(matches!(
        dw.forward(&wrong_channels),
        Err(Error::DimensionMismatch { .. })
    ));

    println!("all error paths behaved as documented");
}
```

`ForwardPassNotRun` 这个错误最容易在训练途中让人措手不及。反向传播要读取 `forward` 写下的缓存。在一个刚建好的层上调用 `backward`,或者连着调用两次、中间没有插入一次 `forward`,都会触发它。不管是哪种情况,层都会返回这个变体,而不是去读陈旧的状态。

在 `Sequential` 内部,这个顺序由框架替你安排妥当。只有当你手动驱动各个层时,才会遇到它。完整的错误分类以及这些变体背后的智能构造函数,见 [1.6. 错误处理](../Chapter-01/1.6._错误处理.md)。