rustyml 0.15.0

A high-performance machine learning & deep learning library in pure Rust, offering ML algorithms and neural network support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
# 4.2. 标准化与归一化

RustyML 提供了两种让数字变得可比的操作,但两者做的事情完全不同。标准化把每个特征重新居中并缩放,使其均值为 0、方差为 1(也就是 z-score)。归一化则缩放每个样本,让它的向量范数等于 1。RustyML 把两者都实现为无状态的自由函数:`rustyml::utils::standardize` 和 `rustyml::utils::normalize`。

这两个函数每次调用,都会从你递给它的数组里重新计算统计量,然后返回一个新数组。函数在两次调用之间不会保存任何训练均值。熟悉 scikit-learn 的用户,在测试集上用它们之前要特别留意这一点区别。

RustyML 还在 `rustyml::utils` 里提供了带状态的对应物:`StandardScaler`、`MinMaxScaler`、`MaxAbsScaler`、`RobustScaler` 和 `Normalizer`。它们都是 `fit`/`transform` 对象,会把从训练矩阵学到的东西存下来,再施加到之后的每一批数据上。

选自由函数还是 scaler,取决于一个问题:是否还有第二批数据需要用同样的方式缩放?如果有——比如测试划分、验证折,或者推理时到来的单个样本——就该用 scaler。[4.2.5 节](#425-复用训练统计量scaler-家族)详细讲了这件事。

如果还没读过 [4.1. 训练集与测试集划分](./4.1._训练集与测试集划分.md),请先去读它。本页讲的泄漏陷阱,只有相对于一次数据划分才存在。

## 4.2.1. 标准化与归一化是两种不同的操作

这两个函数回答的是不同的问题,几乎不能互相替代。如果你想找的是那种把特征压进 `[0, 1]` 的 min-max 缩放器,那是完全另一样东西:RustyML 把它实现为带状态的 [`MinMaxScaler`](#家族里的其他成员),不在这一节讨论。`normalize` 按**向量范数**(L1、L2、Max 或 Lp)缩放,而不是按特征的取值范围;`standardize` 产出的则是 z-score。

| | `standardize` | `normalize` |
|---|---|---|
| 统一的对象 | 每个特征的**均值与方差** | 每个样本的**长度(范数)** |
| 常用轴 | `Column`(按特征) | `Row`(按样本) |
| 输出保证 | 列均值为 0,总体方差为 1 | 条带范数等于 1 |
| 是否跨样本耦合? | 会。某一列的均值和标准差取决于每一行。 | 取决于轴:按行不会;按列或全局会。 |
| scikit-learn 对应物 | `StandardScaler`,或无状态的 `standardize` | `Normalizer`,或无状态的 `normalize` |
| 退化条带的除数 | `1.0`(常量列变为全 0) | `1.0`(接近零的条带原样保留) |

「是否跨样本耦合」这一行,决定了到底会不会出现泄漏问题。[4.2.5 节](#425-复用训练统计量scaler-家族)会再次讲到这一点。

## 4.2.2. 标准化(z-score)

`standardize` 是一个单独的泛型自由函数,适用于任意维度的 `f64` 数组:

```rust,ignore
pub fn standardize<S, D>(
    data: &ArrayBase<S, D>,
    axis: StandardizationAxis,
) -> Result<Array<f64, D>, Error>
where
    S: Data<Elem = f64>,
    D: Dimension;
```

该函数借用输入,返回一个形状相同、全新拥有所有权的数组,原数组不会被修改。

`StandardizationAxis` 有 3 个变体,它们到物理轴的映射,恰好和「行」「列」两个词乍看给出的直觉相反:

- `Column` 沿 `Axis(0)` 标准化:每个条带就是一列,因此得到的是**按特征**的 z-score。对于样本为行、特征为列的常见布局,这正是你要用的那个。
- `Row` 沿最后一个轴标准化:每个条带就是一行。适合「样本」本身是一段信号、需要归一化其内部尺度的场景,对表格型特征来说很少是你想要的。
- `Global` 把整个数组摊平,作为单一数据集来标准化。

对于维度数 N 大于 2 的数组,`Row` 作用于最后一个轴,`Column` 作用于倒数第二个轴。在一维数组上使用 `Row` 或 `Column` 会失败,因为一维数组只有一个轴,此时返回 `Error::InvalidInput`。`Global` 则适用于任意维度,包括一维。

除数是**总体**标准差,即 `sqrt(variance)`,其中方差除以 `n` 而非 `n - 1`。这与 scikit-learn 的 `StandardScaler`(同样使用 `ddof=0`)一致,所以 RustyML 算出的 z-score 能和迁移过来的 scikit-learn 流水线对得上。

均值和方差来自一趟数值稳定的 Welford 计算;越过内部标定的规模阈值后,这趟计算会并行归并。同一台机器上多次运行,结果保持一致,但 RustyML 不保证跨机器或跨线程数时逐位一致(见 [7.3. 性能调优与并行](../Chapter-07/7.3._性能调优与并行.md))。

```rust
use ndarray::{array, Axis};
use rustyml::utils::standardize::{standardize, StandardizationAxis};

fn main() {
    // 行是样本,列是尺度不同的特征。
    let x = array![
        [1.0, 2000.0],
        [2.0, 3000.0],
        [3.0, 4000.0],
        [4.0, 5000.0],
    ];

    // 把每个特征(列)标准化为均值 0、方差 1。
    let z = standardize(&x, StandardizationAxis::Column).unwrap();
    println!("shape: {:?}", z.shape()); // [4, 2]

    // 现在每一列的总体均值都接近 0,总体方差都接近 1。
    for (j, col) in z.axis_iter(Axis(1)).enumerate() {
        let n = col.len() as f64;
        let mean = col.sum() / n;
        let var = col.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
        println!("feature {j}: mean={mean:.3e} var={var:.3}");
    }
}
```

## 4.2.3. 归一化(单位范数)

`normalize` 接受一个轴和一个范数阶数,把每个条带除以它自己的范数,使该条带的范数变为 1:

```rust,ignore
pub fn normalize<S, D>(
    data: &ArrayBase<S, D>,
    axis: NormalizationAxis,
    order: NormalizationOrder,
) -> Result<Array<f64, D>, Error>
where
    S: Data<Elem = f64>,
    D: Dimension;
```

`NormalizationAxis` 与标准化的那几个变体一一对应:`Row` 是最后一个轴,`Column` 是倒数第二个轴,`Global` 把整个数组摊平。`Row`/`Column` 同样有一维数组的限制。`NormalizationOrder` 决定使用哪种范数:

| 变体 | 范数 | 条带 `[3, 4]` 变为 |
|---|---|---|
| `L1` | 绝对值之和 | `[3/7, 4/7]` |
| `L2` | 欧几里得范数(平方和的平方根) | `[0.6, 0.8]` |
| `Max` | 最大绝对值(无穷范数) | `[0.75, 1.0]` |
| `Lp(p)` | `(sum \|x\|^p)^(1/p)`,自定义 `p` | 取决于 `p` |

`Lp(1.0)` 和 `Lp(2.0)` 恰好等于 `L1` 和 `L2`;单独保留这两个变体,是为了绕开 `powf` 调用。除以一个正的范数不会翻转数值的正负号,所以 `[-3, 4]` 在 L2 下变成 `[-0.6, 0.8]`。

最常见的用法是按样本做 L2 归一化(`Row`、`L2`),它把每个样本投影到单位球面上,于是只有方向才有意义,大小不再重要——这正是余弦相似度或点积模型需要的。

按行的这种情形也有对象化的形式 `Normalizer`,在 [4.2.5 节](#425-复用训练统计量scaler-家族)里讲到;它做的是完全相同的运算,只是包在了估计器的契约里。

```rust
use ndarray::array;
use rustyml::utils::normalize::{normalize, NormalizationAxis, NormalizationOrder};

fn main() {
    let x = array![[3.0, 4.0], [1.0, 2.0]];

    // 把每一行(样本)缩放到 L2 长度为 1:第 0 行变为 [0.6, 0.8]。
    let l2 = normalize(&x, NormalizationAxis::Row, NormalizationOrder::L2).unwrap();
    for row in l2.rows() {
        let norm = row.iter().map(|v| v * v).sum::<f64>().sqrt();
        println!("row L2 norm = {norm:.6}");
    }

    // 同样的数据换成 Max(无穷)范数:每一行最大的绝对值变为 1。
    let mx = normalize(&x, NormalizationAxis::Row, NormalizationOrder::Max).unwrap();
    println!("Max-normalized: {mx:?}");

    // 自定义的 Lp 阶数:p 必须为正且有限,否则调用会返回 Error::InvalidParameter。
    let lp = normalize(&x, NormalizationAxis::Row, NormalizationOrder::Lp(3.0)).unwrap();
    println!("Lp(3)-normalized: {lp:?}");
}
```

## 4.2.4. 哪些模型需要缩放

缩放并不是对所有模型都有用,它重不重要取决于模型怎么度量数据。模型大致分成 3 类。

| 模型家族 | 成员 | 缩放重要吗? | 原因 |
|---|---|---|---|
| 基于距离 | [KNN](../Chapter-02/2.3._K近邻.md)、[KMeans](../Chapter-02/2.7._KMeans聚类.md)、[DBSCAN](../Chapter-02/2.8._DBSCAN.md)、[MeanShift](../Chapter-02/2.9._MeanShift.md)、[带 RBF 的 SVC](../Chapter-02/2.5._支持向量机.md) | 关键 | 欧几里得距离由方差最大的特征主导,以千为单位的特征会淹没以个位数计的特征 |
| 基于梯度 | [线性回归](../Chapter-02/2.1._线性回归.md)、[逻辑回归](../Chapter-02/2.2._逻辑回归.md)、[神经网络](../Chapter-03/3.0._神经网络.md) | 重要 | 不同的特征尺度会把损失曲面拉成一道狭长的山谷,梯度下降在其中来回震荡,需要很小的学习率才能保持稳定 |
| 基于方差 | [PCA](../Chapter-02/2.10._主成分分析.md)、[核 PCA](../Chapter-02/2.11._核主成分分析.md)、[t-SNE](../Chapter-02/2.12._t-SNE.md) | 重要 | 主成分追逐方差最大的方向,一个没缩放的特征可能仅仅因为量纲更大就定义了第一主成分 |
| 基于树 | [决策树](../Chapter-02/2.4._决策树.md)、[孤立森林](../Chapter-02/2.13._孤立森林.md) | 不需要 | 分裂每次只在单个特征上设阈值,任何单调的重新缩放都产出相同的树,所以缩放白费力气 |

基于距离这一行最能说明问题,可以参见 [6.1. 距离度量](../Chapter-06/6.1._距离度量.md)。这些模型把各特征差值的平方加总,所以某个特征的贡献会随它的方差一起放大。把列标准化后,每个特征就以对等的地位进入距离计算。

对基于梯度的家族来说,标准化是让神经网络能以稳定学习率训练的最省事的一招。树模型完全不需要缩放:在拟合 `DecisionTree` 之前做标准化,对结果毫无影响。

`standardize`(按特征)和 `normalize`(按样本)彼此不能替代。基于距离和基于梯度的模型几乎总是需要按列标准化。按行归一化适用的是另一种场景:样本的大小是噪声,只有方向携带信号。文本词频向量就是常见的例子。

## 4.2.5. 复用训练统计量:scaler 家族

自由函数容易让人踩进一个坑。每次调用 `standardize`,都会从你递给它的数组重新计算统计量,这带来两个不同的后果,把它们混为一谈正是出错的地方。

**第一,泄漏陷阱。** 在划分之前对整个数据集调用 `standardize(&x_full, Column)`,算出来的按特征均值和方差里,就掺进了后来会落入测试集的那些行。训练集上的变换因此吸收了测试分布的信息,验证分数也就变得偏乐观。解决办法是调整顺序:先划分,再缩放。

**第二,变换不一致。** 即使先划分了,调用 `standardize(&x_test, Column)` 也会用测试集自己的列统计量去缩放测试集,这是和施加在训练集上的不同的线性映射。模型在按训练集缩放的特征上训练,之后看到的却是按另一套数字缩放的测试特征,这会悄悄拖垮每一个基于距离和基于梯度的模型,且没有任何错误提示。这在真实推理时也行不通:单独到来的一个样本,本身没有什么有意义的按列标准差。

这两个问题都指向同一条纪律:统计量只在训练矩阵上算一次,然后把这套冻结的数字施加到之后的每一批数据上。`StandardScaler` 就是替你保管这些数字的对象,采用和 scikit-learn 一样的 `fit`/`transform` 契约:

```rust,ignore
use rustyml::utils::StandardScaler;

let mut scaler = StandardScaler::new();
scaler.fit(&x_train)?;                       // 只从训练行学习均值和标准差。
let x_train_z = scaler.transform(&x_train)?; // 也可以一次性调用 fit_transform(&x_train)。
let x_test_z = scaler.transform(&x_test)?;   // 同一套数字,施加到测试划分上。
```

`fit` 和 `transform` 分工明确:`fit` 是唯一一个在统计意义上查看数据的方法,`transform` 只是把 `fit` 存下来的东西施加出去。

一旦拟合完成,scaler 就是一个固定的线性映射:递给它一行、一千行,或者再递一次训练矩阵,每个数值走的都是同一个 `(x - mean) / scale`。顺序仍然要你自己把关:先划分再拟合,而且除了训练数据之外,绝不要再次 `fit`。

```rust
use ndarray::{array, Array1};
use rustyml::utils::StandardScaler;
use rustyml::utils::train_test_split::train_test_split;

fn main() {
    let x = array![
        [1.0, 100.0],
        [2.0, 150.0],
        [3.0, 200.0],
        [4.0, 250.0],
        [5.0, 300.0],
        [6.0, 350.0],
    ];
    let y = Array1::from(vec![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. 只在训练矩阵上拟合;fit_transform 会直接返回缩放后的训练数据。
    let mut scaler = StandardScaler::new();
    let x_train_z = scaler.fit_transform(&x_train).unwrap();

    // 3. 测试划分走的是存下来的训练统计量。
    let x_test_z = scaler.transform(&x_test).unwrap();

    // 4. 线上单个样本也是同样的走法(这是自由函数做不到的)。
    let one_sample = scaler.transform(&array![[2.5, 175.0]]).unwrap();

    println!("train mean: {:?}", scaler.get_mean().unwrap());
    println!("train scale: {:?}", scaler.get_scale().unwrap());
    println!("x_train_z shape: {:?}", x_train_z.shape());
    println!("x_test_z shape:  {:?}", x_test_z.shape());
    println!("one sample:      {one_sample:?}");
}
```

### `StandardScaler` 持有什么、提供什么

| 方法 / 访问器 | 作用 | scikit-learn |
|---|---|---|
| `fit(&x)` | 学习并存下按特征的均值与尺度,丢弃之前的拟合结果 | `fit` |
| `transform(&x)` | 施加存下来的 `(x - mean) / scale`,返回一个新数组 | `transform` |
| `fit_transform(&x)` | 一次做完两件事(训练矩阵的入口) | `fit_transform` |
| `inverse_transform(&x)` | 把标准化后的值映射回原始单位 | `inverse_transform` |
| `partial_fit(&x)` | 把另一批数据并进统计量,而不是替换它们 | `partial_fit` |
| `get_mean()` / `get_var()` / `get_scale()` | 学到的统计量,`fit` 之前为 `None` | `mean_` / `var_` / `scale_` |
| `get_n_samples_seen()` / `get_n_features()` | 统计量覆盖了多少行,以及它们的宽度 | `n_samples_seen_` / `n_features_in_` |
| `with_mean(bool)` / `with_std(bool)` | 控制居中与缩放的 builder 开关 | 构造函数参数 |
| `save_to_path` / `load_from_path` | 持久化拟合好的 scaler(postcard 二进制) | `pickle` |

训练集塞不进内存时,`partial_fit` 就派上用场:一次喂一块数据,按特征的矩会(依 Chan 等人的方法)合并成与在拼接结果上做一次 `fit` 完全相同的结果。

`inverse_transform` 则在你需要拿到原始单位下的答案时有用,比如还原一次重建,或者把训练回归器前缩放过的目标值换算回去。

scaler 是训练好的流水线的一部分,不是用完即弃的临时状态:一个存下来却没存 scaler 的模型是不能用的,因为再没有别的东西记录着模型的输入被除以了什么。两个都要存:

```rust,ignore
scaler.save_to_path("scaler.bin")?;
model.save_to_path("model.bin")?;

// 上线服务时
let scaler = StandardScaler::load_from_path("scaler.bin")?;
let model = LinearRegression::load_from_path("model.bin")?;
let prediction = model.predict(&scaler.transform(&incoming)?)?;
```

有两条错误路径值得提前知道。对未拟合的 scaler 调用 `transform`,会返回 `Error::NotFitted("StandardScaler")`;递给它一个列数与拟合时不同的矩阵,会返回 `Error::DimensionMismatch`。在动态类型的流水线里,这种特征顺序的 bug 可能悄悄毁掉预测,在这里它变成了一个带类型的错误。

非有限输入会被拒绝,方式和自由函数完全一样(`Error::NonFinite`),这是与 scikit-learn 刻意不同的地方:它的 scaler 把 `NaN` 当作缺失数据直接跳过,而 RustyML 要求你先插补或丢弃缺失值。

scaler 同样实现了 crate 共享的 [`Fit`、`Transform` 和 `FitTransform`](../Chapter-01/1.5._Prelude与模块导入.md) trait,因此能和任何写成泛型转换器的代码组合起来:

```rust,ignore
use rustyml::traits::{Fit, Transform};

Fit::fit(&mut scaler, &x_train)?;
let z = Transform::transform(&scaler, &x_test)?;
```

### 家族里的其他成员

`StandardScaler` 是默认选择,但不是唯一选择。4 个同族成员共享它的全部契约:同样的 `fit`、`transform`、`fit_transform` 方法,同样的 `NotFitted` 与 `DimensionMismatch` 守卫,同样的 `save_to_path`。它们的区别只在于各自学到的东西:

| Scaler | 对每个特征做的映射 | 学到什么 | 什么时候用它 |
|---|---|---|---|
| `StandardScaler` | `(x - mean) / std` | 均值、方差 | 基于距离和基于梯度的模型的默认选择 |
| `MinMaxScaler` | `(x - min) / (max - min)`,再落到目标区间 | 最小值、最大值 | 某个下游组件需要有界输入 |
| `MaxAbsScaler` | `x / max(\|x\|)` | 最大幅值 | 零必须保住「不存在」这个含义 |
| `RobustScaler` | `(x - median) / IQR` | 中位数、分位数跨度 | 数据里有你不打算剔除的离群点 |
| `Normalizer` | *每个样本*除以它自己的范数 | 除了特征数什么都不学 | 只有样本的方向携带信号 |

**`MinMaxScaler`** 默认把每个特征压到 `[0, 1]`;`with_feature_range(-1.0, 1.0)` 可以重新指定目标区间,即使是在已经拟合过的 scaler 上也可以——极值仍然保留着,只有落点会变。

这里有两个行为要知道。第一,后来的一批数据如果超出训练时的极值,就会落在区间之外,而这正是对的结果:它说明生产数据已经漂出了 scaler 训练时见过的范围。当下游组件确实需要有界输入时,传 `with_clip(true)`;裁剪是有损的,`inverse_transform` 无法撤销它。

第二,`MinMaxScaler` 是这里 3 个按特征 scaler 里对离群点最敏感的一个:训练集里一个极端值会撑大分母,把其余每个样本都挤进区间的一小部分里。常量特征根本没有区间可言,于是除数取 `1.0`,该列平铺在 `feature_range.0` 上。

**`MaxAbsScaler`** 除以 `max(|x|)`,从不平移数据,所以零仍然精确是零,每个符号也都保住。这对计数向量、one-hot 块或 TF-IDF 行很重要,因为在这些场景里零意味着「不存在」。min-max 缩放会平移数据,把这些结构性的零变成一个不再有任何含义的任意非零值。训练值落在 `[-1, 1]` 内;全零的特征除数保持 `1.0`,仍然是零。

**`RobustScaler`** 用中位数和四分位距取代均值与标准差。两者都是顺序统计量,极端值几乎撼动不了它们。相比之下,一个离群点会拖动 `StandardScaler` 的均值、抬高它的标准差;离群点也会撑大 `MinMaxScaler` 的分母,把其余数据都挤进区间的一小部分里。数据里有你不打算剔除的离群点时,就用 `RobustScaler`。

`with_quantile_range(10.0, 90.0)` 会加宽它度量的跨度,覆盖分布的更多部分,也就对尾部更敏感。这个调用会丢弃已拟合的统计量:scaler 之前读到的分位数是在旧位置上的,没有训练数据就没法把它们挪到新位置。

这里有两条限制值得知道。它没有 `partial_fit`,因为分位数不像矩和极值那样能跨批次精确合并。稳健性还需要足够的样本量:只有 4 行时,第 75 百分位会直接插值到那个离群点上,这是分位数规则按设计工作,而不是失灵。它的输出既没有固定区间,也没有单位方差,只有一个可比的「中段」。

**`Normalizer`** 是家族里按行工作的那一个。一个样本的范数只取决于它自己,所以 `fit` 除了特征数之外什么都学不到。它存在的意义,是让按行归一化也能共享家族里其余成员的估计器契约,于是形状不对的批次会产生一个带类型的错误,而不是一个悄无声息的错误答案。

`Normalizer` 接收和自由函数相同的 `NormalizationOrder`。它没有 `inverse_transform`:除以范数会丢弃幅值,没有办法把它找回来。对于 `Column`/`Global` 轴或 N 维数组,请直接调用 `normalize`。

```rust
use ndarray::array;
use rustyml::utils::normalize::NormalizationOrder;
use rustyml::utils::{MaxAbsScaler, MinMaxScaler, Normalizer};

fn main() {
    let x_train = array![[1.0, 0.0, -4.0], [2.0, 3.0, 0.0], [3.0, 0.0, 2.0]];
    let x_test = array![[4.0, 1.0, -1.0]];

    // 按训练集的极值压到 [0, 1];测试行是故意超出它们的。
    let mut min_max = MinMaxScaler::new();
    let train_scaled = min_max.fit_transform(&x_train).unwrap();
    println!("train (min-max): {train_scaled:?}");
    println!("test  (min-max): {:?}", min_max.transform(&x_test).unwrap()); // 第 0 列 > 1.0

    // 裁剪版:同样的映射,被夹进区间内。
    let mut clipped = MinMaxScaler::new().with_clip(true);
    clipped.fit(&x_train).unwrap();
    println!("test  (clipped): {:?}", clipped.transform(&x_test).unwrap());

    // 只看幅值:第 1、2 列里的零仍然精确是零。
    let mut max_abs = MaxAbsScaler::new();
    println!("train (max-abs): {:?}", max_abs.fit_transform(&x_train).unwrap());

    // 按样本看方向:每一行最终的 L2 范数都是 1。
    let mut normalizer = Normalizer::new(NormalizationOrder::L2).unwrap();
    println!("train (rows):    {:?}", normalizer.fit_transform(&x_train).unwrap());
}
```

下面的例子在一列带离群点的数据上,展示了稳健缩放的效果:

```rust
use ndarray::array;
use rustyml::utils::{RobustScaler, StandardScaler};

fn main() {
    // 2 个完全相同的特征,只是第 1 列最后一个值是个离群点。
    let x = array![
        [1.0, 1.0], [2.0, 2.0], [3.0, 3.0], [4.0, 4.0], [5.0, 5.0],
        [6.0, 6.0], [7.0, 7.0], [8.0, 8.0], [9.0, 1000.0],
    ];

    let mut robust = RobustScaler::new();
    robust.fit(&x).unwrap();
    let mut standard = StandardScaler::new();
    standard.fit(&x).unwrap();

    // 稳健:两列拿到的中心和尺度完全相同,离群点谁都没进去。
    println!("robust center: {:?}", robust.get_center().unwrap()); // [5, 5]
    println!("robust scale:  {:?}", robust.get_scale().unwrap());  // [4, 4]

    // 标准:第 1 列的均值和标准差被那个极端值主导。
    println!("mean:  {:?}", standard.get_mean().unwrap());  // [5, ~115]
    println!("scale: {:?}", standard.get_scale().unwrap()); // [~2.6, ~313]
}
```

`MinMaxScaler` 和 `MaxAbsScaler` 也支持 `partial_fit`:它们的极值能精确合并,所以分批喂数据得到的答案和一次性大拟合相同。这里每个按特征的 scaler 都有 `inverse_transform`。

crate 目前还没有提供分位数与幂变换(`QuantileTransformer`、`PowerTransformer`),也没有 `RobustScaler` 的 `unit_variance` 选项——那个选项需要一个 crate 尚未实现的正态分布反函数。遇到这些情况,就自己算出统计量,再按同一套「在训练集上冻结」的纪律施加出去。

### 什么时候自由函数依然是对的选择

`standardize` 依然有它的用武之地:只要没有第二批数据需要保持一致,它就是对的工具。

- **一次性探索。** 你只是在看一个矩阵,并没有训练任何东西。
- **`Row` 或 `Global` 轴,以及 N 维数组。** `StandardScaler` 是一个二维、按特征的转换器,和 scikit-learn 的完全一样。按行和整体的标准化只存在于自由函数里。
- **不跨样本耦合的变换。** 按行归一化只用每个样本自己的范数来缩放它,别无其他,所以 `normalize(&x_train, Row, L2)` 和 `normalize(&x_test, Row, L2)` 天生一致,没有任何跨样本的统计量需要泄漏或冻结。`Column` 和 `Global` 归一化确实会耦合样本,而且没有对应的 scaler 对象;如果需要在一次划分上一致地施加它们,就自己把范数算出来。

对于这个家族没有覆盖的变换——比如分位数或幂变换——同一条纪律照样适用:在训练矩阵上算出统计量,把它们留着,再手工把同一套数字施加到之后的每一批数据上。

## 4.2.6. 边界情况:常量特征、零向量与非有限输入

**常量特征(零方差)。** 取值全部相等的列,方差为 0,直接除以 `sqrt(0)` 会得到 NaN。`standardize` 用一个与量级相关、基于方差的界(机器精度下的 Chan-Golub-LeVeque 误差界)来检测这种情况,并把除数设为 `1.0`。那些本已接近 0 的居中值,随即精确映射为 0.0,不产生 NaN 或 Inf,其他列也不受影响。

这个检测是基于方差的,而不是一次精确的「等于 0」判断。一个真实存在、但极其微小的离散度——比如两个值只差 `1e-8`——仍然会被当作真正的特征,`standardize` 会正常对它做标准化。只有落在浮点噪声范围内的离散度,才会被拉平成常量。

`StandardScaler` 遵循同一条规则:常量列的 `get_scale()` 读回来是 `1.0`。这也是为什么 `inverse_transform` 无法把这样的特征还原回来的原因,每个值回来都是训练集均值。

`MinMaxScaler`、`MaxAbsScaler` 和 `RobustScaler` 用 scikit-learn 给它们的那条更粗的判据来防同一个失败:除数低于 `10 * f64::EPSILON` 就取 `1.0`。这会让常量列在 min-max 下落在 `feature_range.0` 上,在另外两个下落在零上。

**归一化中接近零的条带。** RustyML 把范数低于 `10 * f64::EPSILON` 的条带当作范数为 `1.0` 处理,原样保留。一个零向量(或接近零的向量)没有方向可缩放,除以它只会凭空造出 NaN。全零的行保持全零;对全零数组做 `Global` 归一化,结果同样全零。

**非有限与退化的输入。** 两个函数都会在动手之前先校验输入,并抛出带类型的错误(见 [1.6. 错误处理](../Chapter-01/1.6._错误处理.md)):

| 条件 | 错误变体 |
|---|---|
| 空数组 | `Error::EmptyInput` |
| 输入中出现任何 `NaN`/`Inf` | `Error::NonFinite` |
| 对一维数组使用 `Row`/`Column` 轴 | `Error::InvalidInput` |
| `normalize` 用 `Lp(p)`,且 `p <= 0` 或非有限 | `Error::InvalidParameter` |
| 范数累加溢出为非有限值 | `Error::NonFinite` |

```rust
use ndarray::array;
use rustyml::error::Error;
use rustyml::utils::normalize::{normalize, NormalizationAxis, NormalizationOrder};
use rustyml::utils::standardize::{standardize, StandardizationAxis};

fn main() {
    // 常量列:零方差。除数被强制设为 1.0,因此居中后的值
    // 精确坍缩为 0.0,不产生 NaN 或 Inf。
    let constant = array![[3.0, 1.0], [3.0, 3.0], [3.0, 5.0]];
    let z = standardize(&constant, StandardizationAxis::Column).unwrap();
    println!("constant-column result: {z:?}");
    assert!(z.iter().all(|v| v.is_finite()));

    // 归一化中的零行:范数低于 10*f64::EPSILON 时按 1.0 处理,于是这个
    // 接近零的条带原样保留,而不是去除以一个接近 0 的数。
    let with_zero_row = array![[3.0, 4.0], [0.0, 0.0]];
    let n = normalize(&with_zero_row, NormalizationAxis::Row, NormalizationOrder::L2).unwrap();
    println!("zero-row preserved: {n:?}");

    // 非有限输入会被提前拒绝,任何缩放都不会发生。
    let bad = array![[1.0, f64::NAN], [3.0, 4.0]];
    match standardize(&bad, StandardizationAxis::Column) {
        Err(Error::NonFinite(_)) => println!("rejected non-finite input, as expected"),
        other => panic!("expected NonFinite, got {other:?}"),
    }
}
```

既然非有限输入会被提前拒绝,这两个函数都不可能从干净数据里产出 `NaN`。如果缺失值是用 `NaN` 编码的,就在缩放之前先插补或丢弃它们,这两个函数不会悄悄把它们放过去。

## 4.2.7. 性能与确定性说明

两个函数都会把输入克隆一次,绝不改动原数组,然后在克隆体上原地缩放。

繁重的统计工作在每个条带内部串行执行,包括标准化的 Welford 均值/方差计算,以及归一化的逐条带范数计算。一旦元素数量越过内部标定的阈值,rayon 就会把工作跨条带并行化。这两层并行绝不会嵌套。

全局标准化用的是确定性的分块并行归约:在同一台机器上多次运行,结果保持一致,但跨不同机器或线程数则不保证逐位一致。数据集小到能舒舒服服放进缓存时,走的是串行路径,没有线程开销。这些都不需要手动调节。

拟合好的 `StandardScaler` 跑在同一套机制上,只是省掉了统计这一步:`transform` 对每一行只做一次融合遍历,遍历存下来的向量。在紧凑的循环里做缩放,花费的是这一次遍历,而不是自由函数每次调用都要重做的 Welford 遍历,真正省下来的时间来自这个差别,而不是并行。

`StandardScaler::fit_transform` 在二维数组上与 `standardize(&x, Column)` 逐位一致:两者共用同一趟 Welford 遍历、同一条常量特征规则,所以在它们之间切换绝不会改变任何数字。

阈值模型见 [7.3. 性能调优与并行](../Chapter-07/7.3._性能调优与并行.md),下一步预处理见 [4.3. 标签编码](./4.3._标签编码.md)。