Skip to content

第 08 章 · 评价回归的四个数字:MAE / MSE / RMSE / R² ​

这一章要解决什么:把"模型好不好"这件事变成四个数字, 并且知道每个数字的量纲、基准和适用场合。

最容易踩的坑: ① 用 MAE 选模型 —— 两个模型 MAE 几乎一样,RMSE 却能差 3.4 倍; ② 看到负的 R² 以为代码坏了 —— 它是一句诚实的话:"你比用均值预测还差"; ③ 拿两个不同数据集上的 R² 比大小 —— 分母(标签方差)都不一样,没法比。

本章你需要完成的代码:demos/ch08-regression-metrics.py

8.1 四个数字一起算出来 ​

先用第 06 章那个 cycle 单特征模型,把四个指标一次算全:

python
pred = model.predict(df[["cycle"]])
resid = df["soh"] - pred                      # 残差

MAE  = np.abs(resid).mean()
MSE  = (resid ** 2).mean()
RMSE = np.sqrt(MSE)
R2   = 1 - MSE / df["soh"].var(ddof=0)

真实输出:

text
  模型:soh ≈ -0.00014620 × cycle + 0.996891
  指标               值   量纲
  MAE         0.017864   和标签同量纲(soh)→ 平均差 1.79 个百分点
  MSE       0.00049689   soh²(平方过,没有物理含义)
  RMSE        0.022291   soh → 典型误差 2.23 个百分点
  R²          0.834759   无量纲,同 model.score() = 0.834759
  (顺带一个更好懂的:MedAE = 0.016009 → 一半的行误差小于 1.60 个百分点)

★ 四个数字,一张表说清:

指标公式量纲一句话读法
MAEmean(abs(y - pred))和标签同量纲平均差多远
MSEmean((y - pred) ** 2)标签的平方只用于数学推导
RMSEsqrt(MSE)和标签同量纲典型误差(大误差权重更高)
R²1 - MSE / var(y)无量纲比"用均值预测"好多少

★ 第一个必须记住的结论:

MSE 没有可读的量纲,所以永远不要把它报给别人。

MSE = 0.00049689
   ↓
"这是 0.0005 什么?"
   ↓
soh² —— 一个在物理上不存在的单位

MSE 存在的唯一理由是"数学上好用"(可导、可分解)。 对外沟通时,把它开平方变成 RMSE。

★ 第二个必须记住的结论:

MAE 和 RMSE 都是"误差";只有 R² 是"相对提升"。

MAE  = 1.79 个百分点      ← 绝对的:平均差这么多
RMSE = 2.23 个百分点      ← 绝对的:把大误差也算进来
R²   = 0.8348            ← 相对的:比"一律报平均"少犯错 83.5%

这两类数字回答的是不同的问题:

"模型的误差有多大?"        → MAE / RMSE
"模型比什么都不做强多少?"  → R²
点开:既然 MSE 读不懂,为什么它还活着?

三个理由,都是"数学性质"层面的。

① 平方误差可以分解(这是 R² 能存在的前提)

text
总平方和  =  回归解释的平方和  +  残差平方和
   ↓
Σ(yᵢ - ȳ)²  =  Σ(ŷᵢ - ȳ)²  +  Σ(yᵢ - ŷᵢ)²

翻译成人话:

标签的总波动 = 模型解释掉的波动 + 模型没解释掉的波动
   ↓
所以 R² = "解释掉的 / 总波动"
   ↓
绝对值误差(MAE)没有这个分解

② 平方误差的最小值有解析解

MSE 对参数求导 = 0  →  解一个线性方程组  →  直接得到系数
MAE 对参数求导     →  绝对值在 0 点不可导,没有闭式解,要迭代

这就是为什么 LinearRegression 一行代码就能出结果。

③ 平方误差直接对应"高斯噪声下的最大似然"

假设残差服从正态分布
   ↓
"最大似然" 等价于 "最小二乘"
   ↓
于是 MSE 有一个明确的统计学解释:
   它是对噪声方差 σ² 的估计
   ↓
RMSE 就是对 σ 的估计 —— "典型误差"这个说法由此而来

★ 所以正确的态度是:

内部计算 / 推导 / 比较   →  用 MSE(性质好)
对外汇报 / 选型 / 定阈值  →  用 RMSE 和 MAE(量纲对)

★ 一个额外的指标:MedAE(中位绝对误差)

python
MedAE = np.median(np.abs(resid))       # 0.016009

它的好处是"不受离群点影响":

MAE  = 0.017864    ← 被大误差拉高
MedAE = 0.016009   ← 一半的行误差比它小
   ↓
如果 MAE 远大于 MedAE → 说明有一批行的误差特别大
   ↓
这是一个免费的"离群点报警"

⭐ 本书后面报误差时会带一个 MedAE,原因就是这个。

8.2 量纲:把数字翻译成人话 ​

这一节的纪律从第 03 章延续到现在,而这一章是它最密集的用武之地。

翻译表:

原始数字翻译结果
MAE = 0.017864× 100平均差 1.79 个百分点
RMSE = 0.022291× 100典型误差 2.23 个百分点
MedAE = 0.016009× 100一半的行误差小于 1.60 个百分点
R² = 0.834759× 100%比一律报平均值少犯错 83.5%(按平方误差)

★ 为什么 soh 的误差要乘 100

text
soh = 0.9088      ← 一个 0~1 的比例,读者无感
soh 差 0.0179     ← 也还是无感
soh 差 1.79 个百分点 ← 有感觉了:相当于从 90% 掉到 87.2%

