【RustyML入门】4.2. 标准化与归一化

4.2. 标准化与归一化

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

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

RustyML 还在 rustyml::utils 里提供了带状态的对应物:StandardScalerMinMaxScalerMaxAbsScalerRobustScalerNormalizer。它们都是 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 作用于倒数第二个轴。在一维数组上使用 RowColumn 会失败,因为一维数组只有一个轴,此时返回 Error::InvalidInputGlobal 则适用于任意维度,包括一维。

除数是总体 标准差,即 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) 恰好等于 L1L2;单独保留这两个变体,是为了绕开 powf 调用。除以一个正的范数不会翻转数值的正负号,所以 [-3, 4] 在 L2 下变成 [-0.6, 0.8]

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

按行的这种情形也有对象化的形式 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 类。

模型家族 成员 缩放重要吗? 原因
基于距离 KNNKMeansDBSCANMeanShift带 RBF 的 SVC 关键 欧几里得距离由方差最大的特征主导,以千为单位的特征会淹没以个位数计的特征
基于梯度 线性回归逻辑回归神经网络 重要 不同的特征尺度会把损失曲面拉成一道狭长的山谷,梯度下降在其中来回震荡,需要很小的学习率才能保持稳定
基于方差 PCA核 PCAt-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)?;   // 同一套数字,施加到测试划分上。

fittransform 分工明确: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 共享的 FitTransformFitTransform trait,因此能和任何写成泛型转换器的代码组合起来:

rust 复制代码
use rustyml::traits::{Fit, Transform};

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

家族里的其他成员

StandardScaler 是默认选择,但不是唯一选择。4 个同族成员共享它的全部契约:同样的 fittransformfit_transform 方法,同样的 NotFittedDimensionMismatch 守卫,同样的 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]
}

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

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

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

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

  • 一次性探索。 你只是在看一个矩阵,并没有训练任何东西。
  • RowGlobal 轴,以及 N 维数组。 StandardScaler 是一个二维、按特征的转换器,和 scikit-learn 的完全一样。按行和整体的标准化只存在于自由函数里。
  • 不跨样本耦合的变换。 按行归一化只用每个样本自己的范数来缩放它,别无其他,所以 normalize(&x_train, Row, L2)normalize(&x_test, Row, L2) 天生一致,没有任何跨样本的统计量需要泄漏或冻结。ColumnGlobal 归一化确实会耦合样本,而且没有对应的 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 无法把这样的特征还原回来的原因,每个值回来都是训练集均值。

MinMaxScalerMaxAbsScalerRobustScaler 用 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
normalizeLp(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. 标签编码

相关推荐
起床学FPGA1 小时前
AI回答问题之后,会自动跳到最下面的问题
开发语言·javascript·ecmascript
hsfxuebao1 小时前
OpenSpec 完整使用流程笔记 (SDD)
后端
唐青枫1 小时前
别把 struct 只当成数据盒子:Zig 结构体从入门到实战
后端
why技术2 小时前
AI 写的文章,可能都带着手敲一遍都去不掉的“隐形水印”。
前端·人工智能·后端
罗西的思考2 小时前
【Agent OS / AIOS】AOHP 深度解读:当 OS 开始为 Agent 而设计
人工智能·算法·机器学习
北斗落凡尘2 小时前
LangGraph 入门实战(9)--中断
后端·langchain
CodeSheep2 小时前
又一个华为天才少年,离职了!
前端·后端·程序员
x_x-F2 小时前
网络数据从网卡到用户态:一场基于内存所有权转移的接力赛
开发语言·网络·php
民乐团扒谱机2 小时前
【微实验】组合优化matlab实战(马科维茨投资模型):在收益与风险之间,寻找最优的人生配比
大数据·人工智能·算法·机器学习·数学建模·matlab·组合优化
名字还没想好☜2 小时前
Java 线上内存泄漏排查实战:jmap 导堆、MAT 找 GC Roots 与四类常见泄漏
java·开发语言·jvm·内存泄漏