# 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)。