5.1. 回归指标
回归指标把一组预测值和一组真值压缩成一个标量,这个标量说明拟合的好坏。RustyML 把这些指标归在 rustyml::metrics 下。类别模块 rustyml::metrics::regression 把它们扁平重导出,所以你可以写成 rustyml::metrics::mean_squared_error 来使用其中任意一个。你也可以通过 prelude 用 use rustyml::prelude::*; 来访问它们。
本页覆盖 crate 提供的 7 个回归函数。它讲解每个函数背后的数学、每个函数对离群点的反应,以及参数顺序如何改变结果。它还展示了在拟合一个 线性回归 模型之后,如何把这些函数放在一起解读。这些函数名和 (y_true, y_pred) 的参数顺序都和 scikit-learn 保持一致。RustyML 在 2 个地方特意偏离 scikit-learn:它用 panic 代替返回 Result(见 5.1.6 节),而且它不提供 adjusted_r2(见 5.1.2 节)。
5.1.1. 7 个函数及其签名
每个回归指标的形状都一样:输入 2 个一维数组,返回一个 f64 值。这些函数对 ndarray 的存储类型是泛型的。y_true 和 y_pred 各自既可以是拥有所有权的数组(Array1<f64>),也可以是视图(ArrayView1<f64>),比如二维数组的一个 .column(j) 切片。两个数组都必须装 f64 值。没有 f32 的重载版本。
rust
// 其中 S1: Data<Elem = f64>, S2: Data<Elem = f64>
pub fn mean_squared_error<S1, S2>(y_true: &ArrayBase<S1, Ix1>, y_pred: &ArrayBase<S2, Ix1>) -> f64;
pub fn root_mean_squared_error<S1, S2>(y_true: &ArrayBase<S1, Ix1>, y_pred: &ArrayBase<S2, Ix1>) -> f64;
pub fn mean_absolute_error<S1, S2>(y_true: &ArrayBase<S1, Ix1>, y_pred: &ArrayBase<S2, Ix1>) -> f64;
pub fn median_absolute_error<S1, S2>(y_true: &ArrayBase<S1, Ix1>, y_pred: &ArrayBase<S2, Ix1>) -> f64;
pub fn mean_absolute_percentage_error<S1, S2>(y_true: &ArrayBase<S1, Ix1>, y_pred: &ArrayBase<S2, Ix1>) -> f64;
pub fn r2_score<S1, S2>(y_true: &ArrayBase<S1, Ix1>, y_pred: &ArrayBase<S2, Ix1>) -> f64;
pub fn explained_variance_score<S1, S2>(y_true: &ArrayBase<S1, Ix1>, y_pred: &ArrayBase<S2, Ix1>) -> f64;
参数顺序是 (y_true, y_pred):真值在前,预测在后。这与 scikit-learn 以及聚类指标的 (labels_true, labels_pred) 顺序一致。对 7 个函数中的 4 个(MSE、RMSE、MAE、MedAE)来说,顺序无关紧要。它们都建立在 abs(y_true - y_pred) 或 (y_true - y_pred)^2 之上,这两种形式都是对称的。MAPE 的分母只用到第一个参数,所以 MAPE 也对顺序敏感(见 5.1.5 节)。
对另外 2 个方差解释类的分数来说,顺序的影响很大。r2_score 和 explained_variance_score 用第一个参数的离散程度做归一化。参数写反不会报错,只会悄悄返回一个不同的、错误的数值。这是搞坏回归评估最常见的方式。5.1.5 节会展示这个错误的代价。
| 函数 | 定义 | 单位 | 最优值 | 典型范围 |
|---|---|---|---|---|
mean_squared_error(MSE) |
(y_true - y_pred)^2 的均值 |
目标值单位的平方 | 0.0 | [0, infinity) |
root_mean_squared_error(RMSE) |
sqrt(MSE) |
目标值单位 | 0.0 | [0, infinity) |
mean_absolute_error(MAE) |
abs(y_true - y_pred) 的均值 |
目标值单位 | 0.0 | [0, infinity) |
median_absolute_error(MedAE) |
abs(y_true - y_pred) 的中位数 |
目标值单位 | 0.0 | [0, infinity) |
mean_absolute_percentage_error(MAPE) |
abs(y_true - y_pred) / max(abs(y_true), eps) 的均值 |
分数(乘以 100 得到百分比) | 0.0 | [0, infinity) |
r2_score(R^2) |
1 - SSE / SST |
无量纲 | 1.0 | (-infinity, 1.0] |
explained_variance_score(EVS) |
1 - Var(resid) / Var(y_true) |
无量纲 | 1.0 | (-infinity, 1.0] |
5.1.2. 每个指标衡量什么,什么时候可以信它
MSE 对平方误差取平均。平方让每个残差都变正,所以参数顺序不影响结果。平方还会让大残差比小残差权重更高。一个大小为 4 的误差贡献 16。四个大小为 1 的误差合计贡献 4。
正是这种二次加权,让多数优化器把 MSE 当作损失函数来最小化。它也让 MSE 对离群点极度敏感,因为一个坏样本就能主宰整个数值。MSE 的单位是目标值单位的平方,所以很难直接解读。类似"误差是 0.02 平方美元"这样的话,没有清晰的含义。
RMSE 是 sqrt(MSE)。开平方把数值带回目标值本身的单位,于是你可以说"典型误差大约是 0.15 美元"。MSE 从不为负,所以平方根总是有定义。RMSE 保留了 MSE 对大误差的二次强调,所以它仍然对离群点敏感。
当大错代价格外高、又想要真实单位下的答案时,就报 RMSE。RMSE 从不小于 MAE,只有当每个误差大小都相同时,两者才相等。RMSE 和 MAE 之间差距大,就说明有几个大残差在把 RMSE 撑高。
MAE 对绝对误差取平均。每个样本按它自身的误差、而不是误差的平方来贡献。这让 MAE 比 RMSE 对离群点更不敏感,并且 MAE 保持在目标值的单位下。当每个单位的误差代价相同、又不想让少数极端点左右评分时,就用 MAE。
MedAE 取绝对误差的中位数,而不是均值。中位数完全不理会尾部的大小。哪怕一半样本错得离谱,中位数也不会移动。这让 MedAE 成为这组指标里受离群点影响最小的一个。
数据带重尾噪声、或已知有坏记录时,就用 MedAE。MedAE 对它忽略的那条尾巴只字不提。当真正在乎的恰恰是那些大误差时,就不要单独报 MedAE。
按对离群点的敏感度从高到低排:MSE 和 RMSE(平方),然后是 MAE(线性),然后是 MedAE(基于排序、受影响最小)。MAPE 不适用这个排序。MAPE 按真值的大小、而不是误差本身的大小来重新加权误差。
MAPE 是各样本相对误差的均值:abs(y_true - y_pred) / max(abs(y_true), eps),其中 eps 取 f64::EPSILON。结果是一个分数。乘以 100 就能把它说成百分比。MAPE 与量纲无关。当目标值横跨好几个数量级时,这个性质很重要,因为同一个绝对误差在不同尺度下意味着不同的事情。
有两个行为值得留意。其一,分母用的是 abs(y_true),所以负的目标值可以正常处理。y_true 的取值为零或接近零时,会被下限截断到 f64::EPSILON,而不是引发除零。这个下限会让那个样本的项炸到大约 10^13,把整个均值拖上去。碰到万亿级的 MAPE,就把它当成某个 y_true 值为零的信号,而不是模型失败的信号。
其二,MAPE 不对称。低估的误差上限是 100%,而高估没有上限。结果就是,MAPE 会不动声色地奖励那些偏向低报的模型。
R^2 即决定系数。公式是 1 - SSE / SST。SSE = sum((y_pred - y_true)^2) 是残差平方和。SST = sum((y_true - mean(y_true))^2) 是目标值围绕自身均值的总方差。R^2 说明模型解释了目标值方差的几成,参照的基线是始终预测均值。取值 1.0 代表完美拟合。
取值 0.0 意味着模型不比预测 mean(y_true) 更好。R^2 没有下界。只要 SSE 大于 SST,也就是模型比恒定均值这条基线还差,R^2 就会变成负数。负的 R^2 是一个真实且有意义的信号。它通常有以下 3 个原因之一:模型设定有误、模型是在一个和训练分布不同的数据上被评估、或者参数被写反了。由于 SST 只来自 y_true,R^2 对参数并不对称(见 5.1.5 节)。
当 y_true 恒定时,这个比值没有定义。RustyML 沿用 scikit-learn 的做法:精确拟合返回 1.0,否则返回 0.0,所以恒定的目标值不会产生 NaN。RustyML 通过把各个值互相比较来判断"恒定",而不是拿 SST 去和某个阈值比。早期版本对未归一化的平方和用过一个绝对的 1e-10 阈值。那个阈值会给一个真正在变化、但整体跨度很小的目标(比如 [1e-6, 2e-6, 3e-6],其 SST 为 2e-12)报出虚假的 1.0。而对 SST == 0.0 做精确判断又会朝另一个方向出错,因为计算均值并不能把每个常数都精确地还原回来。
EVS ,即可解释方差分数,把 R^2 里的残差平方和换成残差的方差。公式是 1 - Var(y_true - y_pred) / Var(y_true)。在平方之前先减去残差均值,意味着恒定的预测偏差不会拉低分数。
假设一个模型总是恰好偏差 +1。它的残差方差为零,所以 EVS 等于 1.0,即便预测系统性地偏了。R^2 则会正确地惩罚同样这个偏差。EVS - R^2 这个差值衡量的是预测有多偏。设想一次无偏的拟合,比如任何带截距、在自己的训练数据上用最小二乘拟合出来的模型。它的残差均值接近 0,所以 EVS 接近 R^2。
RustyML 不提供 adjusted-R^2 函数。普通 R^2 在你加入特征时永远不会下降,所以它没法比较特征数量不同的模型。如果你需要这个调整项,就自己算。公式是 adj = 1 - (1-r2)*(n-1)/(n-p-1),其中 n 是样本数,p 是预测变量数。
5.1.3. 为模型选择挑一个指标
至少同时报一个感知量纲的误差指标,外加一个方差解释类的分数。单个数字盖住的东西太多。大误差代价格外高、又想要目标值单位下的答案时,用 RMSE。误差与代价成线性关系、又不信任那几个极端点时,用 MAE。数据已知有坏记录或带重尾、想要一个尾巴撼动不了的数字时,用 MedAE。只有目标值严格为正且跨尺度可比时才用 MAPE,只要目标值可能为零就不要用它。
用 R^2 来无量纲地说明模型有多好。想专门把恒定偏差剔除时,改用 EVS。做超参数搜索和跨模型比较时,一开始就选定一个指标并固定下来。拿模型 A 的 RMSE 去比模型 B 的 MAE 没有意义。这些指标始终要在留出的划分上计算(见 训练集与测试集划分)。过拟合训练集会轻易把这一页里的每个指标都压到最低。
5.1.4. 一个完整示例
这个示例在 5 个带噪点上拟合一个 线性回归 模型,在同一批输入上预测,然后计算这页里的每个指标。这里 LinearRegression 用的是它默认的闭式求解器。这个求解器精确又瞬时,没有学习率或迭代次数要调,所以示例保持确定且快速。不管预测是怎么产生的,指标的调用方式都不变。
rust
use ndarray::array;
use rustyml::machine_learning::LinearRegression;
use rustyml::metrics::{
explained_variance_score, mean_absolute_error, mean_absolute_percentage_error,
mean_squared_error, median_absolute_error, r2_score, root_mean_squared_error,
};
fn main() {
// 5 个样本,1 个特征。大致是 y = 2x,带一点测量噪声。
let x = array![[1.0], [2.0], [3.0], [4.0], [5.0]];
let y_true = array![2.1, 3.9, 6.2, 7.8, 10.1];
// 闭式普通最小二乘:精确、无超参数、瞬时完成。
let mut model = LinearRegression::new(true);
model.fit(&x, &y_true).unwrap();
let y_pred = model.predict(&x).unwrap();
// 真值在前,预测在后。顺序对 R^2 和 EVS 有影响。
println!("MSE = {:.5}", mean_squared_error(&y_true, &y_pred));
println!("RMSE = {:.5}", root_mean_squared_error(&y_true, &y_pred));
println!("MAE = {:.5}", mean_absolute_error(&y_true, &y_pred));
println!("MedAE = {:.5}", median_absolute_error(&y_true, &y_pred));
println!("MAPE = {:.5}", mean_absolute_percentage_error(&y_true, &y_pred));
println!("R2 = {:.5}", r2_score(&y_true, &y_pred));
println!("EVS = {:.5}", explained_variance_score(&y_true, &y_pred));
// 拟合好的 LinearRegression 也能不调用 predict() 就直接给你 R^2:
println!("score = {:.5}", model.score(&x, &y_true).unwrap());
}
下面是程序打印出的数值,已经四舍五入。闭式求解器让输出是确定性的,所以同样的输入总是打印同样的数字。
text
MSE ~ 0.021 (y 单位的平方)
RMSE ~ 0.146 (y 单位)
MAE ~ 0.136 (y 单位)
MedAE ~ 0.130 (y 单位)
MAPE ~ 0.026 (分数 -> ~2.6%)
R2 ~ 0.997
EVS ~ 0.997
score ~ 0.997
把这些数放在一起读,讲述的是一个连贯的故事。R^2 大约是 0.997,说明拟合出的这条直线解释了 y 里几乎全部的方差。RMSE 大约是 0.15,MAE 大约是 0.14,两者都以 y 的单位表示,而 y 的取值范围是 2 到 10。这 2 个值证实了典型的偏差大约是七分之一个单位,相对于 y 的取值范围来说很小。RMSE 只比 MAE 高一点,说明没有哪个残差在主导误差。MedAE 接近 MAE,这是另一个信号,说明误差大小均匀,而不是尾部沉重。
MAPE 大约是 2.6%,用与量纲无关的方式表达了同样的精度。EVS 在这个精度下与 R^2 相等。带截距的最小二乘拟合产生的残差均值接近 0,所以没有偏差留给 EVS 去宽恕。model.score(&x, &y_true) 复现了 r2_score(&y_true, &y_pred),因为 LinearRegression 内部按同样的定义计算 R^2。在拟合好的模型上,为图方便就用 score。给其他任何东西的预测打分时,比如神经网络、KNN 回归器,或者某个外部模型,就用自由函数 r2_score。
5.1.5. 参数顺序的陷阱
r2_score 和 explained_variance_score 用第一个参数的方差做归一化。不小心调用 r2_score(&y_pred, &y_true) 不会报错。它会算出一个格式良好、但错误的数。下面的示例把同样的 2 个数组按两种顺序各打一次分。它还展示了一个真正糟糕的模型如何让 R^2 变负。
rust
use ndarray::array;
use rustyml::metrics::r2_score;
fn main() {
let a = array![1.0, 2.0, 4.0];
let b = array![2.0, 3.0, 4.0];
// 交换参数会改变 R^2,因为 SST 只来自第一个数组。
println!("r2_score(a, b) = {:.4}", r2_score(&a, &b)); // ~ 0.5714 (= 4/7)
println!("r2_score(b, a) = {:.4}", r2_score(&b, &a)); // 0.0000
// 预测方向与真值相反的模型,比预测均值还差,
// 所以 R^2 会变负。这是真实信号,不是错误状态。
let y_true = array![1.0, 2.0, 3.0];
let y_pred = array![3.0, 2.0, 1.0];
println!("negative R² = {:.4}", r2_score(&y_true, &y_pred)); // -3.0000
}
对称指标(MSE、RMSE、MAE、MedAE,以及 MAPE 的分子)不受这个问题影响,因为它们只看得到 y_true - y_pred。MAPE 是误差类指标里的例外。它的分母用到第一个参数,所以 mean_absolute_percentage_error 同样对顺序敏感。有一个习惯能防住这一切。永远把真值放在前面,并把变量命名为 y_true 和 y_pred,这样一旦写反,在调用处就能一眼看出来。
5.1.6. 输入校验、panic 与 NaN 行为
第 2 章的模型 API 返回 Result<_, Error>(见 错误处理)。metrics 模块的做法不一样。它会在违反前置条件时 panic ,而不是返回错误,这与 ndarray 自身在维度不匹配时的做法一致。
7 个函数都先跑同一套检查。长度必须相等,并且输入不能为空。长度检查先于空值检查,所以即便一侧为空,报出来的也是长度不匹配。panic 的消息对齐 crate 的 Error 措辞:
text
dimension mismatch: expected 3, found 2
input is empty: y_true and y_pred
你没法用 ? 来传播一个 panic。在调用这些函数之前,先在自己的代码里校验长度。如果需要从 panic 里恢复,就用 std::panic::catch_unwind 把调用包起来。实践中,你能掌控传进去的数组长度,比如一个 predict 的输出和它对应的目标。只要长度本就匹配,panic 就永远不会触发。
这些函数处理非有限输入的方式是有意不一致的。当你的数据里可能混进 NaN 或 inf 时,这一点很要紧。r2_score 用的是普通求和。只要任一数组里有一个非有限值,它就会沿着求和传播,结果就是 NaN。这会把脏数据大声暴露出来,而不是藏起来,通常这正是你想要的。
explained_variance_score 走的是相反的路子。它的方差辅助函数会悄悄跳过非有限样本,只在有限的那部分上求平均。几个坏值过后,剩下的样本仍会算出一个看起来正常的分数。这样做很方便,但可能掩盖数据问题。如果丢样本会有影响,就先把输入清洗干净。
误差指标 MSE、RMSE 和 MAE 同样会让 NaN 穿过求和传播。MedAE 用 total_cmp 排序,它会把 NaN 确定性地排好序,而不是 panic。
还有两个边界情形收尾。当 y_true 恒定时,R^2 和 EVS 本会除以零。R^2 只在精确拟合时返回 1.0,否则返回 0.0。EVS 只要残差方差为零就返回 1.0(哪怕存在恒定的偏移),否则返回 0.0。这两条路径都不会产生 NaN。
RustyML 直接从各个值本身读出"恒定",而不是靠方差上的某个容差。这个做法让一个跨度确实很小、但真正在变化的目标,不会被误判成常数、拿到一个虚假的 1.0。
至于 MAPE,y_true 中为零的项不会导致除零。它会把分母下限截断到 f64::EPSILON,于是那个样本的项会炸开,并把均值一起拖上去。碰到大得离谱的 MAPE,就把它当成"y_true 里有个零"的信号,而不是对模型下的判决。