★ 一个关键的"尺度感"练习:

退役线是 0.80
MAE 是 1.79 个百分点
   ↓
模型的判断在 0.80 附近会晃动 ±1.79 个百分点
   ↓
0.80 ± 0.018 = [0.782, 0.818]
   ↓
如果你用"预测值 < 0.80"当告警条件
   ↓
真实值可能是 0.818(还没到)或 0.782(早就到了)

这段推理在真实项目里非常有用:

误差的量纲必须和"决策阈值"放在一起看, 才能回答"这个误差能不能支撑这个决策"。

一个 MAE = 1.79 个百分点的模型,不适合做"±1 个百分点"级别的判断。

点开:一个"够不够准"的对照表(怎么开口回答"准不准")

"这个模型准不准?"是一个没有答案的问题。有答案的问法是:"用来做什么判断?需要多准?"

把误差和决策放在一起看:

决策容忍的误差MAE = 1.79 个百分点够吗
粗排维护批次(按月)±5 个百分点✅ 远远够
判断"要不要换"(阈值 0.80)±2 个百分点⚠️ 勉强,会误报
判断"还能撑几圈"±50 圈❌ 不够(推回去误差会放大)
做容量交易结算±0.5 个百分点❌ 差得远

★ 最后一行"误差会放大"值得展开:

soh 的 MAE = 0.0179
   ↓
反解成 cycle:除以 |coef_| = 0.00014620
   ↓
0.0179 / 0.00014620 ≈ 122 圈
   ↓
也就是说:"退役点 1347 圈" 这个数字,
   对应的 soh 误差会放大成 ±122 圈的圈数误差

这是一个非常实用的换算:

python
mae = float(np.abs(resid).mean())
k = abs(float(model.coef_[0]))
print(f"soh 的 MAE {mae:.4f} ≈ 反解成 cycle 的 ±{mae / k:.0f} 圈")
# soh 的 MAE 0.0179 ≈ 反解成 cycle 的 ±122 圈

★ 反解会把误差放大,这是"反解"的普遍性质:

直线很平(|k| 小) → 一点点 soh 误差 = 很多圈
直线很陡(|k| 大) → 一点点 soh 误差 = 很少圈
   ↓
本书的直线很平(每 100 圈才掉 1.46 个百分点)
   ↓
所以"退役点"的圈数误差天然很大

⚠️ 由此得到一个重要判断:

"预测退役圈数"比"预测 soh"难得多。 一个 soh 误差看起来很小的模型,反解成圈数可能完全不能用。

第 07 章那个 1347 圈,真实的不确定度是 ±122 圈起步 (还没算模型本身的不确定度,那要第 24~25 章)。

8.3 RMSE ≥ MAE 恒成立,比值能读出"误差的形状" ​

真实输出:

text
  实测:RMSE 0.022291 ≥ MAE 0.017864 -> True
  比值 RMSE / MAE = 1.2478
  参考值:如果误差是正态的,这个比值会趋近 √(π/2) = 1.2533
  实测 1.2478 和 1.2533 很接近 → 误差大致是「正态的随机波动」,
    没有少数几个错得离谱的行在捣乱。

★ 这个不等式是数学上恒成立的,不是经验规律:

text
RMSE ≥ MAE        (恒成立,等号只在所有误差一样大时取到)

证明只要一行(第 8.3 节末的折叠块里有完整推导), 核心是"方差非负":

平方的均值 ≥ 均值的平方        (等号只在所有数相等时成立)
   ↓
E[r²] ≥ (E[|r|])²
   ↓
RMSE ≥ MAE

★ 比值告诉你什么:

RMSE / MAE误差的形状含义
= 1.000每个点的误差完全相同最"整齐"的误差(现实中几乎不存在)
≈ 1.25正态分布√(π/2) = 1.2533,最常见的形态
> 1.5少数点误差特别大有离群点 / 有几段数据模型完全不会
> 3少数点错得离谱必须先查那些行,再谈模型好不好

实测数据给出的是 1.2478 —— 非常接近正态参考值。

★ 三种"形状"的实测对照:

真实输出:

text
  误差的形状                                          MAE      RMSE  RMSE/MAE
  每个误差都等于 0.01975(最整齐)                      0.019750  0.019750    1.0000
  正态噪声 σ=0.03                               0.023895  0.029865    1.2498
  95% 的行误差 0.005 + 5% 的行误差 0.3              0.019723  0.067197    3.4071
  ★ 三种形状的 MAE 几乎一样,RMSE 却能差 3 倍以上。
    比值越接近 1 → 错误越「均匀」;比值越大 → 有少数行错得特别离谱。

★ 注意第一行和第三行的 MAE:

全等误差:  MAE = 0.019750
带 5% 大错:MAE = 0.019723        ← 几乎一模一样!
   ↓
但 RMSE 是 0.019750 vs 0.067197   ← 差 3.4 倍

这就是下一节的主题。

点开:RMSE ≥ MAE 的证明(以及一个常见的错误证明)

设残差是 r₁ … rₙ,记 MAE 为"绝对值的平均",RMSE 为"平方的平均再开根号"。

★ 正确的证明只要两步

text
第一步:由「方差非负」
    Var(|r|) = E[|r|²] - (E|r|)²  ≥ 0

    其中 E[|r|²] = E[r²]        (因为 |r|² 就是 r²)
         E|r|     = MAE
         √E[r²]   = RMSE

    代入:  RMSE² - MAE²  ≥ 0
      →     RMSE² ≥ MAE²

