# 4.3. 标签编码
RustyML 里的分类器从不直接接触你的字符串类别。softmax 输出头为每一列给出一个概率。交叉熵损失要么读一行 one-hot,要么读一个整数索引。[第 5 章](../Chapter-05/5.0._模型评估.md)里的每个指标统计的都是整数类别 id。标签编码就是这层转换:把你手头的标签(`"cat"`、`"spam"`、`42`)翻译成训练代码接受的两种数值形态。RustyML 为此提供三个自由函数。它们定义在 `rustyml::utils::label_encoding`。RustyML 还在 `rustyml::utils` 及 [prelude](../Chapter-01/1.5._Prelude与模块导入.md) 中重新导出了它们。
如果你熟悉 scikit-learn,先忘掉那套有状态的 `LabelEncoder` 或 `OneHotEncoder` 模式——先 `fit` 一次,再反复 `transform`。RustyML 没有编码器对象,也没有任何状态需要持久化。每个函数都是对你传入数组的纯变换。这样的设计让 API 保持精简且线程安全。它也把一项责任转交给了你:你必须自己保管标签到索引的映射。你需要这份映射来解码预测结果,并把同一套方案应用到新数据上。本页余下的内容说明该怎么做。
## 4.3.1. API 全貌:三个无状态函数
全部就是这 3 个函数。没有 `LabelEncoder` 结构体,没有 `fit`、`transform`、`inverse_transform` 方法,也没有单独的序数编码器。这套接口刻意保持扁平。
| 函数 | 输入 | 输出 | 用途 |
|---|---|---|---|
| `to_categorical` | `&ArrayBase<S, Ix1>`(其中 `S: Data<Elem = i32>`)、`Option<usize>` | `Result<Array2<f64>, Error>` | 把连续整数标签转换成 one-hot 矩阵 |
| `to_categorical_with_mapping` | `&[T]`(其中 `T: Clone + Eq + Hash`)、`Option<usize>` | `Result<(Array2<f64>, AHashMap<T, usize>), Error>` | 把任意标签(字符串、稀疏整数)转换成 one-hot 矩阵,并给出所用的映射 |
| `to_sparse_categorical` | `&ArrayBase<S, Ix2>`(其中 `S: Data<Elem = f64>`) | `Result<Array1<i32>, Error>` | 通过 argmax 把 one-hot 或概率行转换成整数标签 |
有两个关于类型的事实现在就要说清楚,因为它们后面最容易造成困扰。第一,`to_categorical` 只接受 `i32` 标签。`to_categorical_with_mapping` 接受任何满足 `Clone + Eq + Hash` 的类型,因此它也能接受 `&str`、`String`、`u8` 或枚举。第二,one-hot 矩阵返回的是 `Array2<f64>`,解码出的标签是 `Array1<i32>`。神经网络栈用的是 `Tensor = ArrayD<f32>`,因此还需要 `f32`。这个 `f64` 到 `f32` 的转换是必需步骤,也很容易被忽略。[4.3.5](#435-选择目标格式one-hot-还是稀疏整数) 会讲到它。
## 4.3.2. 整数标签转 one-hot:`to_categorical`
当你的标签已经是连续整数 `0..n_classes` 时,使用 `to_categorical`。它构建一个 `(n_samples, n_classes)` 矩阵。每行只有一个 `1.0`,落在标签所指的那一列。
```rust
use ndarray::array;
use rustyml::utils::to_categorical;
fn main() {
let labels = array![0i32, 1, 2, 1, 0];
// num_classes = None 时,宽度由 max_label + 1 = 3 推断得出。
let onehot = to_categorical(&labels, None).unwrap();
assert_eq!(onehot.shape(), &[5, 3]);
// 固定一个更大的宽度,即使某次划分恰好缺了最后一个类别,
// 训练 / 验证 / 测试也能共用同一套列布局。多出来的列全为零。
let padded = to_categorical(&labels, Some(4)).unwrap();
assert_eq!(padded.shape(), &[5, 4]);
println!("{onehot:?}");
}
```
`num_classes` 参数正是这个函数需要单独一节来讲的原因。传 `None` 会用 `max_label + 1` 推断宽度。这很方便,但在[训练/测试划分](./4.1._训练集与测试集划分.md)上很危险。如果测试折里恰好没有最高类别的样本,`to_categorical(&test_labels, None)` 产出的矩阵就会比训练矩阵少 1 列。下游的每一处形状检查都会因此拒绝它。把 `num_classes` 固定为真实的类别总数,布局就能保持对齐。放宽宽度的代价很低,因为多出来的列不过是零。这个办法能修复验证集目标和输出层之间常见的形状不匹配问题。
有两种输入会被拒绝,而不是被悄悄弄坏。两者都以带类型的[错误](../Chapter-01/1.6._错误处理.md)形式暴露出来:
```rust
use ndarray::array;
use rustyml::error::Error;
use rustyml::utils::to_categorical;
fn main() {
// 负数标签无法索引到任何 one-hot 列。
let bad = array![0i32, -1, 2];
assert!(matches!(
to_categorical(&bad, None),
Err(Error::InvalidInput(_))
));
// num_classes 小于 max_label + 1 会丢掉一个类别。
let labels = array![0i32, 1, 2];
assert!(matches!(
to_categorical(&labels, Some(2)),
Err(Error::InvalidParameter { .. })
));
println!("error paths behave as documented");
}
```
负数标签会产出 `Error::InvalidInput`,因为它没有对应的合法列。`num_classes` 小于 `max_label + 1` 会产出 `Error::InvalidParameter`,因为这会截断一个真实存在的类别。空输入数组不算错误。它返回形状 `(0, 1)`,默认按 1 个类别处理,好让矩阵仍然保持二维。
## 4.3.3. 任意标签转 one-hot:`to_categorical_with_mapping`
真实数据集很少直接以 `0..n` 的形式出现。它们可能是 `"cat"`、`"dog"`、`"bird"`,或者像 `10`、`20`、`30` 这样不连续的整数 id。`to_categorical_with_mapping` 一次就能处理好这些情况。它按**首次出现的顺序**给每个不同的标签分配一个列索引。它据此做 one-hot 编码。然后把矩阵和它构建的那份 `AHashMap<T, usize>` 一并返回。
```rust
use rustyml::utils::to_categorical_with_mapping;
fn main() {
let labels = vec!["cat", "dog", "bird", "dog", "cat"];
let (onehot, mapping) = to_categorical_with_mapping(&labels, None).unwrap();
assert_eq!(onehot.shape(), &[5, 3]);
// 首次出现的顺序决定了列的分配。
assert_eq!(mapping["cat"], 0);
assert_eq!(mapping["dog"], 1);
assert_eq!(mapping["bird"], 2);
println!("{mapping:?}");
}
```
契约是首次出现的顺序,而不是排序后的顺序。`"cat"` 是第 0 列,因为它最先出现,而不是因为它排在最前。这对可复现性很关键。同一个切片总是产出同一份映射,但两个以不同顺序引入类别的数据集,得到的列分配会不同。这正是函数选择返回映射、而非丢弃它的原因。映射是每一列含义的唯一记录。留住它。你需要它来解码预测结果,以后如果还要编码更多数据、想复现出相同的布局,也需要它。见 [4.3.7](#437-未见过的类别与无状态模型)。`num_classes` 参数的行为和 `to_categorical` 里一样。`None` 使用不同标签的数量。`Some(n)` 会把矩阵填充得更宽,如果 `n` 小于不同标签的数量,就会返回错误。
有 1 个边界情形和 `to_categorical` 不同。空切片返回形状 `(0, 0)` 并附带一份空映射,因为零个不同标签推断出的类别数也是零。`to_categorical` 对空数组返回的则是 `(0, 1)`。两种结果都没错。如果你要根据空批次的列数做分支判断,先确认是哪个函数产出的。
## 4.3.4. 解码预测与往返转换:`to_sparse_categorical`
`to_sparse_categorical` 走的是反方向。它接收一个二维矩阵,把每一行归约成其最大值所在的索引。喂给它一个严格的 one-hot 矩阵,它就会还原出原始的整数标签。喂给它一个 softmax 概率矩阵,它就会返回每个样本的预测类别。第二种用法更常见。你就是用它把模型 `predict` 的输出转成类别 id 的。
```rust
use ndarray::array;
use rustyml::utils::{to_categorical, to_sparse_categorical};
fn main() {
let original = array![0i32, 1, 2, 1, 0];
let one_hot = to_categorical(&original, None).unwrap();
let recovered = to_sparse_categorical(&one_hot).unwrap();
assert_eq!(recovered, original);
println!("round-trip ok: {recovered:?}");
}
```
这里有 2 个行为值得了解。平局会取**最靠前**(最小)的索引。当 2 列并列为该行最大值时,函数会选靠前的那一个。这与 NumPy 的 `argmax` 一致,而不是会保留最后一个的 `max_by`。矩阵里任何位置出现非有限值,都会在一开始就触发 `Error::NonFinite`。逐行比较因此是完全的,绝不会把 `NaN` 当成赢家或输家。如果你的概率里出现 `NaN`,那是模型输出的 bug。这个函数会报告它,而不是替你藏起来。
对于字符串的情形,往返还需要多走 1 步,因为 `to_sparse_categorical` 只还原**索引**,它从没见过你的标签。自己把映射反转过来,才能从索引回到标签:
```rust
use rustyml::utils::{to_categorical_with_mapping, to_sparse_categorical};
use std::collections::HashMap;
fn main() {
let labels = vec!["cat", "dog", "bird", "dog", "cat"];
let (one_hot, mapping) = to_categorical_with_mapping(&labels, None).unwrap();
// 解码成类别索引,再反转映射还原出字符串。
let idx = to_sparse_categorical(&one_hot).unwrap();
let inverse: HashMap<usize, &str> = mapping.iter().map(|(&k, &v)| (v, k)).collect();
let recovered: Vec<&str> = idx.iter().map(|&i| inverse[&(i as usize)]).collect();
assert_eq!(recovered, labels);
println!("{recovered:?}");
}
```
先构建一次 `index` 到 `label` 的逆映射,再反复复用它。这是用原始标签汇报预测结果的标准做法。[4.3.5](#435-选择目标格式one-hot-还是稀疏整数) 的结尾正是这么收的。
## 4.3.5. 选择目标格式:one-hot 还是稀疏整数
[神经网络栈](../Chapter-03/3.1._Sequential模型.md)里的多分类提供 2 种损失。你选哪种损失,就决定了喂给 `fit` 的编码形式。这 2 种损失在数值上等价:前向值相同,梯度相同。它们的区别只在于目标怎么存储。损失这一侧的细节见 [3.3. 损失函数](../Chapter-03/3.3._损失函数.md)。
| | `CategoricalCrossEntropy` | `SparseCategoricalCrossEntropy` |
|---|---|---|
| 目标形状 | `[batch, num_classes]` one-hot | `[batch, 1]` 整数类别 id |
| 构建方式 | `to_categorical`(+ 把 `f64` 转成 `f32`) | 把编码 reshape 成一列,再转成 `f32` |
| 目标存储 | `O(batch x classes)` | `O(batch)` |
| `from_logits` 标志 | 有 | 有 |
`CategoricalCrossEntropy` 要的是完整的 one-hot 矩阵。`to_categorical` 返回的是 `f64`,而 `Tensor` 是 `f32`。所以 `.mapv(|v| v as f32)` 这步转换是强制的。漏了它,类型就会和你的 `f32` 特征矩阵对不上:
```rust
use ndarray::array;
use rustyml::neural_network::{
layers::{Activation, Dense},
losses::CategoricalCrossEntropy,
optimizers::Adam,
sequential::Sequential,
};
use rustyml::utils::to_categorical;
fn main() {
let x = array![
[5.1f32, 3.5, 1.4, 0.2],
[4.9, 3.0, 1.4, 0.2],
[6.2, 3.4, 5.4, 2.3],
[5.9, 3.0, 5.1, 1.8],
[6.0, 2.2, 4.0, 1.0],
[5.5, 2.4, 3.8, 1.1],
]
.into_dyn();
let labels = array![0i32, 0, 2, 2, 1, 1];
// 先 one-hot,再把 f64 转成 f32,并变成动态维度的 Tensor 形状。
let y = to_categorical(&labels, None)
.unwrap()
.mapv(|v| v as f32)
.into_dyn();
let mut model = Sequential::new();
model
.add(Dense::new(4, 8, Activation::ReLU).unwrap())
.add(Dense::new(8, 3, Activation::Softmax).unwrap())
.compile(
Adam::new(0.01, 0.9, 0.999, 1e-8, 0.0).unwrap(),
CategoricalCrossEntropy::new(false),
);
model.fit(&x, &y, 5).unwrap();
let preds = model.predict(&x).unwrap();
println!("prediction shape: {:?}", preds.shape()); // [6, 3]
}
```
`SparseCategoricalCrossEntropy` 完全跳过 one-hot 这一步。它直接从 `[batch, 1]` 张量里读类别 id。根本不用构建矩阵。你只需把整数编码 reshape 成一列,再转成 `f32`。类别只有 2 个时,这省不了多少。但当类别多达数千,比如词表或商品目录,one-hot 矩阵就几乎全是零。稀疏形式这时能同时省下内存和分配开销:
```rust
use ndarray::{array, Axis};
use rustyml::neural_network::{
layers::{Activation, Dense},
losses::SparseCategoricalCrossEntropy,
optimizers::Adam,
sequential::Sequential,
};
fn main() {
let x = array![
[5.1f32, 3.5, 1.4, 0.2],
[4.9, 3.0, 1.4, 0.2],
[6.2, 3.4, 5.4, 2.3],
[5.9, 3.0, 5.1, 1.8],
[6.0, 2.2, 4.0, 1.0],
[5.5, 2.4, 3.8, 1.1],
]
.into_dyn();
// 稀疏目标:整数类别 id 组成 [batch, 1] 的 f32 列。无需 one-hot。
let labels = array![0i32, 0, 2, 2, 1, 1];
let y = labels.mapv(|v| v as f32).insert_axis(Axis(1)).into_dyn();
let mut model = Sequential::new();
model
.add(Dense::new(4, 8, Activation::ReLU).unwrap())
.add(Dense::new(8, 3, Activation::Softmax).unwrap())
.compile(
Adam::new(0.01, 0.9, 0.999, 1e-8, 0.0).unwrap(),
SparseCategoricalCrossEntropy::new(false),
);
model.fit(&x, &y, 5).unwrap();
println!("target shape fed to fit: {:?}", y.shape()); // [6, 1]
}
```
稀疏目标需要 `[batch, 1]` 的形状。`insert_axis(Axis(1))` 把长度为 `batch` 的标签向量变成一列。`SparseCategoricalCrossEntropy` 会校验这个形状。它会拒绝裸的 `[batch]` 向量、负数或非有限的标签,以及任何 `>= num_classes` 的标签。每种情况都会给出描述性的错误,而不是抛出越界 panic。非整数标签不会被拒绝。`1.6` 会经 `.round()` 悄悄变成类别 `2`。所以类别 id 要按精确整数来编码,以免出现这种情况。
字符串标签数据集的完整流程把这一切串了起来。用映射编码、训练、预测,再解码回原始标签用于汇报:
```rust
use ndarray::{array, Ix2};
use rustyml::neural_network::{
layers::{Activation, Dense},
losses::CategoricalCrossEntropy,
optimizers::Adam,
sequential::Sequential,
};
use rustyml::utils::{to_categorical_with_mapping, to_sparse_categorical};
use std::collections::HashMap;
fn main() {
let x = array![[0.1f32, 0.2], [0.9, 0.8], [0.15, 0.25], [0.85, 0.95]].into_dyn();
let raw = vec!["cat", "dog", "cat", "dog"];
// 编码目标、保留映射,并据此设定输出层大小。
let (y_f64, mapping) = to_categorical_with_mapping(&raw, None).unwrap();
let y = y_f64.mapv(|v| v as f32).into_dyn();
let n_classes = mapping.len();
let mut model = Sequential::new();
model
.add(Dense::new(2, 8, Activation::ReLU).unwrap())
.add(Dense::new(8, n_classes, Activation::Softmax).unwrap())
.compile(
Adam::new(0.01, 0.9, 0.999, 1e-8, 0.0).unwrap(),
CategoricalCrossEntropy::new(false),
);
model.fit(&x, &y, 5).unwrap();
// 预测 -> f32 概率 -> f64 二维 -> argmax 索引 -> 原始标签。
let probs = model.predict(&x).unwrap();
let probs_2d = probs.mapv(|v| v as f64).into_dimensionality::<Ix2>().unwrap();
let class_ids = to_sparse_categorical(&probs_2d).unwrap();
let inverse: HashMap<usize, String> =
mapping.iter().map(|(k, &v)| (v, k.to_string())).collect();
let predicted: Vec<String> = class_ids
.iter()
.map(|&i| inverse[&(i as usize)].clone())
.collect();
println!("predicted labels: {predicted:?}");
}
```
返回路径上的这 2 次类型转换,正好对应进入时的那次转换。`predict` 产出的是动态 `ArrayD` 形状的 `f32`。`to_sparse_categorical` 要的是二维 `f64` 数组。所以要先转成 `f64`,再把维度固定为 `Ix2`,然后才能解码。用 `mapping.len()` 而不是写死的常量来确定输出层大小,能让网络始终跟编码保持一致。
## 4.3.6. 序数编码:当整数编码撒谎时
整数编码很方便,这份方便也藏着一个真实的陷阱。对于喂给交叉熵损失的**分类目标**,编码只是一个身份标识。损失只查哪一列才是真的。它从不把 `2` 和 `1` 当作数值大小去比较。所以 `SparseCategoricalCrossEntropy` 把类别 `2` 当成一个裸整数是安全的。陷阱在于:把同样的整数编码拿去给一个会对输入做算术运算的模型,编码一个分类**输入特征**。线性回归和[逻辑回归](../Chapter-02/2.2._逻辑回归.md)、SVM,以及每一种基于距离的方法,比如 [KNN](../Chapter-02/2.3._K近邻.md) 或 KMeans,都会把这些编码当成数轴上的数字来读。当 `red=0, green=1, blue=2` 时,模型读到的是 green 恰好在 red 与 blue 正中间,blue 是 green 的两倍。这些关系纯属捏造。线性模型仍然会为它们拟合出一个系数,把这套虚构泛化下去。
修法是对这个分类特征做 one-hot,让任何虚假的次序或间距都无处遁形。每个类别各占一个坐标轴,彼此等距:
```rust
use ndarray::{array, concatenate, Axis};
use rustyml::utils::to_categorical;
fn main() {
let price = array![[10.0f64], [12.0], [11.0]];
let color_code = array![0i32, 2, 1]; // red, blue, green
// 对线性 / 距离模型是错的:这些编码凭空造出了次序和间距。
let color_ordinal = color_code.mapv(|c| c as f64).insert_axis(Axis(1));
let ordinal = concatenate(Axis(1), &[price.view(), color_ordinal.view()]).unwrap();
assert_eq!(ordinal.shape(), &[3, 2]);
// 正确做法:one-hot,让 red、green、blue 彼此等距。
let color_onehot = to_categorical(&color_code, None).unwrap();
let encoded = concatenate(Axis(1), &[price.view(), color_onehot.view()]).unwrap();
assert_eq!(encoded.shape(), &[3, 4]); // price + 3 个颜色列
println!("{encoded:?}");
}
```
裸整数编码作为特征在 2 种情况下没问题。第一种情况是类别本身确实有序,且整数尊重这个次序,比如 `small=0, medium=1, large=2`。这时模型读到的次序是真实的,不过 *间距* 仍然只是一种假设。第二种情况是[决策树或树集成](../Chapter-02/2.4._决策树.md)。这类模型按阈值分裂,而不是把特征乘以权重。所以它们对任意整数编码的容忍度远高于线性模型,代价是需要更深的分裂才能把单个类别隔离出来。对每一种线性模型或基于距离的模型,只要碰到名义型特征,就用 one-hot 编码。多出来的那些列,是不在几何关系上对模型撒谎所付的代价。one-hot 列和 [4.2. 标准化与归一化](./4.2._标准化与归一化.md) 里的缩放器天然搭配。指示列本身就是 `0` 或 `1`,你通常会原样保留它们,只对连续特征做缩放。
## 4.3.7. 未见过的类别与无状态模型
这些函数是无状态的,所以 scikit-learn 里那个关于 `transform` 遇到未见类别的问题,在这里有不一样的答案。这里没有 `fit` 步骤,也没有存下来的编码器。对新数据重新调用一次 `to_categorical_with_mapping`,会根据这批数据的内容构建一份**全新**的映射。在首次出现排序下,这份新映射分配的列可能和你的模型训练时所用的那份不一样。从头重新编码你的推理数据,才是真正的风险所在。它是无声的,因为它产出的矩阵看起来完全合法,含义却已经变了。
正确的模式是:编码一次,留住返回的映射,推理时拿这份存下来的映射去查标签,而不是重新编码。记住 1 个尖锐的坑:用 `mapping[key]` 索引映射时,若键不存在就会 panic。这正是遇到未见过类别的情形。改用 `.get`,让陌生标签变成一个你能处理的值,而不是一次崩溃:
```rust
use rustyml::utils::to_categorical_with_mapping;
fn main() {
let train = vec!["red", "green", "blue"];
let (_matrix, mapping) = to_categorical_with_mapping(&train, None).unwrap();
// 推理时,映射由你自己保管,标签也由你自己去查。
let incoming = ["green", "purple"]; // "purple" 在训练时从未出现过
for label in incoming {
match mapping.get(label) {
Some(&idx) => println!("{label} -> class {idx}"),
None => println!("{label} -> UNSEEN, route to a fallback"),
}
}
// 索引缺失的键会 panic,所以优先用 `.get`:
// let _ = mapping["purple"]; // 会 panic:键不存在
}
```
拿未见过的标签怎么办,是一个建模决策。RustyML 把它交给你来定。你可以丢掉这一行。你可以把它路由到一个预留的「unknown」列,做法是训练时把 `num_classes` 显式设得比观测到的类别数多 1。你也可以直接拒绝这次请求。RustyML 不会替你凭空造出一个「unknown」桶。这才是诚实的做法:模型从没训练过的类别没有学到的表示。悄悄把它塞进某个已有类别,只会掩盖这个事实。
映射就是一个普通的 `AHashMap`。持久化它同样是你的活儿,因为它不属于[保存与加载](../Chapter-03/3.9._权重保存与加载.md)所处理的模型权重。一个重新加载、却没带上标签映射的模型,仍然能吐出列索引。但没有任何东西能把这些索引再变回 `"cat"` 和 `"dog"`。把映射和权重一起序列化,因为 serde 能直接处理 `HashMap`。这样你就有了一条完整、可复现的流水线。更全面的讨论见 [7.2. 深入模型持久化](../Chapter-07/7.2._深入模型持久化.md)。