# 3.3. 损失函数
损失函数是训练循环要压向零的那个标量目标。它也是反向穿过网络的第一道梯度的源头。RustyML 在 `rustyml::neural_network::losses` 里提供了 5 个损失。`MeanSquaredError` 和 `MeanAbsoluteError` 处理回归。`BinaryCrossEntropy` 处理二分类和多标签分类。`CategoricalCrossEntropy` 和 `SparseCategoricalCrossEntropy` 处理互斥的多分类问题。每个损失都是一个小巧的类单元结构体(unit-like struct),实现了 `Loss` trait。这让它们在 [`Sequential`](./3.1._Sequential模型.md) 模型里的组合方式完全一致。你也可以直接调用某个损失,用于评估,或者用在手写的训练循环里。
这 5 个损失都不携带任何逐样本状态。它们的构造和拷贝代价都很低(`Debug + Clone + Copy + PartialEq`)。它们的区别在于:各自接受什么形状、如何归一化,以及在定义域边界上如何保持数值稳定。本节把这些事实直接从实现里陈述出来,让你在把某个损失 `compile` 进模型之前,先弄清楚它到底做了什么。
## 3.3.1. `Loss` trait 与平均约定
每个损失都实现两个方法:
```rust,ignore
pub trait Loss {
fn compute_loss(&self, y_true: &Tensor, y_pred: &Tensor) -> Result<f32, Error>;
fn compute_grad(&self, y_true: &Tensor, y_pred: &Tensor) -> Result<Tensor, Error>;
}
```
`Tensor` 是 `ndarray::ArrayD<f32>` 的别名,一个动态维度的 `f32` 数组。注意参数顺序:先 `y_true`,再 `y_pred`。两个方法都返回 `Result`。形状和标签的校验都放在最前面做,而不是等代码算到一半才 panic。错误类型见[错误处理](../Chapter-01/1.6._错误处理.md)。`compute_grad` 返回的是 `compute_loss` 对预测值的梯度。两者始终严格一致,所以这个梯度不是近似值。
这些损失并不都用同一种方式归一化,而且是故意的。回归损失和二分类损失对张量里的每一个元素求平均(`y.len()`),把每个输出当作一个独立目标。`CategoricalCrossEntropy` 则沿末尾的类别轴求和,再对预测位置——类别轴之前所有轴的乘积——求平均。对常见的 `[batch, classes]` 目标,这个除数就是 batch 大小。对 channels-last `Conv2D` softmax 头输出的 `[batch, height, width, classes]`,除数是 `batch * height * width`,每个像素一次预测。这正是 Keras 默认的 `sum_over_batch_size` 归约所算的东西。`SparseCategoricalCrossEntropy` 只接受 2 维预测,所以它的除数始终是 batch 大小。把一个模型从比如 `MeanSquaredError` 换成 `CategoricalCrossEntropy`,梯度的量级会按类别数大致重新缩放。这会改变实际生效的学习率。如果换了损失之后,原本调好的学习率突然发散或停滞,先检查这个重新缩放。去[优化器](./3.4._优化器.md)那里调整学习率来补偿。
| 损失 | 归一化对象 | 除数 |
|------|-----------------|---------|
| `MeanSquaredError` | 每个元素 | `y.len()` |
| `MeanAbsoluteError` | 每个元素 | `y.len()` |
| `BinaryCrossEntropy` | 每个元素 | `y.len()` |
| `CategoricalCrossEntropy` | 沿类别轴求和,对预测位置平均 | 前导各轴的乘积(`batch`,4 维时是 `batch * height * width`) |
| `SparseCategoricalCrossEntropy` | 沿类别轴求和,对 batch 平均 | `y.shape()[0]`(仅限 2 维) |
输入形状也各不相同。弄错它们是 `fit` 调用被拒最常见的原因。下表列出了每一条约束,逐项对照各损失的校验代码核对过。
| 损失 | `y_true` 形状 | `y_pred` 形状 | 约束 |
|------|----------------|----------------|-------------|
| `MeanSquaredError` | 任意 | 与 `y_true` 完全相同 | 形状必须完全一致 |
| `MeanAbsoluteError` | 任意 | 与 `y_true` 完全相同 | 形状必须完全一致 |
| `BinaryCrossEntropy` | 任意(取值 `0.0`/`1.0`) | 与 `y_true` 完全相同(概率在 `(0,1)`) | 形状必须完全一致 |
| `CategoricalCrossEntropy` | `[..., classes]` one-hot | 与 `y_true` 相同 | 至少 2 维,非空。末轴是类别轴 |
| `SparseCategoricalCrossEntropy` | `[batch, 1]`,整数值 | `[batch, num_classes]` | `y_pred` 恰好 2 维。标签落在 `0..num_classes` 内 |
## 3.3.2. 均方误差
MSE 是回归的默认损失。前向值是对所有元素求 `mean((y_pred - y_true)^2)`。梯度是 `2 * (y_pred - y_true) / n`,其中 `n` 是元素总数。系数 2 来自平方项的求导。RustyML 把这个系数折进梯度,而不是折进损失,所以 `compute_grad` 就是 `compute_loss` 的精确梯度。
因为误差项被取了平方,MSE 对大残差按二次方加权。一个偏差为 10 的预测,对损失的贡献是偏差为 1 时的 100 倍。当大误差确实更糟、且目标值大致围绕真值呈高斯分布时,MSE 是对的选择。当数据带重尾噪声、或有你不想让模型去贴合的离群点时,MSE 是错的选择。
```rust
use rustyml::neural_network::Tensor;
use rustyml::neural_network::losses::MeanSquaredError;
use rustyml::neural_network::traits::Loss;
use ndarray::Array;
fn main() {
let mse = MeanSquaredError::new();
let y_true: Tensor = Array::from_shape_vec(vec![2, 2], vec![1.0_f32, 2.0, 3.0, 4.0])
.unwrap()
.into_dyn();
let y_pred: Tensor = Array::from_shape_vec(vec![2, 2], vec![1.0_f32, 3.0, 5.0, 4.0])
.unwrap()
.into_dyn();
let loss = mse.compute_loss(&y_true, &y_pred).unwrap();
let grad = mse.compute_grad(&y_true, &y_pred).unwrap();
println!("MSE loss: {loss:.4}"); // 平方差的均值
println!("grad shape: {:?}", grad.shape());
}
```
两个形状不一致时,两个方法都会返回 `Err(Error::ShapeMismatch { .. })`。这防止了 ndarray 的广播 panic 逃逸出来。训练之后要报告的那些指标,比如 R2 和可解释方差,见[回归指标](../Chapter-05/5.1._回归指标.md)。这里的损失是训练目标,不是评估报告。
## 3.3.3. 平均绝对误差
MAE 是比 MSE 更能容忍离群点的损失。前向值是 `mean(|y_pred - y_true|)`,残差按线性而非二次方贡献。一个偏差为 10 的离群点给损失增加的量,是偏差为 1 时的 10 倍,而不是 100 倍。当目标里含有你不想让它主导训练的离群点时,用 MAE。
梯度需要更多留意。`|x|` 的导数是 `sign(x)`。MAE 的梯度在预测高于目标处是 `+1/n`,低于目标处是 `-1/n`。在精确相等、即 `y_pred == y_true` 处,RustyML 返回 `0.0`,而不是随便给一个 `+1/n` 或 `-1/n`。这是一个刻意选择的次梯度。完美预测会给出一个真正的零梯度。零点处的折角是真实存在的。梯度大小不会随着预测逼近目标而缩小,这一点和 MSE 不同,MSE 的梯度随残差成比例缩放。因此朴素的 SGD 在 MAE 上可能会在最优点附近来回振荡。把 MAE 和一个按梯度历史缩放步长的自适应[优化器](./3.4._优化器.md)搭配起来,实践中能减少这种振荡。
符号逻辑有一个最后的分支。它对任何既不大于 0、也不小于 0、又不等于 0 的残差返回 `f32::NAN`。这个分支只对 `NaN` 残差触发。有限输入永远走不到这里。如果 `NaN` 值已经进入了你的预测,MAE 不会把它转换成一个干净的数。这符合库的策略:把非有限值暴露出来,而不是藏起来。
```rust
use rustyml::neural_network::Tensor;
use rustyml::neural_network::losses::{MeanAbsoluteError, MeanSquaredError};
use rustyml::neural_network::traits::Loss;
use ndarray::Array;
fn main() {
// 4 个干净的点,外加最后一位 1 个较大的离群点。
let y_true: Tensor = Array::from_shape_vec(vec![5], vec![1.0_f32, 2.0, 3.0, 4.0, 5.0])
.unwrap()
.into_dyn();
let y_pred: Tensor = Array::from_shape_vec(vec![5], vec![1.0_f32, 2.0, 3.0, 4.0, 15.0])
.unwrap()
.into_dyn();
let mse = MeanSquaredError::new();
let mae = MeanAbsoluteError::new();
// 这个 10 单位的误差给 MSE 增加约 100/5,只给 MAE 增加约 10/5。
// 离群点主导了 MSE。它没有主导 MAE。
println!("MSE: {:.4}", mse.compute_loss(&y_true, &y_pred).unwrap());
println!("MAE: {:.4}", mae.compute_loss(&y_true, &y_pred).unwrap());
}
```
## 3.3.4. 二分类交叉熵
`BinaryCrossEntropy` 面向的是每个输出都是一个独立是/否判断的问题。这既包括每个样本一个二元标签,也包括每个样本好几个互相独立的二元标签(多标签分类)。前向值是对每个元素求 `mean(-[y * ln(p) + (1 - y) * ln(1 - p)])`,其中 `y` 是 `0.0`/`1.0` 标签,`p` 是预测概率。因为它对每个元素求平均,一个形状为 `[batch, labels]` 的多标签输出会对 `batch * labels` 求平均。这就是惯例上的平均二分类交叉熵。
有 2 点要弄对。第一是**标签格式**:`y_true` 必须装 `0.0` 或 `1.0` 浮点数,不是类别索引,也不是 logits。第二,`y_pred` 必须是**`(0, 1)` 区间内的概率**。这个损失没有 logits 模式。你必须自己把网络输出压进 `(0, 1)`,实践中就是接一个 `Sigmoid` 输出层(见[全连接层与激活函数](./3.2._全连接层与激活函数.md))。这与 Keras 有实质差别。Keras 的 `BinaryCrossentropy` 接受一个 `from_logits` 标志。RustyML 的没有,所以 `Sigmoid` 层是必需的,不是可选的。
预测恰好为 `0.0` 或 `1.0` 时,`ln(0)` 和除零可能产出 `NaN` 或 `Inf`。为了防止这一点,这个损失在取对数之前,会把每个概率裁剪进 `[1e-7, 1 - 1e-7]`,前向和梯度两条路径都是如此。设想一个对正标签“完全自信且正确”、恰为 `1.0` 的预测。它给出的是一个微小但有限的损失(约 `1e-7`)和一个有限的梯度,而不是一个零损失和一个 NaN 梯度。
```rust
use rustyml::neural_network::Tensor;
use rustyml::neural_network::losses::BinaryCrossEntropy;
use rustyml::neural_network::traits::Loss;
use ndarray::Array;
fn main() {
let bce = BinaryCrossEntropy::new();
// 标签是 0.0 或 1.0。预测是 (0, 1) 区间内的概率。
let y_true: Tensor = Array::from_shape_vec(vec![4], vec![0.0_f32, 1.0, 1.0, 0.0])
.unwrap()
.into_dyn();
let y_pred: Tensor = Array::from_shape_vec(vec![4], vec![0.1_f32, 0.9, 0.8, 0.2])
.unwrap()
.into_dyn();
println!("BCE loss: {:.4}", bce.compute_loss(&y_true, &y_pred).unwrap());
// 即便落在边界上,裁剪也让损失保持有限。
let hard_pred: Tensor = Array::from_shape_vec(vec![4], vec![0.0_f32, 1.0, 1.0, 0.0])
.unwrap()
.into_dyn();
let loss = bce.compute_loss(&y_true, &hard_pred).unwrap();
assert!(loss.is_finite());
println!("BCE at boundary predictions: {loss:.6}");
}
```
## 3.3.5. 多分类交叉熵
`CategoricalCrossEntropy` 处理带 one-hot 目标的互斥多分类。末轴始终是类别轴。它之前的每一个轴都索引一个独立的预测位置。形状为 `[batch, classes]` 的 `y_true` 和 `y_pred` 给出每个样本 1 次预测。channels-last `Conv2D` softmax 头输出的 `[batch, height, width, classes]` 给出每个像素 1 次预测,也就是逐像素分割那种场景。损失沿类别轴对交叉熵求和。然后它除以预测位置的总数,也就是前导各轴的乘积。这个除数在 2 维时是 `batch`,在 4 维时是 `batch * height * width`。
这个损失还要求输入至少 2 维、且非空。1 维输入会被 `Error::InvalidInput` 拒绝。1 维张量没有前导轴,那样会让除数变成 1,softmax 也会跨仅有的那一个轴去归一化。空输入会被 `Error::EmptyInput` 拒绝。
构造函数接受 1 个布尔参数 `from_logits`。它控制着 2 种不同的模式:
```rust,ignore
CategoricalCrossEntropy::new(false) // y_pred 已经是一个概率分布
CategoricalCrossEntropy::new(true) // y_pred 是原始 logits,损失在内部施加 softmax
```
当 `from_logits = false`(默认值)时,`y_pred` 应当是沿末轴的一个概率分布,也就是一个 `Softmax` 层的输出。跟 Keras 一样,损失先用 `y_pred / sum(y_pred, axis=-1)` 对每一行重新归一化。之后才裁剪进 `[1e-7, 1 - 1e-7]`,得到 `-sum(y_true * ln(q_clipped)) / sites`。
softmax 的行本就和为 1,所以这一步除法不会改变损失值。这个除法仍然是 `compute_grad` 求导对象的一部分。梯度是 `(sum(y_true) - y_true / q_clipped) / (row_sum * sites)`。整个括号都要再除以行和。用未归一化的 `p` 写出来,就是 `(sum(y_true) / row_sum - y_true / p) / sites`。这正是熟悉的 `-y/p` 项,外加一个在每一行内保持恒定的项。softmax 的反向传播会把任何行内常数项消掉,所以 softmax 输出头无论如何都训练得一样。这个额外的项只有在这个损失读取一个非 softmax 的输出头时才要紧。
重新归一化带来 1 个实际好处。一个各行和不完全等于 1 的输出头,会被按它所隐含的那个分布来评分,而不是因为尺度不对而挨罚。
当 `from_logits = true` 时,损失把 `y_pred` 当作原始的、未归一化的 logits。它自己施加 softmax,用的是一个数值稳定的 log-softmax。训练时优先用这个模式,理由有 2 个。
第一个理由是稳定性。朴素地先算 `softmax(z)` 再取 `ln(...)`,对大 logits 会让 `exp` 溢出。对一个已经被裁剪到接近零的概率取对数,也会损失精度。内部路径的做法是先减去每个预测位置的最大值再取指数,这样 `exp` 永不溢出。然后它直接计算 `log_softmax = z - logsumexp(z)`,这样就永不对裁剪过的概率取对数。这一步归一化只在 1 个预测位置内部进行,绝不跨位置。因此 4 维卷积头的各个像素不会彼此争夺概率质量。
第二个理由是效率。对 logits 的梯度会收缩成融合形式 `(softmax(z) - y_true) / sites`。这个形式算起来更省。它还避开了 `-y/p` 这个除法,`p` 接近 0 时它会爆炸。
这带来 1 个实际后果。logits 模式下的梯度是对 logits 求的。你网络的最后一层因此必须输出 logits,而不是概率。用 `Linear` 作最终激活,不要接 `Softmax` 层。如果你留着一个 `Softmax` 层,又传 `from_logits = true`,模型就会施加两次 softmax,这会毁掉训练。
```rust
use rustyml::neural_network::Tensor;
use rustyml::neural_network::losses::CategoricalCrossEntropy;
use rustyml::neural_network::traits::Loss;
use ndarray::Array;
fn main() {
// 概率模式:y_pred 必须已经是一个 softmax 分布。
let cce_probs = CategoricalCrossEntropy::new(false);
let y_true: Tensor = Array::from_shape_vec(
vec![3, 3],
vec![1.0_f32, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
)
.unwrap()
.into_dyn();
let probs: Tensor = Array::from_shape_vec(
vec![3, 3],
vec![0.8_f32, 0.1, 0.1, 0.2, 0.7, 0.1, 0.1, 0.2, 0.7],
)
.unwrap()
.into_dyn();
println!("CCE (probs): {:.4}", cce_probs.compute_loss(&y_true, &probs).unwrap());
// logits 模式:y_pred 是原始 logits,损失在内部施加 softmax。
let cce_logits = CategoricalCrossEntropy::new(true);
let logits: Tensor = Array::from_shape_vec(vec![1, 3], vec![1.0_f32, 2.0, 0.5])
.unwrap()
.into_dyn();
let label: Tensor = Array::from_shape_vec(vec![1, 3], vec![0.0_f32, 1.0, 0.0])
.unwrap()
.into_dyn();
let loss = cce_logits.compute_loss(&label, &logits).unwrap();
let grad = cce_logits.compute_grad(&label, &logits).unwrap();
// grad 是融合后的 (softmax(logits) - one_hot) / 预测位置数。
println!("CCE (logits): {loss:.4}, grad shape {:?}", grad.shape());
}
```
## 3.3.6. 稀疏多分类交叉熵
`SparseCategoricalCrossEntropy` 计算的损失和 `CategoricalCrossEntropy` 相同,但它接受整数类别标签,而不是 one-hot 向量。`y_true` 是一个 `[batch, 1]` 张量,存的是类别索引,以 `f32` 存储并四舍五入到最近的整数。`y_pred` 是一个 `[batch, num_classes]` 的概率或 logits 张量。测试印证了这种等价:对同一批预测,整数标签上的稀疏损失和对应 one-hot 编码上的稠密 CCE,在浮点舍入误差内一致。它们的梯度也一致。
形状规则很严格。没有这些规则,一个畸形的标签会引发一个晦涩的下标越界 panic。`y_pred` 必须恰好 2 维。3 维预测会被 `Error::InvalidInput` 拒绝。`y_true` 必须恰好是 `[batch, 1]`。1 维标签向量或 `[batch, 2]` 形状都会被拒绝。这与 Keras 有实质差别:Keras 的稀疏多分类交叉熵接受 1 维的 `[batch]` 标签。每个标签都必须有限、非负、且严格小于 `num_classes`。负数或越界的标签会返回 `Error::InvalidInput`。标签与预测之间的 batch 大小不匹配会返回 `Error::DimensionMismatch`。校验只做一次,在最前面做。它会在任何算术开始之前,抽出一个类别索引的 `Vec<usize>`。
省下的内存随类别数增长。一个稠密 one-hot 目标是 `[batch, num_classes]` 的 `f32`,也就是 `batch * num_classes * 4` 字节,其中几乎全是零。稀疏标签是 `[batch, 1]`,也就是 `batch * 4` 字节。这是目标张量上 `num_classes` 倍的缩减,而且你压根不用构建 one-hot 矩阵。对一个几万类的词表来说,这就是“一个微不足道的标签张量”和“一个比预测本身还大的张量”之间的差别。当标签天然是整数、且 `num_classes` 很大时,用稀疏损失。手头已经有 one-hot(或软)目标时,用稠密 CCE。
`from_logits` 标志的工作方式和稠密 CCE 完全一样。当 `false` 时,损失期望逐行概率。它在裁剪之前对每一行重新归一化,和 `CategoricalCrossEntropy` 完全一样,所以它会把一个未归一化的输出头按它所隐含的那个分布来评分。行归一化器也是 `compute_grad` 求导对象的一部分。正因如此,每个类别在梯度里都会分到一个共享的 `1 / (batch * row_sum)` 项,不只是标签所在的那个类别。softmax 输出头会在反向传播里把这个共享项消掉,和稠密 CCE 的情形一样。当 `true` 时,损失期望 logits,并施加同样的稳定 log-softmax。融合梯度是:在真实类别处减 1 的 softmax,再除以 batch。
概率路径还会串行地累加各样本的损失,而不用并行归约。这让报告出来的损失不会随线程调度而漂移。见[可复现性与随机种子](../Chapter-07/7.1._可复现性与随机种子.md)。
```rust
use rustyml::neural_network::Tensor;
use rustyml::neural_network::losses::SparseCategoricalCrossEntropy;
use rustyml::neural_network::traits::Loss;
use ndarray::Array;
fn main() {
let scce = SparseCategoricalCrossEntropy::new(false);
// 标签是 [batch, 1] 列里的类别索引,不是 one-hot 行。
let y_true: Tensor = Array::from_shape_vec(vec![3, 1], vec![0.0_f32, 1.0, 2.0])
.unwrap()
.into_dyn();
let y_pred: Tensor = Array::from_shape_vec(
vec![3, 3],
vec![0.8_f32, 0.1, 0.1, 0.2, 0.7, 0.1, 0.1, 0.2, 0.7],
)
.unwrap()
.into_dyn();
let loss = scce.compute_loss(&y_true, &y_pred).unwrap();
let grad = scce.compute_grad(&y_true, &y_pred).unwrap();
println!("SCCE loss: {loss:.4}"); // 等于等价 one-hot 上的稠密 CCE
println!("grad shape: {:?}", grad.shape());
// RustyML 拒绝越界和负数标签,而不是悄悄把它们回绕。
let bad: Tensor = Array::from_shape_vec(vec![3, 1], vec![0.0_f32, 1.0, 9.0])
.unwrap()
.into_dyn();
assert!(scce.compute_loss(&bad, &y_pred).is_err());
}
```
## 3.3.7. 激活函数与损失函数的搭配
最后一层的激活和损失构成一对匹配的搭配。搭错了,你要么给损失喂了错误的定义域(要 logits 的地方给了概率,反之亦然),要么就会施加两次 softmax。下表列出了正确的组合,以及每一种为何有效。
| 任务 | 最终激活 | 损失 | 为何相配 |
|------|------------------|------|---------------|
| 回归 | `Linear` | `MeanSquaredError` / `MeanAbsoluteError` | 输出是无界实数,所以平方或绝对误差就是天然目标,无需任何压缩 |
| 二分类 / 多标签 | `Sigmoid` | `BinaryCrossEntropy` | sigmoid 把每个 logit 独立映进 `(0, 1)`。BCE 吃概率,没有 logits 模式,所以 sigmoid 是必需的 |
| 多分类,概率模式 | `Softmax` | `CategoricalCrossEntropy::new(false)` / `SparseCategoricalCrossEntropy::new(false)` | softmax 把一行归一化成一个分布,这恰好是这些损失所期望的 |
| 多分类,logits 模式(推荐) | `Linear` | `CategoricalCrossEntropy::new(true)` / `SparseCategoricalCrossEntropy::new(true)` | 损失在内部施加稳定的 softmax 并返回融合梯度。在这里再加一个 `Softmax` 层会让 softmax 被施加两次 |
两行分类之间的区分很重要。`Sigmoid` 加 `BinaryCrossEntropy` 适合独立的二元输出,一个样本可以同时属于好几个标签。`Softmax` 加 `CategoricalCrossEntropy` 适合互斥的类别,各类别的概率加起来为 1。标签互相独立时不要用 softmax。标签互相竞争时不要用 sigmoid。
下面这个模型搭建了推荐的多分类方案:一个 `Linear` 输出喂给 logits 模式的 `CategoricalCrossEntropy`。它没有 `Softmax` 层,因为损失自己施加了 softmax。
```rust
use rustyml::neural_network::sequential::Sequential;
use rustyml::neural_network::layers::{Activation, Dense};
use rustyml::neural_network::optimizers::Adam;
use rustyml::neural_network::losses::CategoricalCrossEntropy;
use ndarray::Array;
fn main() {
// 4 个样本,3 个特征 -> 3 个类别。
let x = Array::from_shape_vec(
vec![4, 3],
vec![
0.1_f32, 0.2, 0.7, 0.9, 0.1, 0.0, 0.2, 0.8, 0.1, 0.7, 0.2, 0.1,
],
)
.unwrap()
.into_dyn();
// one-hot 目标:类别 2、0、1、0。
let y = Array::from_shape_vec(
vec![4, 3],
vec![
0.0_f32, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0,
],
)
.unwrap()
.into_dyn();
let mut model = Sequential::new();
model
.add(Dense::new(3, 8, Activation::ReLU).unwrap())
.add(Dense::new(8, 3, Activation::Linear).unwrap()); // logits,不接 Softmax
model.compile(
Adam::new(0.01, 0.9, 0.999, 1e-8, 0.0).unwrap(),
CategoricalCrossEntropy::new(true), // from_logits = true
);
model.fit(&x, &y, 5).unwrap();
let preds = model.predict(&x).unwrap();
println!("prediction shape: {:?}", preds.shape());
}
```
## 3.3.8. 数值稳定性与边界行为
有 3 个机制让这些损失在定义域边缘保持有限。每一个都守护着不同的失效方式。
概率裁剪守护的是那些以概率为输入的交叉熵损失。这包括 `BinaryCrossEntropy`,以及 `from_logits = false` 模式下的 `CategoricalCrossEntropy` / `SparseCategoricalCrossEntropy`。在任何 `ln` 之前,每个概率都会被夹进 `[1e-7, 1 - 1e-7]`。所以恰为 `0.0` 或 `1.0` 的预测产生的是一个大但有限的损失和一个有限的梯度,而不是 `NaN` 或 `Inf`。
这个裁剪守的是 `log(0)`。它不是一道梯度闸门。与 Keras 的自动微分不同,`compute_grad` 是在裁剪后的概率处取值。它不会把被裁剪的位置清零。损失和梯度在区间内部处处一致。
对多分类损失来说,按行重新归一化发生在裁剪之前。一行和为零时,结果仍然是非有限的,这与 Keras 的行为一致。测试直接检验了这一点:完全自信的预测和最坏情况的边界预测都保持有限。
稳定的 log-softmax 守护的是 logits 路径。它不去构造 `softmax(z)` 再取对数,而是先减去每个预测位置的最大值再取指数。然后它直接计算 `log_softmax = z - logsumexp(z)`。这让 `from_logits = true` 不只是图个方便。它是训练分类器时一种更可靠的方式。它从不让 `exp` 溢出。它从不对裁剪过的概率取对数。它回递一个良态的融合梯度 `softmax(z) - y`。有得选的时候,优先用这个模式。
没有任何机制会净化 `NaN`。MAE 的符号分支对 `NaN` 残差返回 `f32::NAN`,`NaN` 预测也会穿过其余损失一路传播。一个非有限值会保持非有限,这是设计使然,好让发散大声浮现,而不是藏起来。要在大但有限的梯度失控之前驯服它们,请在[优化器](./3.4._优化器.md)上用按全局范数裁剪,而不是在损失内部去夹。
训练结束后,要给模型打分,就用[分类指标](../Chapter-05/5.2._分类指标.md)和[回归指标](../Chapter-05/5.1._回归指标.md)里专门的评估器。损失的定位是一个平滑的训练信号,而不是给人看的报告。