第二步:两边开根号(两个量都非负,开根号不改变不等号方向)
        RMSE ≥ MAE                              ∎

等号成立的条件:Var(|r|) = 0,也就是所有 |rᵢ| 完全相等。

★ 一个常见的错误证明(别写成这个)

text
❌ 错误写法:
   RMSE ≥ |r̄|  (由"均值的平方 ≤ 平方的均值")
   MAE  ≥ |r̄|  (由绝对值不等式)
   → 所以 RMSE ≥ MAE        ← 推不出来!

为什么推不出来:

text
两个量都 ≥ 同一个数 |r̄|,它们之间谁大谁小是【不确定的】

举例:  RMSE = 5,MAE = 3,|r̄| = 1
        5 ≥ 1 ✅   3 ≥ 1 ✅   5 ≥ 3 ✅   —— 这个例子里恰好成立
        但如果 |r̄| = 3.5:
        5 ≥ 3.5 ✅  3 ≥ 3.5 ❌  —— 反例
        (实际上残差均值为 3.5 而 MAE = 3 是不可能的,因为 MAE ≥ |r̄|)

⚠️ 关键点:这个"传递"推理看起来非常自然, 但它不是有效的推理步骤(中间量相同,不能传递)。

它之所以"看起来对",是因为结论恰好是对的 —— 这正是本书反复强调的那类错误:结论对,推理错。 如果把结论换成别的,这种推理会静默地给出错误答案。

★ 一句可以记住的话:

RMSE 和 MAE 的差距,恰好度量了"误差的离散程度"。两者相等 ⇒ 所有误差一样大(误差只是"偏",不是"乱")。

★ 最后给一个实用推论

text
RMSE / MAE 在 1.25 附近:
   误差是"同一种随机性"
   → 想提升模型,要找的是【系统性】的原因(缺特征、模型容量不够)

RMSE / MAE 明显大于 1.5:
   有一批点错得特别离谱
   → 先【把那批点找出来看看】
     很可能它们不是"模型的误差",而是"数据的问题"(脏数据、特殊工况)

★ 这就是第 08 章之后、第 21 章(学习曲线)、第 22 章(正则化) 要去的地方:把"误差的形状"翻译成"下一步该做什么"。

8.4 ★ 对照:MAE 说"一样好",RMSE 说"差 3.4 倍" ​

这一节是这一章的"看起来对但其实错"。

造两个"MAE 几乎完全一样"的模型:

python
# 模型 A:每个样本都错 0.01975(平均、均匀)
a_res = np.full(len(y), 0.01975)

# 模型 B:95% 的样本只错 0.005,5% 的样本错 0.3
b_res = np.where(rng.random(len(y)) < 0.05, 0.3, 0.005)

真实输出:

text
  模型 A(误差永远 0.01975):MAE = 0.019750   RMSE = 0.019750
  模型 B(95% 很小,5% 错 0.3):MAE = 0.019862   RMSE = 0.067512
  按 MAE 选:A 和 B 差 0.000112(0.57%)→ 看起来一样好
  按 RMSE 选:B 的 RMSE 是 A 的 3.42 倍 → B 差得多
  ★ 差异来自那 5% 的行:它们的误差是 0.3(soh 上是 30 个百分点),
    而 MAE 把这 5% 的行和另外 95% 平均在一起,稀掉了。

★ 问题的根源是"平均":

模型 B 的 MAE = 0.95 × 0.005 + 0.05 × 0.3 = 0.00475 + 0.015 = 0.01975
                                                    ↑
                                       5% 的灾难被平均成了 0.015
   ↓
它和"每个都错 0.01975"在 MAE 上【完全无法区分】
   ↓
但实际后果天差地别:
   A:每个预测都偏 2 个百分点   (稳定、可预期)
   B:95% 的预测几乎完美,5% 的预测偏 30 个百分点  (灾难)

★ 所以选型时必须看 RMSE(或 MSE):

选型用 RMSE / MSE(它惩罚大错), 汇报用 MAE(它好懂), 两个都看。

★ 把本节这两个对照组翻译成业务语言(数字都是上面实测的)

                     MAE              RMSE
模型 A(误差均匀)    1.98 个百分点     1.98 个百分点
模型 B(5% 大错)     1.99 个百分点     6.75 个百分点
   ↓
按 MAE 选:1.98 vs 1.99 —— 几乎一样,随便选一个
按 RMSE 选:1.98 vs 6.75 —— B 差 3.42 倍
   ↓
如果 B 是"95% 的时候更准、5% 的时候完全错"的那个模型,
   那么"选 A"是对的,但你必须【先看到 RMSE 才做得对这个判断】

★ 真实项目里这一步还要多走一步:把那 5% 的行找出来, 看它们是不是集中在某个工况、某支设备、某一段时间。

误差大不是因为"模型差",而是因为"数据里有它不该负责的东西"
   ↓
这时候要改的是【数据口径】,不是模型

★ 这一段里有一个"看起来对但其实错"的推理:

"MAE 更小 → 模型更好"
   ↓
这在"两个模型的误差分布形状相似"时成立
   ↓
一旦形状不同(一个均匀、一个有尾巴),MAE 就可能骗你

判据(一句话):

比较两个模型之前,先看它们的误差分布形状是不是同一个。 形状不同,就至少要比 MAE + RMSE 两个指标。

8.5 ★ 负 R² 意味着什么 ​

这是本章最重要的一节,因为它最容易被误解成"代码坏了"。

先亲手造一个:

