# 3.1. Sequential模型
`Sequential` 是把一摞层组合成可训练模型的容器。Keras 的工作流程几乎能原样搬过来:调用 `new()` 得到一个空模型,按顺序 `add()` 各层,用一个优化器和一个损失函数 `compile()`,然后调用 `fit()` 和 `predict()`。
RustyML 在 3 个地方与 Keras 不同:训练默认是全批量的;这里没有回调机制,提前停止和学习率调度都得自己写循环去实现;相邻层之间的宽度不匹配以 panic 的形式出现,而不是 `Result` 值。
`Sequential` 位于 [`rustyml::neural_network::sequential`](https://docs.rs/rustyml)。插进它的层、优化器和损失函数各有专页([3.2](./3.2._全连接层与激活函数.md)、[3.4](./3.4._优化器.md)、[3.3](./3.3._损失函数.md))。
## 3.1.1. 生命周期一览
下面这 5 个调用,就是训练一个网络所需的全部公开接口。每个构建器方法都返回 `&mut Self`,所以 `add` 和 `compile` 能链式书写。`fit` 返回 `Result<History, Error>`,每个 epoch 带一个损失值。`predict` 返回 `Result<Tensor, Error>`。
```rust,ignore
let mut model = Sequential::new();
model
.add(/* 一个层 */)
.add(/* 另一个层 */)
.compile(/* 优化器 */, /* 损失函数 */);
model.summary();
let history = model.fit(&x, &y, epochs)?; // epochs: u32;history.loss() 每个 epoch 一个 f32
let y_hat = model.predict(&x_new)?; // Tensor = ArrayD<f32>
```
本页用到的一切都出自 [prelude](../Chapter-01/1.5._Prelude与模块导入.md):`Sequential`、`History`、`Dense`、`Activation`、所有优化器和损失函数、还有 `Tensor` 别名。下面每个示例都只需要这一行 glob 导入:
```rust,ignore
use rustyml::prelude::*; // Sequential、History、Dense、Activation、Adam、SGD、损失函数、Tensor
```
框架里的每个张量类型都是 `Tensor`,也就是 `ndarray::ArrayD<f32>` 的别名。它是动态阶的,并且永远只持有 `f32` 值。调用 [`.into_dyn()`](../Chapter-01/1.3._使用ndarray准备数据.md),把静态阶数组(比如 `Array2` 或 `Array3`)转换成 `IxDyn`,再送进层里。RustyML 没有 `f64` 通路,整条栈都用单精度,为的是缓存和 SIMD 性能。
## 3.1.2. 搭建模型:`new` 与 `add`
`Sequential::new()` 创建一个空模型:没有优化器、没有损失函数、也没有打乱种子。`add` 按值接收任意 `L: 'static + Layer`,把它装箱成 `Box<dyn Layer>`,追加进模型:
```rust,ignore
pub fn add<L: 'static + Layer>(&mut self, layer: L) -> &mut Self;
```
`add` 会消费掉这个层,所以你可以在一个表达式里现构造、现移动:`model.add(Dense::new(2, 8, Activation::ReLU).unwrap())`。层的构造函数本身也可能失败,比如 `Dense` 的某个维度为零就会返回 `Error::InvalidParameter`。这就是为什么 `.unwrap()` 挂在层的构造函数上,而不是 `add` 上。
**`add` 不做任何跨层校验。** 它从不检查某层的输入宽度是否匹配前一层的输出宽度,因为 `Box<dyn Layer>` 在插入时并不暴露这样的契约。只有当数据流过模型时,这种不匹配才会暴露出来,见 [3.1.8](#318-错误形状检查与不匹配-panic)。Keras 的函数式 API 恰好相反:连接不兼容的层,会在搭建计算图时就失败。
### `Layer` trait 速览
用 `Sequential` 并不需要你去实现 `Layer`,但读一读它的契约,能弄明白 `fit` 和 `predict` 到底调用了什么。核心方法有:
```rust,ignore
pub trait Layer: std::any::Any + Send + Sync {
fn forward(&mut self, input: &Tensor) -> Result<Tensor, Error>; // 训练前向;为反向传播缓存中间量
fn predict(&self, input: &Tensor) -> Result<Tensor, Error>; // 求值前向;&self,不写缓存
fn backward(&mut self, grad_output: &Tensor) -> Result<Tensor, Error>;
fn param_count(&self) -> TrainingParameters;
fn output_shape(&self) -> String;
fn layer_type(&self) -> &str;
// parameters()、layer_type()、output_shape()、set_training_if_mode_dependent() 有默认实现
}
```
`Layer` 有 2 条前向路径,而不是 1 条。`forward(&mut self, ...)` 在训练时跑,它拿 `&mut self`,把每一层反向传播要用的中间张量都藏起来。`predict(&self, ...)` 在推理时跑,它拿 `&self`,不写任何缓存,并把依赖模式的层(比如 dropout 和 batch normalization)切到推理行为。`Layer` trait 要求 `Send + Sync`。因为 `predict` 借的是 `&self`,一个层就能在多线程里并发做推理调用,不用加锁。这个拆分意味着 `predict` 从不会扰动训练状态,而且它比一次训练前向更省。
反向传播是纯数学运算,它刻意 **不** 清洗 `NaN` 或 `Inf` 值。非有限的梯度会一路传播,在下一次前向传播时、或以 `NaN` 损失的形式暴露出来,而不是被悄悄掩盖。
## 3.1.3. `compile`:接上优化器和损失函数
```rust,ignore
pub fn compile<O, LFunc>(&mut self, optimizer: O, loss: LFunc) -> &mut Self
where O: 'static + Optimizer, LFunc: 'static + Loss;
```
`compile` 把优化器和损失函数存成 trait 对象,返回 `&mut Self`,好接在最后一个 `add` 后面链式书写。它做的仅此而已:不推断形状,也不分配权重。每一层的权重都在自己的构造函数里分配,用 Xavier/Glorot 初始化。
只有训练需要 `compile`。如果优化器或损失函数缺了,`fit` 就返回 `Error::NeuralNetwork(NnError::NotCompiled(_))`。`predict` 从不读取优化器或损失函数,所以在未编译的模型上也能跑。`evaluate` 夹在两者之间:它要一个损失函数来打分,但不需要优化器来迈步。这个区别对部署很关键:把[权重加载](./3.9._权重保存与加载.md)进一套全新的架构之后,你可以立刻调用 `predict`,不需要补一个优化器。
从 [3.4](./3.4._优化器.md) 和 [3.3](./3.3._损失函数.md) 里挑优化器和损失函数。
每个损失家族的归一化方式各不相同。`MeanSquaredError`、`MeanAbsoluteError` 和 `BinaryCrossEntropy` 对每个元素取平均。`CategoricalCrossEntropy` 在末尾的类别轴上求和,再对预测位置取平均。一个预测位置,对 `[batch, classes]` 目标来说就是一个样本。对逐像素预测 1 个类别的 channels-last 卷积 softmax 头来说,一个预测位置就是一个像素,这时的除数就是 `batch * height * width`。
切换损失家族会重新缩放梯度的量级,也就改变了实际的学习率。换了损失函数之后,记得重新调步长。
## 3.1.4. `summary`:读懂网络结构
`summary()` 会往 stdout 打印一张 Keras 风格的表。在你为训练花掉若干 epoch 之前,用它来核对接线和参数量。下面是一个 `Dense(2 -> 8, ReLU)` 层后接 `Dense(8 -> 2, Softmax)` 层的表:
```text
Model: "sequential"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type) ┃ Output Shape ┃ Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ dense (Dense) │ (None, 8) │ 24 │
│ dense_1 (Dense) │ (None, 2) │ 18 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
Total params: 42 (168 B)
Trainable params: 42 (168 B)
Non-trainable params: 0 (0 B)
```
RustyML 按类型生成层名,采用 Keras 风格:第一个 `Dense` 层叫 `dense`,下一个叫 `dense_1`,以此类推。输出形状里的 `None` 是 batch 维度,在数据到来之前一直是未知的。
参数量来自每一层的 `param_count()` 方法。一个 `Dense(in -> out)` 层报告 `in * out + out` 个参数,也就是权重矩阵加偏置向量,所以 `2 * 8 + 8 = 24`,`8 * 2 + 2 = 18`。字节数按每个 `f32` 占 4 字节估算。
层分成 3 组:可训练参数计入 "Trainable",冻结参数计入 "Non-trainable",无参数的层(比如激活函数和池化)贡献 0。`summary` 借的是 `&self`,从不改动模型,所以随时都能调用它,训练前训练后都行。
## 3.1.5. `fit` 与训练循环
```rust,ignore
pub fn fit(&mut self, x: &Tensor, y: &Tensor, epochs: u32) -> Result<History, Error>;
```
**`fit` 是全批量的。** 每个 epoch 都恰好是在你提供的整个 `x` 和 `y` 上算一次梯度步,只有一个 batch,所以什么都不会被打乱,模型也从不读取它的打乱种子。`epochs` 数的就是这种全数据集步骤的次数。相比之下,Keras 的 `fit` 默认会切成 mini-batch,并且每个 epoch 都打乱。如果一个 epoch 一步梯度对你的数据集来说收敛得太慢,就改用 `fit_with_batches`。要是一次前向传播装不下你的内存预算,也用它(见下文)。
每个 epoch,`fit` 都跑一次 `train_batch` 调用。[下文那一小节](#自己写循环train_batch-与-evaluate)就是用这同一个公开的单步方法来搭自定义循环的。`train_batch` 按顺序执行这些步骤:
1. 以训练模式前向穿过每一层。
2. 算出标量损失。
3. 算出损失对输出的梯度。
4. 把优化器的全局步数推进一次。这让 Adam 每步只抬一次偏差修正的时间戳,而不是每层抬一次。
5. 沿层反向传播,让每一层各自把参数梯度藏好。
6. 如果优化器要求,就按全局范数裁剪(见 [`Optimizer::global_clipnorm`](./3.4._优化器.md))。
7. 更新每一层的参数。
`step()` 在每层的 `update()` 调用之前跑,正是这个顺序让依赖步数的优化器在多层模型下依然正确。
`fit` 返回一个 `History`,它的全部 API 就是 `loss()`:每个 epoch 一个 `f32`,按 epoch 顺序排列。`epochs = 0` 就给出一个空切片。每一项都是在这个 epoch **进行当中** 测到的每样本平均损失,测量发生在这个 epoch 自己的更新之前。每个 batch 贡献的,都是它自己那次权重更新之前那趟前向传播的损失。所以每一项描述的是模型在这个 epoch 运行期间持有的权重,绝不是这个 epoch 结束时的那份权重。
把最后一项当成训练完的模型的最终损失,在两个方向上都会出错。训练还在收敛时,这一项会偏高,因为这个 epoch 自己的更新已经改进了它所测的那份权重。一旦步长冲过头,这一项又会偏低,因为那些更新反而把情况弄糟了。这是 Keras 的约定,不是循环写法的意外。`evaluate`(见下文)报告的才是你手上这个模型现在的损失。
`show_progress` feature 只是加了一个显示,并不是读取损失的唯一途径。开着它,`fit` 会渲染一个实时进度条,显示当前 epoch 的损失,精确到 6 位小数。`fit_with_batches` 渲染的是一个类似的进度条,跟着 batch 推进显示运行平均损失:
```toml
[dependencies]
rustyml = { version = "0.15", features = ["neural_network", "show_progress"] }
```
```text
[00:00:00] ████████████████████████████████████████ 400/400 | Loss: <current loss>
```
不开这个 feature,训练就静默进行。返回的 `History` 两种情况下带的是同样的数字,所以你的代码不必依赖这个 feature 开没开。
### 用 `fit_with_batches` 做小批量训练
```rust,ignore
pub fn fit_with_batches(&mut self, x: &Tensor, y: &Tensor, epochs: u32, batch_size: usize)
-> Result<History, Error>;
```
`fit_with_batches` 是小批量循环:它在每个 epoch 开始时重新打乱样本顺序,然后按固定大小的分块训练。因为会打乱,这是全模型里唯一让种子起作用的地方。用 `Sequential::new_with_seed(seed)` 或 `set_seed(seed)` 来设定它,就能得到可复现的打乱顺序(见 [7.1](../Chapter-07/7.1._可复现性与随机种子.md))。这个种子只管打乱,不影响权重初始化,后者由每一层通过自己的 `with_random_state` 调用来播种。
`batch_size` 为 `0`,或者大于数据集大小,都会返回 `Error::InvalidParameter`。`batch_size` 等于 `n_samples` 时,会退化成每个 epoch 一次全批量步,跟 `fit` 一样。
它的 `History` 每一项跟 `fit` 的含义相同,只多一条细则,只有在 `batch_size` 除不尽数据集时才会显形:每个 batch 按自己的样本数、成比例地计入这个 epoch 的损失,而不是一批一票。所以最后那个不满的尾批,对这个 epoch 数字的拉动比一个满批要小。这样每一项才恰好是整个数据集上的每样本平均损失,也正是 Keras 报出来的东西。Keras 的 loss 指标按 `sample_weight = batch_size` 累加每个 batch,而各批直接取平均对不上这个数。有一个测试把加权方式和"更新之前"这个测量时机,都钉死在取自 Keras 3.15 的实际数字上:`tests/neural_network/sequential.rs::test_batch_losses_and_epoch_mean_match_keras`。
```rust
use ndarray::Array;
use rustyml::prelude::*;
fn main() {
let x = Array::ones((8, 3)).into_dyn();
let y = Array::zeros((8, 1)).into_dyn();
// 给逐 epoch 的打乱播种,让这次运行可复现。
let mut model = Sequential::new_with_seed(0);
model
.add(Dense::new(3, 6, Activation::ReLU).unwrap())
.add(Dense::new(6, 1, Activation::Sigmoid).unwrap())
.compile(
SGD::new(0.05, 0.9, false, 0.0).unwrap(),
BinaryCrossEntropy::new(),
);
// 每个 epoch 四个小批量、每批两个样本,且每个 epoch 都重新打乱。
let history = model.fit_with_batches(&x, &y, 5, 2).unwrap();
assert_eq!(history.loss().len(), 5);
// 外部学习率调度:把步长读出来、减半、再写回去。优化器会跨越这次改动
// 保留自己的动量缓冲。
let lr = model.learning_rate().unwrap();
model.set_learning_rate(lr * 0.5);
let resumed = model.fit_with_batches(&x, &y, 5, 2).unwrap();
// 训练是接着上次的进度继续,而不是从头再来。
assert!(resumed.loss()[0] < *history.loss().last().unwrap());
assert_eq!(model.predict(&x).unwrap().shape(), &[8, 1]);
}
```
上文用到的 `set_learning_rate`,是外部调度(比如 step decay 或 warmup)的钩子。它就地重设步长,优化器会跨越这次改动,保留自己积累的全部状态,比如动量缓冲和 Adam 的各阶矩。如果模型还没编译过,它就什么都不做。
`learning_rate()` 是它的读取那一半,未编译的模型上它返回 `None`。这种"读取-缩放-写回"的模式,省得在模型旁边另存一份学习率副本,那份副本迟早会跟优化器对不上。跟优化器的构造函数不同,`set_learning_rate` 不做任何校验,`learning_rate()` 会原样报告你设置的值,哪怕是零或者负数。
### 自己写循环:`train_batch` 与 `evaluate`
```rust,ignore
pub fn train_batch(&mut self, x: &Tensor, y: &Tensor) -> Result<f32, Error>;
pub fn evaluate(&self, x: &Tensor, y: &Tensor) -> Result<f32, Error>;
```
`train_batch` 是两个 `fit` 变体共用的那个单步,而且它是公开的。任何别的 epoch 结构,都是你自己写的循环,而不是把库 fork 一份,比如课程式排序、逐步调度,或者步与步之间插一次探测。
整个 `x` 就是这一批:不拆分、不打乱,依赖模式的层跑在训练模式下。返回的 `f32` 是这次调用自身更新之前那趟前向传播的损失。`fit` 每个 epoch 记下的正是这个数,`fit_with_batches` 也是把这些数按样本数加权平均之后,才写进每一项 `History`。Keras 管这个方法叫 `train_on_batch`。
`train_batch` 自己校验自己的输入,而不是指望调用方已经校验过。所以在未编译的模型上调它,得到的是 `NotCompiled` 错误,而不是一个 panic。
`evaluate` 是另外一半。它在整个 `x` 上跑一趟推理模式的前向传播,再用编译好的损失函数打分。它什么都不更新:不算梯度、不动参数、也不动 batch norm 的滑动统计量。它借的是 `&self`,所以在训练步之间给模型打分,不可能扰动它。它也不从任何随机数生成器取数,所以塞进 `fit_with_batches` 的循环里,也不可能搅乱打乱序列。
各层的行为跟在 `predict` 里完全一致:dropout 和噪声层是恒等映射,batch normalization 读它的滑动统计量。于是在含这类层的模型上,`evaluate` 跟 `fit` 为同一批数据记下的数字并不相等。训练模式的 dropout 把 `fit` 记的那个数抬高了。两者之中,`evaluate` 给出的才是更准的估计。
有了 `train_batch` 和 `evaluate`,提前停止、学习率调度和检查点选择就都成了你自己写的普通代码。下面这个循环一次走一个全批量步。它给每一步之后手上这个模型打分,连着 10 步没有改进就把步长减半,调度用完了就停:
```rust
use ndarray::Array;
use rustyml::prelude::*;
fn main() {
// y = 2x + 1 上的八个点。
let x = Array::from_shape_vec((8, 1), (0..8).map(|i| i as f32 / 8.0).collect::<Vec<_>>())
.unwrap()
.into_dyn();
let y = x.mapv(|v| 2.0 * v + 1.0);
let mut model = Sequential::new();
model
.add(Dense::new(1, 8, Activation::Tanh).unwrap().with_random_state(0))
.add(Dense::new(8, 1, Activation::Linear).unwrap().with_random_state(0))
.compile(SGD::new(0.1, 0.9, false, 0.0).unwrap(), MeanSquaredError::new());
let start = model.evaluate(&x, &y).unwrap();
let (mut best, mut stale, mut previous) = (start, 0, start);
for _ in 0..500 {
// 这一步报告的是它出发时的损失,而这摞层里没有 dropout,所以这个数就是上一次
// `evaluate` 重新量了一遍——滞后一步。
let during = model.train_batch(&x, &y).unwrap();
assert!((during - previous).abs() < 1e-5);
// 这一个才是给这步产出的权重打分。
let after = model.evaluate(&x, &y).unwrap();
previous = after;
if after < best - 1e-6 {
best = after;
stale = 0;
continue;
}
// 十步没有进展:把步长减半,而且是从优化器里读,而不是从存在这儿的副本读。
// 小到这个地步,就没什么可再试的了。
stale += 1;
if stale == 10 {
let lr = model.learning_rate().unwrap();
if lr < 1e-4 {
break;
}
model.set_learning_rate(lr * 0.5);
stale = 0;
}
}
assert!(best < start / 100.0);
}
```
## 3.1.6. `predict`:只做前向的推理
```rust,ignore
pub fn predict(&self, x: &Tensor) -> Result<Tensor, Error>;
```
`predict` 让推理前向路径(`Layer::predict`)穿过每一层,返回输出 `Tensor`。它借 `&self`,不分配任何反向缓存,并把[依赖模式的层](./3.8._正则化与归一化层.md)切到推理行为:dropout 被关闭,batch normalization 改用它的滑动统计量,这正是部署时该有的行为。`predict` 是确定性的:同一输入上调用 2 次,返回完全相同的张量。跟训练和评估不同,`predict` 不需要 `compile`。
结果形状就是最后一层吐出的东西,`Dense` 收尾的话就是 `(batch, output_dim)`。输入必须符合每一层的预期:`Dense` 层要求一个二维的 `(batch, features)` 张量,其他任何东西都会返回 `Error::InvalidInput`。
## 3.1.7. 一个完整示例:学习 XOR
XOR 是线性模型解不了的最小问题,它是隐藏层确实在干活的经典证明。这个示例用了 2 个 `Dense` 层:一个 `Tanh` 隐藏层,加一个作用在 2 个类别上的 `Softmax` 输出头,用 Adam 和多分类交叉熵训练,就能把两类干净地分开。目标是 one-hot 的:类别 0 是 `[1, 0]`,类别 1 是 `[0, 1]`。用 `with_random_state(0)` 给两层的权重初始化播种,让这次运行可复现。
```rust
use ndarray::Array;
use rustyml::prelude::*;
fn main() {
// XOR 输入,形状 (4, 2)。
let x = Array::from_shape_vec((4, 2), vec![0.0_f32, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0])
.unwrap()
.into_dyn();
// One-hot 目标:(0,1) 和 (1,0) 处 XOR 为类别 1,其余为类别 0。
let y = Array::from_shape_vec((4, 2), vec![1.0_f32, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0])
.unwrap()
.into_dyn();
let mut model = Sequential::new();
model
.add(Dense::new(2, 8, Activation::Tanh).unwrap().with_random_state(0))
.add(Dense::new(8, 2, Activation::Softmax).unwrap().with_random_state(0))
.compile(
Adam::new(0.1, 0.9, 0.999, 1e-8, 0.0).unwrap(),
CategoricalCrossEntropy::new(false),
);
model.summary();
let history = model.fit(&x, &y, 400).unwrap();
assert!(*history.loss().last().unwrap() < history.loss()[0] / 100.0);
let preds = model.predict(&x).unwrap();
for i in 0..4 {
// 在 2 个类别概率上取 argmax
let class = if preds[[i, 0]] >= preds[[i, 1]] { 0 } else { 1 };
println!("row {i} -> class {class} probs [{:.3}, {:.3}]", preds[[i, 0]], preds[[i, 1]]);
}
}
```
跑完 400 个全批量 epoch,网络把 2 个真的 XOR 行分给类别 1,把 2 个假的行分给类别 0,每个概率都落在 `1.000` 或者非常接近它。`History` 记下了这段下降:最后一个 epoch 比第一个低了 2 个数量级还多,上面那行断言查的就是这件事。`CategoricalCrossEntropy::new(false)` 这个参数告诉损失函数,输出头(`Softmax` 层)已经产出概率了,所以损失函数不该再套一次自己的 log-softmax。只有当你最后一层吐的是原始 logits 时,才传 `true`。
## 3.1.8. 错误、形状检查与不匹配 panic
`fit`、`fit_with_batches` 和 `train_batch` 都在碰任何层之前先校验模型和输入,检查按这个顺序进行:
1. 优化器在场。
2. 损失函数在场。
3. 模型至少有 1 层。
4. 输入有 batch 轴。
5. 输入非空。
6. `x` 与 `y` 的 batch 大小一致。
`evaluate` 做的是同一套检查,只是没有优化器那一项,因为只有参数更新才用得上优化器。下表把每种失败映射到它的错误变体,全都是 [`rustyml::error::Error`](../Chapter-01/1.6._错误处理.md) 的值:
| 情况 | 返回的错误 |
|---|---|
| `compile` 之前就 `fit`/`fit_with_batches`/`train_batch` | `Error::NeuralNetwork(NnError::NotCompiled("optimizer"))` |
| `compile` 之前就 `evaluate` | `Error::NeuralNetwork(NnError::NotCompiled("loss function"))` |
| 在没有层的模型上训练、评估或 `predict` | `Error::NeuralNetwork(NnError::EmptyModel)` |
| `x` 或 `y` 是零阶张量(标量,没有 batch 轴) | `Error::InvalidInput(_)` |
| `x` 或 `y` 为空 | `Error::EmptyInput(_)` |
| `x` 与 `y` 的 batch 大小(行数)不一致 | `Error::DimensionMismatch { .. }` |
| `fit_with_batches` 且 `batch_size == 0` 或 `> n_samples` | `Error::InvalidParameter { .. }` |
| 非二维输入喂给 `Dense` 层 | `Error::InvalidInput(_)` |
零阶那一行需要解释一下:零维张量并不是空张量,它恰好装着 1 个元素,所以 `is_empty` 不会拒绝它,紧接着要取的那个 batch 轴下标就无处可读。这以前是一个 panic,现在会返回 `InvalidInput`。
这些都是可恢复的 `Result` 值。下一个示例展示了 `compile` 这一要求:训练和评估各自对 `compile` 的要求并不相同,而 `predict` 两样都不需要:
```rust
use ndarray::Array;
use rustyml::error::Error;
use rustyml::neural_network::NnError;
use rustyml::prelude::*;
fn main() {
let x = Array::ones((4, 2)).into_dyn();
let y = Array::ones((4, 1)).into_dyn();
let mut model = Sequential::new();
model.add(Dense::new(2, 1, Activation::Linear).unwrap());
// fit 需要优化器和损失函数;没有 compile 它会快速失败,并点名它发现缺的
// 第一样东西。
match model.fit(&x, &y, 1) {
Err(Error::NeuralNetwork(NnError::NotCompiled(missing))) => assert_eq!(missing, "optimizer"),
other => panic!("expected NotCompiled, got {other:?}"),
}
// evaluate 什么都不更新,所以它只要那个用来打分的损失函数。
match model.evaluate(&x, &y) {
Err(Error::NeuralNetwork(NnError::NotCompiled(missing))) => {
assert_eq!(missing, "loss function")
}
other => panic!("expected NotCompiled, got {other:?}"),
}
// predict 则相反,从不需要 compile:它只做前向。
assert_eq!(model.predict(&x).unwrap().shape(), &[4, 1]);
}
```
有一种情况,是 `Result` 体系里真正的一个缺口。**相邻层之间的维度不匹配不是一个 `Result`,而是一个 panic。** `add` 从不检查相邻层是否吻合,所以一个 `Dense(2 -> 4)` 层接一个 `Dense(8 -> 2)` 层,会毫无怨言地搭起来。这处不一致,只有在数据抵达第二层的矩阵乘法时才会暴露,也就是第一次调用 `fit` 或 `predict` 的时候:
```rust,ignore
let mut model = Sequential::new();
model
.add(Dense::new(2, 4, Activation::ReLU).unwrap()) // 吐出 4 列
.add(Dense::new(8, 2, Activation::Softmax).unwrap()); // 期望 8——不一致
let x = Array::ones((3, 2)).into_dyn();
let _ = model.predict(&x); // 在 GEMM 内部 panic,不返回 Err
```
```text
thread 'main' panicked at gemmkit-ndarray-0.1.2/src/fused.rs:85:5:
assertion `left == right` failed: gemmkit-ndarray: A.cols (4) != B.rows (8)
left: 4
right: 8
```
这个 panic 来自矩阵乘法后端,不是来自 RustyML。`Dense::forward` 把乘法结果直接交给 `gemmkit-ndarray`,所以触发的断言属于这个后端,消息里的路径也是 crates.io 的注册表路径,不是这个仓库里的路径。这是预期行为,不是内部 bug 的信号。
把层间宽度当成一个必须由你自己保证的构建期不变量:每一层的 `input_dim` 必须等于前一层的 `units`。不匹配是编程错误,不是坏输入,所以它直接中止,而不是返回一个普通的 `Error`。在构造函数里把宽度对齐(`summary` 就是干这个用的),对齐之后这个 panic 就永远不会触发。对错了,第一批流过模型的数据就会暴露它,panic 消息会点名两个对不上的维度:这里是第一层输出的 4 列,对上第二层期待的 8 行。
从这里往后,每个构建块各有专页:[全连接层与激活函数](./3.2._全连接层与激活函数.md)、[损失函数](./3.3._损失函数.md)、[优化器](./3.4._优化器.md)、卷积层与循环层,以及[权重保存与加载](./3.9._权重保存与加载.md)。