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 节](#4.2.5 节)详细讲了这件事。
如果还没读过 4.1. 训练集与测试集划分,请先去读它。本页讲的泄漏陷阱,只有相对于一次数据划分才存在。
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 节](#4.2.5 节)会再次讲到这一点。
4.2.2. 标准化(z-score)
standardize 是一个单独的泛型自由函数,适用于任意维度的 f64 数组:
rust
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. 性能调优与并行)。
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
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 |
Lp(1.0) 和 Lp(2.0) 恰好等于 L1 和 L2;单独保留这两个变体,是为了绕开 powf 调用。除以一个正的范数不会翻转数值的正负号,所以 [-3, 4] 在 L2 下变成 [-0.6, 0.8]。
最常见的用法是按样本做 L2 归一化(Row、L2),它把每个样本投影到单位球面上,于是只有方向才有意义,大小不再重要------这正是余弦相似度或点积模型需要的。
按行的这种情形也有对象化的形式 Normalizer,在 [4.2.5 节](#4.2.5 节)里讲到;它做的是完全相同的运算,只是包在了估计器的契约里。
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、KMeans、DBSCAN、MeanShift、带 RBF 的 SVC | 关键 | 欧几里得距离由方差最大的特征主导,以千为单位的特征会淹没以个位数计的特征 |
| 基于梯度 | 线性回归、逻辑回归、神经网络 | 重要 | 不同的特征尺度会把损失曲面拉成一道狭长的山谷,梯度下降在其中来回震荡,需要很小的学习率才能保持稳定 |
| 基于方差 | PCA、核 PCA、t-SNE | 重要 | 主成分追逐方差最大的方向,一个没缩放的特征可能仅仅因为量纲更大就定义了第一主成分 |
| 基于树 | 决策树、孤立森林 | 不需要 | 分裂每次只在单个特征上设阈值,任何单调的重新缩放都产出相同的树,所以缩放白费力气 |
基于距离这一行最能说明问题,可以参见 6.1. 距离度量。这些模型把各特征差值的平方加总,所以某个特征的贡献会随它的方差一起放大。把列标准化后,每个特征就以对等的地位进入距离计算。
对基于梯度的家族来说,标准化是让神经网络能以稳定学习率训练的最省事的一招。树模型完全不需要缩放:在拟合 DecisionTree 之前做标准化,对结果毫无影响。
standardize(按特征)和 normalize(按样本)彼此不能替代。基于距离和基于梯度的模型几乎总是需要按列标准化。按行归一化适用的是另一种场景:样本的大小是噪声,只有方向携带信号。文本词频向量就是常见的例子。
4.2.5. 复用训练统计量:scaler 家族
自由函数容易让人踩进一个坑。每次调用 standardize,都会从你递给它的数组重新计算统计量,这带来两个不同的后果,把它们混为一谈正是出错的地方。
第一,泄漏陷阱。 在划分之前对整个数据集调用 standardize(&x_full, Column),算出来的按特征均值和方差里,就掺进了后来会落入测试集的那些行。训练集上的变换因此吸收了测试分布的信息,验证分数也就变得偏乐观。解决办法是调整顺序:先划分,再缩放。
第二,变换不一致。 即使先划分了,调用 standardize(&x_test, Column) 也会用测试集自己的列统计量去缩放测试集,这是和施加在训练集上的不同的线性映射。模型在按训练集缩放的特征上训练,之后看到的却是按另一套数字缩放的测试特征,这会悄悄拖垮每一个基于距离和基于梯度的模型,且没有任何错误提示。这在真实推理时也行不通:单独到来的一个样本,本身没有什么有意义的按列标准差。
这两个问题都指向同一条纪律:统计量只在训练矩阵上算一次,然后把这套冻结的数字施加到之后的每一批数据上。StandardScaler 就是替你保管这些数字的对象,采用和 scikit-learn 一样的 fit/transform 契约:
rust
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
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 trait,因此能和任何写成泛型转换器的代码组合起来:
rust
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. 错误处理):
| 条件 | 错误变体 |
|---|---|
| 空数组 | 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. 性能调优与并行,下一步预处理见 4.3. 标签编码。