python
train = df[df["cycle"] <= 400]        # 只用早期数据训练
test  = df[df["cycle"] >= 1200]       # 在晚期数据上评估
model = LinearRegression().fit(train[["cycle"]], train["soh"])
print(model.score(test[["cycle"]], test["soh"]))

真实输出:

text
  训练集:cycle ≤ 400 的 7,020 行(soh 均值 0.9679)
  测试集:cycle ≥ 1200 的 1,136 行(soh 均值 0.8226)
  这个模型:【从没在 cycle > 400 的数据上训练过】
  训练集 R² = 0.571143
  测试集 R² = -3.525302   ← 负数!
  测试集 MAE = 0.019401(1.94 个百分点)   RMSE = 0.021139   RMSE/MAE = 1.0896

R² = −3.53 是什么意思? 回到定义:

text
             模型的 MSE
R² = 1 -  ──────────────────
           用均值预测的 MSE

真实输出:

text
  负 R² 到底在说什么?把基准也算一遍:
    基准(永远答测试集的均值 0.8226):MSE = 标签方差 = 0.00009884
    模型:MSE = 0.00044688
    比值 = 4.5213 倍 → R² = 1 - 4.5213 = -3.525302
  ★ 也就是说:这个模型比「闭着眼睛答平均值」还差 4.52 倍。

★ 逐项对照 R² = 0 和 R² < 0:

R²模型 vs 均值基准人话
1.0误差为 0完美(在真实数据里基本意味着泄漏)
0.83误差只有基准的 17%比均值好很多
0和均值一样差模型等于白做(学到的还不如直接答均值)
−3.53误差是基准的 4.52 倍比"什么都不做"还差 4.5 倍

★ 一个必须记住的对照:

R² = 0       ┐
             ├─  两者都是"模型没带来价值"
R² = -3.53   ┘   区别在"错得多离谱"

★ 为什么这个模型会这么差?

真实输出:

text
  ★ 但它并不「乱」:RMSE/MAE = 1.0896 接近 1,说明误差是【整体偏了】
     (预测均值 0.8032 vs 真实均值 0.8226,系统性低了 0.0193)

拆开看:

text
① 训练数据只到 400 圈,那一段的 soh 还在 0.94~1.00
② 模型学到的斜率是"早期那段"的斜率
③ 把它延长到 1200 圈 → 预测值系统性偏低 0.0193
④ RMSE/MAE = 1.0896 ≈ 1 → 误差几乎全是这个"系统性偏移"
   (没有几个点特别离谱,是整体都偏)

★ 所以负 R² 的正确解读是:

不是"模型算错了",是"这个模型被问了一个它没资格回答的问题"。它从没在 cycle > 400 的数据上见过任何东西。

★ 负 R² 在你自己的项目里会以哪些形态出现

场景为什么会出现负 R²
外推(本节)训练范围外的规律和范围内不同
分布漂移上线后数据分布变了,模型的截距失效
划分不当训练/测试的标签范围差异很大(第 09 章)
用错了量纲训练时用米、推理时用毫米
候选模型太弱用一个常数模型去拟合有结构的标签

★ 一条纪律:

看到 R² < 0:
   ① 不要改代码,先看【训练集和测试集的标签分布】(均值和方差)
   ② 再看【预测均值 vs 真实均值】(差多少 → 系统性偏移)
   ③ 最后看 RMSE/MAE(接近 1 说明是"偏",远大于 1 说明是"乱")

这三步做完,负 R² 通常会变成一个非常具体的工程问题。

点开:"比均值还差"为什么在测试集上这么常见?(以及为什么它是好事)

先回答一个反问:

训练集上的 R² 几乎不可能是负的
   ↓
为什么?
   ↓
因为最小二乘本来就【包含】"截距 = 均值"这个解
   ↓
模型在训练集上一定不差于"一律答均值"(R² ≥ 0)

★ 这是线性回归的一个保证:

LinearRegression 一定能找到不比均值差的解(在训练集上)
   ↓
所以训练集 R² < 0 意味着:代码或数据出了问题
   ↓
而测试集 R² < 0 是【完全正常】的:
   模型在训练集上拟合,在新数据上可能比均值差

★ 于是有了一个非常好用的诊断:

训练 R²测试 R²诊断
0.950.94✅ 正常("形态"示意)
0.990.83⚠️ 过拟合(第 18 章,"形态"示意)
0.571143−3.525302⚠️ 训练/测试分布不同(本节实测)
任意负数任意❌ 代码有问题(带截距的最小二乘在训练集上不可能为负)
1.000000—❌ 先查泄漏(第 04 章)

⚠️ 这张表看的是"训练和测试的差距形态",不是具体数值。只有第 3 行是本章的真实实验结果(训练 0.571143 / 测试 −3.525302)。

★ 这张表里第二行和第三行的区别很重要:

过拟合:训练 0.99 / 测试 0.83
   → 测试还是正数,模型有学到东西,只是学多了

分布不同:训练 0.57 / 测试 -3.53
   → 测试是负数,说明"新数据上的最优策略"和"训练学到的策略"不同

★ 为什么"测试 R² 可以为负"是一件好事

因为它让"模型没学到东西"这件事变得可见:

如果 R² 被定义成"永远非负"(比如被 clip 到 0)
   ↓
"比均值还差"的模型会显示 R² = 0
   ↓
你会以为"它和均值一样" —— 但它其实差 4.5 倍
   ↓
错误被隐藏了

★ 一个相关的、更常见的陷阱

"我的模型 R² = 0.42,好像不太行"
   ↓
但如果"标签的方差很小"(机器测量的精度极限)
   ↓
