# 4. 数据预处理
原始数据几乎从不以模型想要的形态出现。特征列的量纲可能天差地别。类别标签可能是字符串,也可能是不连续的整数。而且,在拟合任何模型之前,你还需要一份真正独立的留出集。本章介绍 `rustyml::utils` 里的预处理工具:划分、标准化、归一化和标签编码。它们都藏在 [`utils` feature](../Chapter-01/1.2._安装与Feature配置.md) 之后,直接操作你的模型本来就要吃的 [ndarray](../Chapter-01/1.3._使用ndarray准备数据.md) 数组。
如果你从 scikit-learn 过来,先记住这个模块的形状。它大部分是普通函数,而不是带状态的 `fit`/`transform` 估计器。每次调用只根据传进去的那个数组计算统计量,再返回一个全新的数组。例外是那几个 scaler:`StandardScaler`、`MinMaxScaler`、`MaxAbsScaler`、`RobustScaler`、`Normalizer`。它们确实把 `fit`/`transform` 契约带过了训练/测试的边界。每一个都会存下从训练矩阵学到的东西,好让之后的每一批数据都走同一个映射。不管走哪条路,顺序的纪律都归你。先划分数据,再拟合缩放。这样一来,从测试行推出来的任何东西就都不会漏进训练。每个函数都返回 `Result<_, Error>`,并先校验输入。每个函数都会拒绝空数据集、形状不匹配,或者(在适用的检查项上)非有限值。还没读过[错误处理](../Chapter-01/1.6._错误处理.md)的话,先去读一下。
按流水线顺序读这几节。先划分(4.1)。再缩放训练特征(4.2)。如果模型或损失函数需要 one-hot 目标,最后编码标签(4.3)。下面这段示例跑的正是这条顺序:
```rust
use ndarray::{Array1, array};
use rustyml::utils::train_test_split::train_test_split;
use rustyml::utils::standardize::{StandardizationAxis, standardize};
use rustyml::utils::label_encoding::to_categorical;
fn main() {
// 六个样本,两个量纲差异极大的特征,两个类别。
let x = array![
[1.0, 20.0],
[2.0, 21.0],
[3.0, 19.0],
[4.0, 22.0],
[5.0, 18.0],
[6.0, 23.0],
];
let y: Array1<i32> = array![0, 1, 0, 1, 0, 1];
// 1. 先划分,缩放就永远看不到测试行。
let (x_train, _x_test, y_train, _y_test) =
train_test_split(x, y, Some(0.34), Some(42)).unwrap();
// 2. 把每个特征列标准化到零均值、单位方差。
let x_train = standardize(&x_train, StandardizationAxis::Column).unwrap();
// 3. 为分类损失把整数标签做 one-hot 编码。
let y_train = to_categorical(&y_train, None).unwrap();
println!("x_train shape: {:?}", x_train.shape());
println!("y_train shape: {:?}", y_train.shape());
}
```
## 4.1. 训练集与测试集划分
[训练集与测试集划分](./4.1._训练集与测试集划分.md)把你的特征矩阵和标签切成互不相交的训练子集与测试子集。这样一来,评估数字才能如实反映未见过的数据。`train_test_split` 做的是普通随机划分。`train_test_split_stratified` 对每个类别单独划分,让两侧保持相同的标签比例。只要某个类别稀有,就选分层版本,因为普通划分可能把这个类别整个漏到某一侧。`test_size` 默认取 `0.3`。标签数组对元素类型是泛型的。传入 `random_state` 种子能让洗牌结果可复现(参见[可复现性与随机种子](../Chapter-07/7.1._可复现性与随机种子.md))。
## 4.2. 标准化与归一化
[标准化与归一化](./4.2._标准化与归一化.md)是两种常被混为一谈的不同缩放方式。`standardize` 计算 z-score。它先减去均值,再除以标准差,让每个特征最终变成零均值、单位方差。`normalize` 则按 L1、L2、Max 或自定义 Lp 阶数,把每一行或每一列缩放到单位范数。
这两个操作各自还有一种带状态的形式。带状态的形式会记住自己的训练统计量,好让测试划分或线上的单个样本,按模型训练时所用的数字来缩放。这两个带状态的形式分别是 `StandardScaler` 和 `Normalizer`。与它们同族的还有 `MinMaxScaler`(有界区间)、`MaxAbsScaler`(只看幅值,保住结构性的零)和 `RobustScaler`(中位数与四分位距,让离群点决定不了尺度)。
列标准化是任何基于距离或梯度的模型之前的家常选择。[KNN](../Chapter-02/2.3._K近邻.md)、[SVM](../Chapter-02/2.5._支持向量机.md)、[PCA](../Chapter-02/2.10._主成分分析.md)和神经网络,在特征量纲不一致时统统会失常。按行的单位范数归一化,则适合只看方向的向量,比如用余弦相似度比较的 TF-IDF 行。两个操作都会处理退化的那一路,而不是去除以一个趋近于零的尺度。`standardize` 把除数强制设为 1.0,常量特征居中后变成全 0。`normalize` 把接近零的条带原样保留。
## 4.3. 标签编码
[标签编码](./4.3._标签编码.md)在数据集自带的整数标签和分类损失所需的 one-hot 矩阵之间来回转换。`to_categorical` 把一列非负 `i32` 标签变成 one-hot 矩阵。`to_categorical_with_mapping` 对任意可哈希的标签(字符串也算)做同样的事,还会返回标签到索引的映射,方便你之后解码。`to_sparse_categorical` 做逆向操作。它把 one-hot 或 softmax 概率矩阵按行取 argmax,还原成 `i32` 标签。用 `to_sparse_categorical` 就能把网络的 [softmax](../Chapter-03/3.2._全连接层与激活函数.md) 输出转回预测类别。如果你从 Keras 过来,这三件套对应的就是它的 `to_categorical`。one-hot 目标恰好是[分类交叉熵](../Chapter-03/3.3._损失函数.md)要吃的东西。