rustyml 0.15.0

A high-performance machine learning & deep learning library in pure Rust, offering ML algorithms and neural network support
Documentation
# 3.4. 优化器

RustyML 中的优化器负责把梯度变成参数更新。某一层的 `backward` 会为每个可训练张量算出并暂存一个梯度。优化器随后遍历这些张量,把每一个都往下坡方向推。你在对 [`Sequential`](./3.1._Sequential模型.md) 模型调用 `compile` 时选定优化器,同时也要选定一个[损失函数](./3.3._损失函数.md)。之后,`fit` 就会替你驱动整个优化过程。

RustyML 一共提供 5 个优化器:[`SGD`](https://docs.rs/rustyml)、[`Adam`](https://docs.rs/rustyml)、[`AdamW`](https://docs.rs/rustyml)、[`RMSprop`](https://docs.rs/rustyml) 和 [`AdaGrad`](https://docs.rs/rustyml)。这 5 个都位于 `rustyml::neural_network::optimizers`。prelude 会把它们重新导出。每个构造函数都返回 `Result<Self, Error>`,因为它会先校验超参数。下面的每个示例都以 `.unwrap()`(或真正的错误处理)结束构造调用。

这些算法和 Keras、PyTorch 里的完全一致。有两点不同。构造函数使用位置参数。没有任何参数带默认值。梯度裁剪和 weight decay 也是优化器自身的一部分,而不是训练循环的一部分。

## 3.4.1. 优化器接口与更新的流动方式

每个优化器都实现 `Optimizer` trait。这个 trait 有 5 个方法,你都会用到。训练循环会替你调用其中 3 个。学习率调度逻辑自己读写另外 2 个,也就是 `learning_rate` 和 `set_learning_rate`(见 3.4.8):

```rust,ignore
pub trait Optimizer {
    fn step(&mut self);                                         // 每个 batch 调用一次
    fn update(&mut self, layer: &mut dyn Layer, grad_scale: f32); // 每一层调用一次
    fn global_clipnorm(&self) -> Option<f32>;                        // 裁剪阈值,没有则返回 None
    fn learning_rate(&self) -> f32;                            // 当前步长
    fn set_learning_rate(&mut self, learning_rate: f32);       // 用于调度的钩子
}
```

训练循环在碰触任何一层之前,每个 batch 都恰好调用一次 `step`。之后,它对每一层调用一次 `update`。有状态的优化器在 `step` 里推进自己的“时间”概念。Adam 和 AdamW 在这里递增用于偏差校正的时间步。SGD、RMSprop 和 AdaGrad 只用 `step` 来倒回一个内部游标。

这就是 Adam 的时间步按每个 *batch* 推进一次、而不是按每 *层* 推进一次的原因。手写训练循环必须每个 batch 调用一次 `step`,而不是每层或每个参数调用一次。调用次数一多,就会在没有任何报错的情况下破坏 Adam 的偏差校正计算。

`update` 会从训练循环收到一个 `grad_scale` 系数。循环根据 `global_clipnorm`(见下文)算出这个 `grad_scale`。不裁剪时,`grad_scale` 是 `1.0`。在 `update` 内部,优化器通过 `layer.parameters()` 向层索取参数。这个调用会为每个可训练张量返回一个扁平的 `ParamGrad`。`ParamGrad` 里有一个可变的 `value` 切片、一个与之匹配的 `grad` 切片,以及一个 `decays: bool` 标志。

`decays` 标志让 weight decay 不会用错地方。权重矩阵以及卷积、循环核带 `decays = true`。偏置和归一化的 `gamma`、`beta` 带 `decays = false`。无论你把 `weight_decay` 设成多少,RustyML 都不会 decay 一个 `decays = false` 的参数。层负责设置这个标志,优化器负责尊重它。你自己不需要管这件事。

层把参数以扁平的 `&mut [f32]` 切片形式暴露出来。正因如此,单个逐元素内核就能处理任意形状的张量。一旦某个张量的元素数越过阈值,每个内核还会切到 Rayon 并行路径(见 [7.3](../Chapter-07/7.3._性能调优与并行.md))。

优化器按层交出每个张量时的*位置*来索引自己的逐参数状态。因此参数顺序必须在各步之间保持稳定,事实上它也确实如此。一个优化器实例只属于一个模型。不要跨模型共享同一个优化器实例。

## 3.4.2. SGD

```rust,ignore
SGD::new(learning_rate, momentum, nesterov, weight_decay) // 除 nesterov: bool 外全为 f32
```

SGD 就是朴素的随机梯度下降。它支持可选的动量、Nesterov 加速和解耦的 weight decay。当 `momentum = 0.0` 时,更新公式就是教科书里的那个:

```text
param -= lr * grad
```

把 `momentum` 设为大于 `0.0`,就会累积一个逐参数的速度缓冲。你还可以叠加 Nesterov 前瞻步骤:

```text
v    = momentum * v + grad
step = grad + momentum * v   (nesterov = true)   or   v   (nesterov = false)
param -= lr * step
```

`0.9` 是惯用的动量取值。只有动量非零时,`nesterov` 才有意义。这里的 `weight_decay` 是**解耦**的,也就是 SGDW 的形式。在梯度步之前,SGD 会按 `param *= (1 - lr * weight_decay)` 收缩权重张量,这与梯度无关。AdamW 用的是同一种解耦,只不过施加在 SGD 上。

```rust
use rustyml::prelude::*;
use rustyml::neural_network::sequential::Sequential;
use ndarray::Array;

fn main() {
    // 内联小回归:y = 2*x
    let x = Array::from_shape_vec((4, 1), vec![0.5_f32, 1.0, 1.5, 2.0])
        .unwrap()
        .into_dyn();
    let y = Array::from_shape_vec((4, 1), vec![1.0_f32, 2.0, 3.0, 4.0])
        .unwrap()
        .into_dyn();

    let mut model = Sequential::new();
    model
        .add(Dense::new(1, 1, Activation::Linear).unwrap())
        // learning_rate, momentum, nesterov, weight_decay
        .compile(SGD::new(0.05, 0.9, true, 0.0).unwrap(), MeanSquaredError::new());

    let before = model.predict(&x).unwrap();
    model.fit(&x, &y, 10).unwrap();
    let after = model.predict(&x).unwrap();
    println!("shapes: {:?} -> {:?}", before.shape(), after.shape());
}
```

当你想要精细控制和可预测的行为时,就用带动量的 SGD。它的动力学已经被研究得很透彻。维护它那一个滑动平均的开销很小。带动量的 SGD 至今仍是其他方法用来比较泛化能力的标杆。

代价是对学习率敏感。学习率太大会让训练发散。学习率太小会让训练变慢。Adam 消除了大部分这种敏感性。

## 3.4.3. Adam

```rust,ignore
Adam::new(learning_rate, beta1, beta2, epsilon, weight_decay) // 全为 f32
```

Adam 为每个参数维护 2 个滑动平均。一阶矩 `m` 跟踪梯度的均值。二阶矩 `v` 跟踪梯度平方的均值。Adam 用这两者给每个参数各自的自适应步长。逐元素运行的完整更新如下:

```text
t += 1                                  (advanced once per batch, in step())
m = beta1*m + (1 - beta1)*grad
v = beta2*v + (1 - beta2)*grad^2
m_hat = m / (1 - beta1^t)               <- bias correction
v_hat = v / (1 - beta2^t)               <- bias correction
param -= lr * m_hat / (sqrt(v_hat) + epsilon)
```

偏差校正之所以重要,是因为 `m` 和 `v` 都从零开始。在最初几步里,这会把两个矩都拉向零。对 `v` 来说这种拉力特别强,因为 `beta2 = 0.999` 会让 `v` 在 `t = 1` 时几乎还停留在零附近。

除以 `(1 - beta^t)` 会把这两个矩重新缩放。在 `t = 1` 时,分母 `1 - beta1` 正好抵消 `m` 里已有的 `(1 - beta1)` 因子,还原出原始梯度。随着 `t` 增大,`beta^t` 趋近于 0,校正也就逐渐退化成空操作。

这就是时间步在 `step` 里(每个 batch 一次)推进、而不是在 `update` 里推进的原因。用全批量的 [`fit`](./3.1._Sequential模型.md) 时,`t` 等于 epoch 数。用 `fit_with_batches` 时,`t` 按每个 mini-batch 推进一次。时间步会饱和而不会溢出。因此,极长的训练也始终保持良好定义。

Adam 把 `epsilon` 加在平方根**外面**。RMSprop 和 AdaGrad(3.4.5 与 3.4.6)把各自的 `epsilon` 加在根号**里面**。这看起来像是不一致,实则是刻意为之。Keras 自己也是这样分开处理的。RustyML 逐个优化器地对齐 Keras,而不是强行让三者统一成同一种写法。

因此,`epsilon` 在各个优化器里并不处于同一个尺度。在把 `epsilon` 的取值从一个优化器搬到另一个之前,先读 RMSprop 那一节里关于尺度的说明。

Adam 的 `weight_decay` 实现的是**经典的耦合 L2 正则化**。Adam 会在矩更新*之前*把 `weight_decay * param` 折进梯度里。于是这个惩罚项会流经 `m` 和 `v`,并被自适应的 `1 / (sqrt(v_hat) + epsilon)` 分母重新缩放。这种耦合通常不是你想要的效果。下一节的 AdamW 提供了另一种做法。

当 `weight_decay = 0.0` 时,这种耦合不起作用,Adam 和 AdamW 会变得逐字节完全相同。RustyML 在 `Adam` 里保留这种耦合是故意的。这是相对于 Keras 3 的一次刻意偏离。

Keras 3 的优化器基类会对每个优化器都施加*解耦*的 decay。这使得 Keras 里的 `Adam(weight_decay=x)` 和 `AdamW(weight_decay=x)` 在数值上完全相同。RustyML 转而跟随 PyTorch 的做法。在 PyTorch 里,`torch.optim.Adam(weight_decay=)` 是耦合的,而 `AdamW` 的不是。这让这两个名字在 RustyML 里真正各有所指。

```rust
use rustyml::prelude::*;
use rustyml::neural_network::sequential::Sequential;
use ndarray::Array;

fn main() {
    // 内联小回归:y = x0 + 2*x1
    let x = Array::from_shape_vec((4, 2), vec![0.0_f32, 1.0, 1.0, 0.0, 1.0, 1.0, 2.0, 1.0])
        .unwrap()
        .into_dyn();
    let y = Array::from_shape_vec((4, 1), vec![2.0_f32, 1.0, 3.0, 4.0])
        .unwrap()
        .into_dyn();

    let mut model = Sequential::new();
    model
        .add(Dense::new(2, 8, Activation::ReLU).unwrap())
        .add(Dense::new(8, 1, Activation::Linear).unwrap())
        // learning_rate, beta1, beta2, epsilon, weight_decay
        .compile(Adam::new(0.01, 0.9, 0.999, 1e-8, 0.0).unwrap(), MeanSquaredError::new());

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

    let preds = model.predict(&x).unwrap();
    println!("prediction shape: {:?}", preds.shape());
}
```

`Adam::new(0.001, 0.9, 0.999, 1e-8, 0.0)` 就是默认之选。拿不准该用哪个优化器时,就用这套配置。它能容忍相当大范围的学习率。它在杂乱的损失曲面上也能收敛得很快。它几乎不需要调参就能开始训练。

## 3.4.4. AdamW

```rust,ignore
AdamW::new(learning_rate, beta1, beta2, epsilon, weight_decay) // 与 Adam 签名完全相同
```

AdamW 跑的矩运算和偏差校正与 Adam 完全一样。唯一的差别在于 weight decay 从哪里进入更新过程。这个差别一旦你把 decay 打开,就变得举足轻重。AdamW 是**解耦**的,也就是 Loshchilov 和 Hutter 提出的形式。

AdamW 会在一次普通 Adam 步*之前*,直接按 `param *= (1 - lr * weight_decay)` 收缩权重。decay 从不触碰 `m` 或 `v`。自适应分母也从不去除它。

这个区别不是表面上的。在 Adam 的耦合方案里,`weight_decay * param` 这一项会随二阶矩 `v` 一起传播。见过大梯度的参数会得到大的 `v` 和大的分母。因此它得到的实际 decay 反而*更少*,不如一个安静的参数。正则化强度最终和每个权重的梯度历史缠在了一起。这与 weight decay 本该给出的均匀收缩恰好相反。

AdamW 斩断了这层关系。每个权重都按同样的 `(1 - lr * weight_decay)` 因子 decay,与它的梯度无关。正因如此,AdamW 的泛化效果更好。当你在乎给模型做正则化时,它就是标准选择。

实用准则是这样的:**只要是在自适应优化器上使用非零的 weight decay,就用 `AdamW`,而不是 `Adam` 的 `weight_decay`。** 完全不做正则化时,就用朴素的 `Adam`(`weight_decay = 0.0`)。在 `weight_decay = 0.0` 时,这两个优化器跑的是同一个算法。除了习惯之外,没有理由让 `Adam` 优先于 `AdamW`。

```rust
use rustyml::prelude::*;
use rustyml::neural_network::sequential::Sequential;
use ndarray::Array;

fn main() {
    let x = Array::from_shape_vec(
        (4, 3),
        vec![0.0_f32, 1.0, 2.0, 1.0, 0.0, 1.0, 2.0, 1.0, 0.0, 1.0, 1.0, 1.0],
    )
    .unwrap()
    .into_dyn();
    let y = Array::from_shape_vec((4, 1), vec![1.0_f32, 0.5, 0.5, 0.75])
        .unwrap()
        .into_dyn();

    let mut model = Sequential::new();
    model
        .add(Dense::new(3, 16, Activation::ReLU).unwrap())
        .add(Dense::new(16, 1, Activation::Linear).unwrap())
        // 解耦的 weight_decay = 0.01
        .compile(
            AdamW::new(0.01, 0.9, 0.999, 1e-8, 0.01).unwrap(),
            MeanSquaredError::new(),
        );

    model.fit(&x, &y, 30).unwrap();
    println!("trained: {:?}", model.predict(&x).unwrap().shape());
}
```

## 3.4.5. RMSprop

```rust,ignore
RMSprop::new(learning_rate, rho, epsilon, weight_decay) // 全为 f32
```

这个类型的名字是 `RMSprop`,`p` 是小写。RMSprop 为每个参数维护一个梯度平方的滑动平均。它用这个平均值的平方根来归一化每一步:

```text
cache = rho*cache + (1 - rho)*grad^2
param -= lr * grad / sqrt(cache + epsilon)
```

`rho` 是梯度平方的衰减率,惯用值是 `0.9`。它是 RMSprop 针对 AdaGrad 主要缺陷给出的答案。`cache` 是指数滑动平均,不是累加和。正因如此,`cache` 会遗忘旧梯度,也不会无界增长。有效步长因此会趋于稳定,而不是衰减到零。

可以把 RMSprop 看作没有一阶矩的 Adam。它提供逐参数的自适应缩放,但没有动量、也没有偏差校正。它的 `weight_decay` 是解耦的,采用 AdamW 那种风格,在自适应步之前作用于权重张量。RMSprop 适合循环网络和非平稳目标。在大多数其他问题上,Adam 能覆盖同样的场景。

这里的 `epsilon` 加在根号**里面**,与 Keras 的 `sqrt(velocity + epsilon)` 一致。这个位置不是无关紧要的写法差异。它决定了 `epsilon` 是按什么尺度度量的。`cache` 累积的是梯度的*平方*。

因此,加在开根之前的 `epsilon` 活在 `grad^2` 的尺度上。加在开根之后的 `epsilon`(就像上面的 Adam,以及 PyTorch 的 RMSprop 那样)则活在 `grad` 的尺度上。这两个尺度大致按 `eps_inside = eps_outside^2` 对应。

Keras 默认放在根号内的 `1e-7`,和放在根号外的 `3.2e-4` 是同一档保护力度。不要把同一个 `epsilon` 取值原样搬到另一种形式里用。换到另一种形式后,它会偏差好几个数量级。下面的 AdaGrad 用的也是根号内的形式。

## 3.4.6. AdaGrad

```rust,ignore
AdaGrad::new(learning_rate, epsilon, weight_decay) // 全为 f32,没有 rho 或 beta
```

AdaGrad 累加梯度平方,并且从不遗忘:

```text
accumulator += grad^2
param -= lr * grad / sqrt(accumulator + epsilon)
```

`accumulator` 只会增长。因此 `sqrt(accumulator)` 也只增不减。有效学习率 `lr / sqrt(accumulator + epsilon)` 单调地衰减向零。这是 AdaGrad 的标志性性质,而它是一把双刃剑。

对凸问题和稀疏特征来说,这种衰减是一个优点。很少出现的参数能保持较大的步长。频繁更新的参数则会自动退火。衰减到零还带来了干净的收敛保证。

对训练很多步的深度网络来说,这种衰减则是一个缺陷。步长会一直缩小,直到学习实际上陷入停滞。RMSprop 的衰减因子和 Adam 的二阶矩平均,正是为了解决这个问题而存在的。

这里的 `learning_rate` 确实只是一个*初始*速率,通常取 `0.01`。它设定了一个上限,累加器只会把有效速率从这个上限往下拉。它的 `epsilon` 和 RMSprop 的一样,位于根号内,因此处在梯度平方的尺度上。weight decay 和其他几个优化器一样,是解耦的,且只作用于权重。

## 3.4.7. 参数校验

每个构造函数都会在返回之前校验自己的超参数。一个不合法的超参数因此会在构造时就以 `Error::InvalidParameter` 失败,而不是在训练进行了一段时间之后才产生 NaN。下表列出了直接取自校验逻辑的规则:

| 参数 | 可接受的取值 | 适用于 |
| --- | --- | --- |
| `learning_rate` | 正且有限(`> 0`) | 全部五个 |
| `momentum` | 非负且有限(`>= 0`) | SGD |
| `nesterov` | 任意 `bool`(从不校验) | SGD |
| `beta1`、`beta2` | 在 `[0, 1)` 内且有限 | Adam、AdamW |
| `rho` | 在 `[0, 1)` 内且有限 | RMSprop |
| `epsilon` | 正且有限(`> 0`) | Adam、AdamW、RMSprop、AdaGrad |
| `weight_decay` | 非负且有限(`>= 0`) | 全部五个 |
| `global_clipnorm` | 正且有限(`> 0`) | 全部五个(通过 `with_global_clipnorm`) |

衰减率的取值区间特意设成了半开区间。`beta1 = 0.0` 和 `rho = 0.0` 是合法的,下界是闭合的。`1.0` 是不合法的,上界是开放的。衰减率为 `1.0` 会冻结滑动平均,永远无法纳入新的梯度。

SGD 没有 `epsilon`,因为它从不除以自适应分母。`0.0` 对 `momentum` 和 `weight_decay` 来说是合法的。这就是关掉这两个特性的方式。`0.0` 对 `learning_rate` 和 `epsilon` 来说是不合法的。

`epsilon` 在全部 4 个自适应优化器里的校验方式相同,但它在每一个里的含义并不相同。RMSprop 和 AdaGrad 把 `epsilon` 加在平方根内,处在梯度平方的尺度上。Adam 和 AdamW 把它加在平方根外,处在梯度的尺度上。对一对合理的取值,换到另一对时大致要取平方或平方根。

```rust
use rustyml::neural_network::optimizers::{AdaGrad, Adam, AdamW, RMSprop, SGD};
use rustyml::error::Error;

fn main() {
    // learning_rate 必须为正且有限
    assert!(matches!(
        SGD::new(0.0, 0.0, false, 0.0),
        Err(Error::InvalidParameter { .. })
    ));
    // beta1 必须在 [0, 1) 内:1.0 超出范围
    assert!(matches!(
        Adam::new(0.001, 1.0, 0.999, 1e-8, 0.0),
        Err(Error::InvalidParameter { .. })
    ));
    // epsilon 必须为正且有限
    assert!(matches!(
        RMSprop::new(0.01, 0.9, 0.0, 0.0),
        Err(Error::InvalidParameter { .. })
    ));
    // weight_decay 必须非负且有限
    assert!(matches!(
        AdaGrad::new(0.01, 1e-8, -0.1),
        Err(Error::InvalidParameter { .. })
    ));
    // global_clipnorm 必须为正且有限
    assert!(matches!(
        AdamW::new(0.001, 0.9, 0.999, 1e-8, 0.0)
            .unwrap()
            .with_global_clipnorm(0.0),
        Err(Error::InvalidParameter { .. })
    ));

    // 一个完全合法的配置,启用裁剪
    let opt = Adam::new(0.001, 0.9, 0.999, 1e-8, 0.0).unwrap();
    let _clipped = opt.with_global_clipnorm(1.0).unwrap();
    println!("validation checks passed");
}
```

## 3.4.8. 梯度裁剪与学习率调度

梯度裁剪默认是关闭的。通过一个消耗型 builder `with_global_clipnorm` 来开启它,5 个优化器上都有这个方法。它是**按全局范数裁剪**,不是逐元素截断。训练循环把模型中*每个*张量的梯度平方求和,再取全局 L2 范数。

如果全局范数超过了你设的阈值,循环就会在更新前用同一个系数 `max_norm / global_norm` 去缩放每一个梯度。这一个统一的系数能精确保留下降方向。逐元素截断则会把这个方向掰弯。一个非有限的全局范数会被特意留着不缩放,这样真正的发散依然会以 NaN 的形式显现出来,而不是被悄悄掩盖。这种裁剪是驯服大而有限的梯度(例如在 RNN 或深层堆叠中)的推荐做法。反向传播本身不做任何截断。

这个名字和 Keras 的 `global_clipnorm` 完全一致。如果你要移植的 Keras 模型设的是 `clipnorm`,要留意其中的差异。Keras 的 `clipnorm` 是对每个变量的梯度*各自独立*地重新归一化。一旦有多个张量同时超限,它指向的方向就完全是另一回事了。RustyML 这里只有全局这一种形式。因此 Keras 的 `clipnorm` 阈值不能原封不动地搬过来用。

学习率调度用的是一对读写方法,`learning_rate` 和 `set_learning_rate`。模型把它们暴露为 `Sequential::learning_rate`(返回 `Option<f32>`,在你 `compile` 之前是 `None`)和 `Sequential::set_learning_rate`。在 epoch 或 batch 之间调用 setter 来重新调整步长。这样做会保留全部累积状态,比如 Adam 的矩、SGD 的速度、RMSprop 的 cache。你调整的是同一个优化器实例。你并没有重置它。

RustyML 没有内建的调度器对象。你要自己把调度策略写成一个普通循环。getter 存在的意义,就是让这个循环不需要自己保留一份学习率的副本。一旦别处调整了优化器,这样的副本就会立刻过期。

getter 返回的永远是最后一次写入的值。和构造函数不同,`set_learning_rate` 不做任何校验。因此零或负的学习率会被原样存下、原样读回,而不会被拒绝。

```rust
use rustyml::prelude::*;
use rustyml::neural_network::sequential::Sequential;
use ndarray::Array;

fn main() {
    let x = Array::from_shape_vec((4, 2), vec![0.0_f32, 1.0, 1.0, 0.0, 1.0, 1.0, 2.0, 1.0])
        .unwrap()
        .into_dyn();
    let y = Array::from_shape_vec((4, 1), vec![2.0_f32, 1.0, 3.0, 4.0])
        .unwrap()
        .into_dyn();

    let mut model = Sequential::new();
    model
        .add(Dense::new(2, 8, Activation::ReLU).unwrap())
        .add(Dense::new(8, 1, Activation::Linear).unwrap())
        .compile(
            Adam::new(0.01, 0.9, 0.999, 1e-8, 0.0)
                .unwrap()
                .with_global_clipnorm(1.0) // 把全局梯度范数裁剪到 1.0
                .unwrap(),
            MeanSquaredError::new(),
        );

    // 手动阶梯衰减:每个 block 把 LR 减半。优化器保留全部状态
    for _ in 0..3 {
        model.fit(&x, &y, 10).unwrap();
        // 学习率存在优化器里,读回来即可,不必自己另存一份
        let lr = model.learning_rate().unwrap();
        model.set_learning_rate(lr * 0.5);
    }

    assert_eq!(model.learning_rate(), Some(0.01_f32 / 8.0));
}
```

## 3.4.9. 逐层状态、内存与持久化

每个优化器都惰性地分配自己的状态。它会在 `update` 第一次触及某个参数张量时,为它创建一个大小匹配的缓冲。这些缓冲按层交出参数的顺序来索引。如果某个位置上张量的长度变了,RustyML 就会重置对应的缓冲以匹配它。

这就是两层收敛测试要检验跨层缓冲分配是否正确的原因。优化器的状态必须和它所影随的参数对齐。

内存开销就是这些缓冲的数量和大小:

| 优化器 | 逐参数状态 | 相对参数的额外内存 |
| --- | --- | --- |
| SGD,`momentum = 0.0` | 无 | 0x |
| SGD,`momentum > 0.0` | 速度 | 1x |
| RMSprop | 梯度平方 cache | 1x |
| AdaGrad | 梯度平方累加器 | 1x |
| Adam、AdamW | 一阶矩 `m` + 二阶矩 `v` | 2x |

对一个有 `P` 个 `f32` 权重的模型,Adam 和 AdamW 会额外背上大约 `2 * 4 * P` 字节的优化器状态。这是在参数本身及其梯度之外的开销。这就是自适应性的代价。也正因如此,一个用 SGD 训练毫无问题的模型,换到 Adam 下可能会内存不够。朴素 SGD 不带任何额外状态。在内存预算紧张时,这有时会成为决定性因素。

这些状态完全存在于优化器实例内部。RustyML **不会**把它持久化。`Sequential::save_to_path` 只序列化层的结构和权重。它明确地把优化器和损失函数排除在外。`load_from_path` 之后,你必须重新 `compile` 一个新的优化器。它的矩、速度和时间步都会从零开始。

因此,重新加载权重并续训会重启 Adam 的偏差校正预热过程。这通常无伤大雅。但如果你在训练中途做检查点,这一点就值得留意。完整的持久化说明见 [3.9. 权重保存与加载](./3.9._权重保存与加载.md) 和 [7.2. 深入模型持久化](../Chapter-07/7.2._深入模型持久化.md)。

## 3.4.10. 如何选择优化器

**从 Adam 开始**,使用 `0.001, 0.9, 0.999, 1e-8, 0.0`。Adam 是对学习率最不挑剔的优化器。它几乎能让任何模型训练起来。这正是你还在摸索架构时想要的效果。

当你想要精细控制、又愿意去调学习率时,就用**带动量的 SGD**,`momentum = 0.9`、`nesterov = true`。它的泛化能力是参考标准。它的状态开销很小。它的行为也容易预测。

只要目标是做正则化,就用**带非零 `weight_decay` 的 AdamW**。它的解耦 decay 是正则化自适应优化器的正确方式。在任何开启 decay 的场合,都应该优先选它,而不是 `Adam` 的耦合 `weight_decay`。

**RMSprop** 对循环网络和非平稳问题来说是个不错的后备选择。**AdaGrad** 在凸的、稀疏特征的问题上表现出色,那里衰减的步长是资产而不是负担。不要在长时间的深度网络训练里用 AdaGrad,同样的衰减会把学习拖停。

有一个细节贯穿所有这些选择。有效学习率和你损失函数的平均约定绑在一起。在逐元素损失和逐预测位置损失之间切换,会改变梯度的量级。逐元素损失包括 MSE、MAE 和二元交叉熵。逐预测位置损失指的是分类交叉熵。这种量级上的变化也会改变步长。

更多细节见 [3.3. 损失函数](./3.3._损失函数.md) 里关于平均方式的说明。换了损失之后,无论你选的是哪个优化器,多半都需要重新调整学习率。