R² = 0.42 可能已经是这个问题的上限
   ↓
所以 R² 的绝对值没有意义,必须和"能达到的上限"比

这就是第 08 章最后一个主题(也是第 21 章学习曲线的前置):

R² 必须有个参照系。没参照系的 R² 只是一个数字。

8.6 ★ R² 不能跨数据集比较 ​

现在做第二个对照实验:同一个模型,在三份"标签方差不同"的数据上评估。

真实输出:

text
  同一个模型(用全表训练的 cycle 单特征直线),分别在三份数据上评估:
  数据集                                 行数         标签方差       RMSE        MAE         R²
  全表                              21,239   0.00300722   0.022291   0.017864   0.834759
  soh >= 0.95 的行(标签很挤)             6,202   0.00018669   0.015405   0.012620  -0.271448
  soh <= 0.85 的行(标签很挤)             4,109   0.00014000   0.030851   0.024517  -5.800022
  ★ 看第二行:RMSE 只有全表的 0.69 倍,R² 却是 -0.271448(负的)。

★ 这张表里有三个"反直觉"的结果:

反直觉一:RMSE 更小,R² 反而更差

全表:    RMSE = 0.022291   R² = 0.834759
soh≥0.95:RMSE = 0.015405   R² = -0.271448
              ↑ 小 31%          ↑ 负的!

原因:

R² 的分母是【这份数据自己的标签方差】
   ↓
soh ≥ 0.95 的那 6,202 行,标签方差只有 0.00018669
   ↓
是全表的 6.21%(0.0621 倍)
   ↓
在这一小段里,"一律答均值"本来就极其准
   ↓
所以想赢过这个基准很难 —— R² 天然难看

反直觉二:soh ≤ 0.85 那一组的 RMSE 更大,但 R² 更差得离谱

R² = -5.800022  →  模型的 MSE 是基准的 6.8 倍

原因:那一组标签方差只有 0.00014,而模型在整个区间上是线性外推, 系统误差远超这一小段内部的波动。

反直觉三:三个 R² 之间的差距,和 RMSE 的差距完全不同向

RMSE 排序:soh≥0.95(0.0154) < 全表(0.0223) < soh≤0.85(0.0309)
R² 排序:  全表(0.8348)    > soh≥0.95(-0.2714) > soh≤0.85(-5.80)
              ↑ 完全不同!

★ 结论(这一章第二个必须背下来的句子):

R² 的分母是"这份数据自己的标签方差"。 所以同一个模型在不同数据集上的 R² 不可比。

★ 一个极端的例子,把它推到荒谬:

假设有一份数据,标签全是 0.908760(方差 = 0)
   ↓
R² = 1 - MSE / 0    →  分母是 0,R² 无定义
   ↓
如果有人"聪明的"代码写了 1 - MSE/1e-12
   ↓
他会得到一个 -10^9 的 R²

★ 什么时候可以比 R²:

场景能比吗为什么
同一份测试集上的两个模型✅ 可以分母相同,比的就是分子
两份不同的测试集❌ 不可以分母不同
同一份数据的不同时间片⚠️ 谨慎先比较标签方差,差异大就不能比
和别的论文/别的数据集比❌ 不可以分母不同,任务难度也不同

★ 换数据集时必须同时报三个数:

R² + RMSE + 标签方差
   ↑      ↑        ↑
  相对   绝对     说明"这个 R² 的分母是多少"

★ 这条纪律在本书里的第一次实战是第 23 章(模型横评):

四个模型必须在【同一个测试集】上比
   ↓
否则 R² 的差异可能只是"分母不同"

8.7 怎么向业务方解释这四个数字 ​

真实输出:

text
  ❌ 「R² = 0.8348」        → 业务方不知道这是什么,也不知道该不该信
  ❌ 「MSE = 0.00049689」      → 没有量纲,谁都读不懂
  ✅ 「MAE = 1.79 个百分点」→ 平均每个预测差 1.79 个百分点
  ✅ 「RMSE = 2.23 个百分点」→ 大误差也考虑进去之后是 2.23 个百分点
  ✅ 「R² = 0.8348」→ 比「一律报平均值」少犯错 83.5%(按平方误差算)

★ 一个可以直接用的三句话模板:

text
① 平均误差:平均每次预测差 {MAE×100:.2f} 个百分点
② 波动幅度:如果碰上不太好的情况,误差会到 {RMSE×100:.2f} 个百分点
③ 相对提升:比"不管哪支设备都报 {y.mean():.3f}"这个基线少犯错 {R²×100:.1f}%

用真实数字填进去:

text
① 平均每次预测差 1.79 个百分点
② 不太好的情况误差到 2.23 个百分点
③ 比"不管哪支设备都报 0.909"少犯错 83.5%

★ 为什么第 ③ 句一定要写出基线值

❌ "比基线好 83.5%"        →  哪个基线?
✅ "比『一律报 0.909』好 83.5%"  →  基线具体、可讨论

这条纪律和"给数字加限定词"是同一件事:

任何相对数字,都要说清"相对谁"。

★ 一个真实对话的形态(这家公司会怎么追问)

你:"平均误差 1.79 个百分点。"
业务方:"那能用来判断该不该换吗?"
你:"退役线是 0.80。1.79 个百分点的误差,
     意味着我给出 0.80 的时候,真实值可能在 0.78~0.82 之间。
     如果你们要求'预测低于 0.80 就换'这个规则不出错,
     那这个精度还不够。"
业务方:"那要多少?"
你:"如果把误报控制在 1 个百分点内,需要 MAE ≤ 1 个百分点。
     现在 1.79,还差一倍。"

★ 这场对话里的关键动作:

① 把误差换算到【决策阈值】的尺度上(0.80 ± 0.018)
② 明确指出"够不够"取决于规则(不是取决于你)
③ 给出"要多少才够"的具体数字(MAE ≤ 1 个百分点)

★ 一句反过来的忠告:

不要主动说"R² = 0.83,模型效果不错"。"不错"是别人的判断,你的职责是把数字和它的边界说清楚。

8.8 常见坑 ​

text
① 用 MAE 选模型                → 两个模型 MAE 差 0.57%,RMSE 差 3.42 倍
② 把 MSE 报给别人              → soh² 没有物理含义
③ 看到负 R² 就改代码            → 先查"训练集和测试集的标签分布"
④ 跨数据集比较 R²               → 分母(标签方差)不同,不可比
⑤ 只用 R² 一个数做结论           → 没有绝对误差,"够不够用"无法回答
⑥ 用 y.var() 手算 R²            → pandas 默认 ddof=1,和 sklearn 差 7.78e-06
⑦ 忘记把 MAE 换算成决策尺度      → 1.79 个百分点 vs 退役线裕度,必须放一起看

★ ③ 和 ④ 是"看起来对但其实错"的组合:

③ 负 R²  →  看起来像"bug",其实是"问错了问题"
④ R² 比较 → 看起来像"模型 A 更好",其实是"分母不同"
   ↓
两个都源于同一个认知缺口:【R² 是相对的,相对的是"用均值预测"】

★ 把这句话记牢,这一章就算学完了:

MAE / RMSE 告诉你"错了多少";R² 告诉你"相对什么都不做,好了多少"。 前者需要标尺(量纲),后者需要分母(基准)。

8.9 完整示例与输出 ​

bash
cd code
python demos/ch08-regression-metrics.py

真实输出(节选):

text
=== ① 四个数字一起算出来(特征只用 cycle)===
  MAE         0.017864   和标签同量纲(soh)→ 平均差 1.79 个百分点
  MSE       0.00049689   soh²(平方过,没有物理含义)
  RMSE        0.022291   soh → 典型误差 2.23 个百分点
  R²          0.834759   无量纲,同 model.score() = 0.834759

=== ② RMSE ≥ MAE 恒成立,比值能读出「误差的形状」 ===
  比值 RMSE / MAE = 1.2478
  参考值:如果误差是正态的,这个比值会趋近 √(π/2) = 1.2533

=== ③ 对照:MAE 说「一样好」,RMSE 说「差 3.4 倍」 ===
  模型 A(误差永远 0.01975):MAE = 0.019750   RMSE = 0.019750
  模型 B(95% 很小,5% 错 0.3):MAE = 0.019862   RMSE = 0.067512

=== ④ ★ 亲手造一个负的 R² ===
  测试集 R² = -3.525302   ← 负数!
    比值 = 4.5213 倍 → R² = 1 - 4.5213 = -3.525302

=== ⑤ R² 不能跨数据集比较:同一个模型,RMSE 更小、R² 却是负的 ===
  全表                              21,239   0.00300722   0.022291   0.017864   0.834759
  soh >= 0.95 的行(标签很挤)             6,202   0.00018669   0.015405   0.012620  -0.271448

本章练习 ​

练习 1:手算四个指标,和 sklearn 对照

用 cycle 单特征模型:

  1. 用 numpy 手算 MAE、MSE、RMSE、R²;
  2. 和 sklearn.metrics 的 mean_absolute_error、mean_squared_error、r2_score 逐个对照;
  3. 再用 y.var()(pandas 默认 ddof=1)算一次 R²,看差多少,并说明原因。
参考答案
python
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score

df = pd.read_csv("data/battery/soh_features.csv")
y = df["soh"]
model = LinearRegression().fit(df[["cycle"]], y)
pred = model.predict(df[["cycle"]])
resid = y.to_numpy() - pred

mae = np.abs(resid).mean()
mse = (resid ** 2).mean()
rmse = np.sqrt(mse)
r2 = 1 - mse / y.to_numpy().var()

print(f"手算 MAE  = {mae:.8f}   sklearn = {mean_absolute_error(y, pred):.8f}")
print(f"手算 MSE  = {mse:.8f}   sklearn = {mean_squared_error(y, pred):.8f}")
print(f"手算 RMSE = {rmse:.8f}   sklearn = {np.sqrt(mean_squared_error(y, pred)):.8f}")
print(f"手算 R²   = {r2:.8f}   sklearn = {r2_score(y, pred):.8f}")
print(f"用 y.var()(ddof=1)算:{1 - mse / y.var():.8f}")

期望输出:

text
手算 MAE  = 0.01786443   sklearn = 0.01786443
手算 MSE  = 0.00049689   sklearn = 0.00049689
手算 RMSE = 0.02229111   sklearn = 0.02229111
手算 R²   = 0.83475876   sklearn = 0.83475876
用 y.var()(ddof=1)算:0.83476654

★ 第 3 问的答案:

两者差 0.0000078(7.78e-06)
   ↓
原因:R² 的分母用的是【总体方差】(除以 n,ddof=0)
     sklearn 的 r2_score 和 model.score() 都按这个定义实现
   ↓
pandas 的 y.var() 默认 ddof=1(样本方差,除以 n-1)
   ↓
n = 21,239 时,两个口径的方差只差 1/21239 ≈ 0.005%

★ 这个差别小到"无所谓",但它教会一件事:

任何"手算对不上"的情况,先查定义(公式里的除以 n 还是 n−1), 再查代码。

★ 顺带一个更大的教训:y.var() 的 ddof 会影响大批结论

如果 n 很小(比如 20 个样本):
   ddof=0 和 ddof=1 差 5%  → 足以影响"哪个模型更好"的判断

所以本书所有手算 R² 的地方都显式写 ddof=0。

练习 2:再复现一次负 R²(这次训练到 600 圈)
  1. 用 cycle <= 600 的行训练,在 cycle >= 1200 的行上评估,打印训练/测试 R²;
  2. 打印测试集的 MAE、RMSE、RMSE/MAE;
  3. 算"模型的 MSE 是均值基准的几倍",并写出这个倍数和 R² 的关系式;
  4. 用一句话解释为什么它是负的。
参考答案
python
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression

df = pd.read_csv("data/battery/soh_features.csv")
train = df[df["cycle"] <= 600]
test = df[df["cycle"] >= 1200]

m = LinearRegression().fit(train[["cycle"]], train["soh"])
pred = m.predict(test[["cycle"]])
resid = test["soh"].to_numpy() - pred

print(f"训练集 R² = {m.score(train[['cycle']], train['soh']):.6f}")
print(f"测试集 R² = {m.score(test[['cycle']], test['soh']):.6f}")
print(f"测试集 MAE = {np.abs(resid).mean():.6f}  RMSE = {np.sqrt((resid ** 2).mean()):.6f}"
      f"  比值 = {np.sqrt((resid ** 2).mean()) / np.abs(resid).mean():.4f}")
ratio = (resid ** 2).mean() / test["soh"].var(ddof=0)      # ★ 必须 ddof=0,和 sklearn 对齐
print(f"模型 MSE / 标签方差 = {ratio:.6f} 倍  →  R² = 1 - {ratio:.6f} = {1 - ratio:.6f}")

期望输出:

text
训练集 R² = 0.683957
测试集 R² = -8.738445
测试集 MAE = 0.029741  RMSE = 0.031011  比值 = 1.0427
模型 MSE / 标签方差 = 9.738445 倍  →  R² = 1 - 9.738445 = -8.738445

★ 第 3 问的关系式:

text
             模型的 MSE
R² = 1 -  ─────────────  =  1 - 9.738445  =  -8.738445
            标签方差

★ 第 4 问的答案:

训练数据只到 600 圈(那一批的 soh ≥ 0.8534), 模型从没见过 1200 圈之后的状态。 它把早期那段的斜率延长过去,预测值系统性偏低, 所以在晚期数据上的误差比"一律答晚期均值"还大 9.74 倍。

★ 注意 RMSE/MAE = 1.0427 这个数:

非常接近 1
   ↓
说明误差几乎全是"整体偏移",不是"少数点胡来"
   ↓
所以修的方向不是"找异常点",而是"这个模型根本没资格外推"

★ 和练习 1 对照的结论:

练习 1(全表拟合,全表评估)练习 2(早期训练,晚期评估)
训练 R²0.8347590.683957
测试 R²0.834759(同一批数据)−8.738445
RMSE/MAE1.2478(正态误差)1.0427(系统性偏移)

★ 这张对照表就是"为什么必须划分数据"的第一个证据 —— 第 09 章会把这件事做完。

练习 3:用 RMSE/MAE 比值判断"错误危不危险"

构造三组残差,算 MAE、RMSE、RMSE/MAE:

组构造
A每个误差都是 0.02
B`
C97% 的行误差 0.005,3% 的行误差 0.25

然后回答:

  1. 哪一组的比值最接近 1?这代表什么?
  2. C 组的 MAE 比 B 组小,能说 C 比 B 好吗?
  3. 如果真实的误差比值是 1.2478,你下一步该查什么?如果比值突然变成 3.5 呢?
参考答案
python
import numpy as np

def pad(text, width):
    """按显示宽度补空格(中文占 2 格),让三行对齐。"""
    w = sum(2 if ord(ch) > 0x2E80 else 1 for ch in text)
    return text + " " * max(0, width - w)


rng = np.random.default_rng(7)
n = 21239
sets = {
    "A 每个误差都是 0.02": np.full(n, 0.02),
    "B 正态噪声 σ=0.03": np.abs(rng.normal(0, 0.03, n)),
    "C 97% 的行 0.005 + 3% 的行 0.25": np.where(rng.random(n) < 0.03, 0.25, 0.005),
}
for name, r in sets.items():
    a, q = np.abs(r).mean(), np.sqrt((r ** 2).mean())
    print(f"{pad(name, 36)}MAE = {a:.6f}  RMSE = {q:.6f}  比值 = {q / a:.4f}")

期望输出:

text
A 每个误差都是 0.02                 MAE = 0.020000  RMSE = 0.020000  比值 = 1.0000
B 正态噪声 σ=0.03                   MAE = 0.023741  RMSE = 0.029780  比值 = 1.2544
C 97% 的行 0.005 + 3% 的行 0.25     MAE = 0.012602  RMSE = 0.044311  比值 = 3.5162

★ 第 1 问:A 组,比值 = 1.0000。

所有误差一样大 → Var(|误差|) = 0 → RMSE = MAE
   ↓
代表:误差只是"整体偏了固定的一段",没有"忽大忽小"

⚠️ 但注意:比值 = 1 不代表模型好!

A 组的每个预测都差 0.02(2 个百分点)
   ↓
误差非常"均匀"、非常"可预期"
   ↓
但它依然每次都差 2 个百分点
   ↓
如果决策需要 ±1 个百分点,A 组完全不合格

★ 第 2 问:

C 组 MAE = 0.012602  <  B 组 MAE = 0.023741
   ↓
能说 C 比 B 好吗?—— 不能
   ↓
C 组有 3% 的行误差 0.25(25 个百分点!)
   ↓
RMSE 0.044311 是 B 组(0.029780)的 1.49 倍
   ↓
按 RMSE 选,B 更好

结论:

MAE 小只能说明"平均下来还行"; 想知道"有没有灾难性的个案",必须看 RMSE 的比值。

★ 第 3 问:

比值 = 1.2478(接近 √(π/2))时该查什么:

① 不用查异常点 —— 这个比值说明"没有离群的大错"
② 该查【系统性】因素:
     · 有没有漏掉重要特征(第 13 章)
     · 模型容量够不够(第 18 章)
     · 加数据还有没有用(第 21 章学习曲线)
③ 该问:误差的 1.79 个百分点里,有多少是"每行独立的测量噪声"
   (本书的数据里,这一部分是【不可能消掉】的,第 10、21 章会量化)

比值突然变成 3.5 时该查什么:

① 先找出误差最大的那 3% 行,看它们长什么样
     · 是不是某个 cell_id?(数据采集异常)
     · 是不是 cycle 的某一段?(工况切换)
     · 是不是某个特征的极值?(量程问题)
② 再问"数据里有几批不同的东西" —— 很可能是混了两种工况
③ 最后才回来谈"要不要换模型"

★ 一句话总结这个练习:

MAE 告诉你"平均有多偏",RMSE/MAE 告诉你"最坏有多坏"。 先看比值,决定你下一步是"调模型"还是"查数据"。

面向工程实践:把指标变成"能对外说的一句话" ​

这一章讲的是四个数字。真实项目里,"算指标"占 1% 的工作量, "把指标说清楚"占 99%。

① 一份可以直接抄的评估报告模板

python
def report(y_true, y_pred, split_desc, target="soh", threshold=0.80):
    """把一次评估写成能对外的几句话。"""
    y_true = np.asarray(y_true, dtype=float)
    resid = y_true - np.asarray(y_pred, dtype=float)
    mae, mse = np.abs(resid).mean(), (resid ** 2).mean()
    rmse, var = np.sqrt(mse), y_true.var()
    r2 = 1 - mse / var
    print(f"数据集:{split_desc}({len(y_true):,d} 行,标签方差 {var:.8f})")
    print(f"  MAE  = {mae * 100:.2f} 个百分点")
    print(f"  RMSE = {rmse * 100:.2f} 个百分点   (RMSE/MAE = {rmse / mae:.4f})")
    print(f"  R²   = {r2:.4f}   ← 基准是「一律答 {y_true.mean():.4f}」")
    print(f"  决策含义:阈值 {threshold} ± {mae:.4f} → "
          f"[{threshold - mae:.4f}, {threshold + mae:.4f}]")

★ 这份报告里最关键的三个"多余"信息:

① 标签方差      →  说明 R² 的分母(跨数据集比较的必要条件)
② RMSE/MAE     →  说明误差的形状(下一步该查数据还是调模型)
③ 决策含义       →  把误差换算成阈值附近的区间(回答"够不够用")

缺了这三样,"R² = 0.83" 这句话没法支撑任何决策。

② 三个必须写进报告、不能省的限定词

text
① 在哪份数据上评估的?(训练集 / 测试集 / 怎么划分的)
② 标签的基准是多少?("用均值预测"的均值具体是几)
③ 误差的量纲是什么?(百分点 / 圈 / ...)

★ 第 ② 条最容易被省,也最容易被误解:

"R² = 0.83"
   ↓
读者默认基准是"0"
   ↓
但 R² 的基准是"标签均值 0.9088"
   ↓
所以完整的说法是:"比一律答 0.9088 少犯错 83%"

③ 一个"两个模型比大小"的正确流程

python
# ① 先确认两个模型在【同一个测试集】上评估(分母相同)
assert len(y_test) == len(y_pred_a) == len(y_pred_b)

# ② 比 MAE 和 RMSE 两个
for name, p in [("A", pred_a), ("B", pred_b)]:
    print(f"{name}: MAE={mean_absolute_error(y_test, p):.4f}  "
          f"RMSE={mean_squared_error(y_test, p) ** 0.5:.4f}  "
          f"R²={r2_score(y_test, p):.4f}")

# ③ 如果两者的 MAE 排序和 RMSE 排序不一致 → 去看误差分布
#    不看分布就下结论,是本项目最常见的返工原因

★ 第 ③ 条是这一章的核心忠告:

当 MAE 和 RMSE 给出不同的排序时,不要选一个信, 要去把它们的分歧来源找出来。

④ 这一章的知识,一句话

MAE / RMSE 告诉你"错了多少",需要标尺; R² 告诉你"比什么都不做"好了多少,需要分母。 说清标尺和分母,这四个数字才能支撑判断。

到这里,第一部分(地基)就结束了 —— 你现在能:把表切成 X/y(04)、检查形状(05)、训练一条直线(06)、 读懂它的四个数字(07)、评价它(08)。

但你手上所有的分数,都还建立在"用全部数据训练、又用全部数据打分"上。第 09 章开始,我们要把这件看起来理所当然的事拆掉:数据必须先切开,而怎么切,决定了你的分数有没有意义。


下一章:第 09 章 · 训练集与测试集:先把数据切开