第 08 章 · 评价回归的四个数字:MAE / MSE / RMSE / R²
这一章要解决什么:把"模型好不好"这件事变成四个数字, 并且知道每个数字的量纲、基准和适用场合。
最容易踩的坑: ① 用 MAE 选模型 —— 两个模型 MAE 几乎一样,RMSE 却能差 3.4 倍; ② 看到负的 R² 以为代码坏了 —— 它是一句诚实的话:"你比用均值预测还差"; ③ 拿两个不同数据集上的 R² 比大小 —— 分母(标签方差)都不一样,没法比。
本章你需要完成的代码:demos/ch08-regression-metrics.py
8.1 四个数字一起算出来
先用第 06 章那个 cycle 单特征模型,把四个指标一次算全:
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)真实输出:
模型: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 个百分点)★ 四个数字,一张表说清:
| 指标 | 公式 | 量纲 | 一句话读法 |
|---|---|---|---|
| MAE | mean(abs(y - pred)) | 和标签同量纲 | 平均差多远 |
| MSE | mean((y - pred) ** 2) | 标签的平方 | 只用于数学推导 |
| RMSE | sqrt(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² 能存在的前提)
总平方和 = 回归解释的平方和 + 残差平方和
↓
Σ(yᵢ - ȳ)² = Σ(ŷᵢ - ȳ)² + Σ(yᵢ - ŷᵢ)²翻译成人话:
标签的总波动 = 模型解释掉的波动 + 模型没解释掉的波动
↓
所以 R² = "解释掉的 / 总波动"
↓
绝对值误差(MAE)没有这个分解② 平方误差的最小值有解析解
MSE 对参数求导 = 0 → 解一个线性方程组 → 直接得到系数
MAE 对参数求导 → 绝对值在 0 点不可导,没有闭式解,要迭代这就是为什么 LinearRegression 一行代码就能出结果。
③ 平方误差直接对应"高斯噪声下的最大似然"
假设残差服从正态分布
↓
"最大似然" 等价于 "最小二乘"
↓
于是 MSE 有一个明确的统计学解释:
它是对噪声方差 σ² 的估计
↓
RMSE 就是对 σ 的估计 —— "典型误差"这个说法由此而来★ 所以正确的态度是:
内部计算 / 推导 / 比较 → 用 MSE(性质好)
对外汇报 / 选型 / 定阈值 → 用 RMSE 和 MAE(量纲对)★ 一个额外的指标:MedAE(中位绝对误差)
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
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 圈的圈数误差这是一个非常实用的换算:
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 恒成立,比值能读出"误差的形状"
真实输出:
实测:RMSE 0.022291 ≥ MAE 0.017864 -> True
比值 RMSE / MAE = 1.2478
参考值:如果误差是正态的,这个比值会趋近 √(π/2) = 1.2533
实测 1.2478 和 1.2533 很接近 → 误差大致是「正态的随机波动」,
没有少数几个错得离谱的行在捣乱。★ 这个不等式是数学上恒成立的,不是经验规律:
RMSE ≥ MAE (恒成立,等号只在所有误差一样大时取到)证明只要一行(第 8.3 节末的折叠块里有完整推导), 核心是"方差非负":
平方的均值 ≥ 均值的平方 (等号只在所有数相等时成立)
↓
E[r²] ≥ (E[|r|])²
↓
RMSE ≥ MAE★ 比值告诉你什么:
| RMSE / MAE | 误差的形状 | 含义 |
|---|---|---|
| = 1.000 | 每个点的误差完全相同 | 最"整齐"的误差(现实中几乎不存在) |
| ≈ 1.25 | 正态分布 | √(π/2) = 1.2533,最常见的形态 |
| > 1.5 | 少数点误差特别大 | 有离群点 / 有几段数据模型完全不会 |
| > 3 | 少数点错得离谱 | 必须先查那些行,再谈模型好不好 |
实测数据给出的是 1.2478 —— 非常接近正态参考值。
★ 三种"形状"的实测对照:
真实输出:
误差的形状 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 为"平方的平均再开根号"。
★ 正确的证明只要两步
第一步:由「方差非负」
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ᵢ| 完全相等。
★ 一个常见的错误证明(别写成这个)
❌ 错误写法:
RMSE ≥ |r̄| (由"均值的平方 ≤ 平方的均值")
MAE ≥ |r̄| (由绝对值不等式)
→ 所以 RMSE ≥ MAE ← 推不出来!为什么推不出来:
两个量都 ≥ 同一个数 |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 的差距,恰好度量了"误差的离散程度"。两者相等 ⇒ 所有误差一样大(误差只是"偏",不是"乱")。
★ 最后给一个实用推论
RMSE / MAE 在 1.25 附近:
误差是"同一种随机性"
→ 想提升模型,要找的是【系统性】的原因(缺特征、模型容量不够)
RMSE / MAE 明显大于 1.5:
有一批点错得特别离谱
→ 先【把那批点找出来看看】
很可能它们不是"模型的误差",而是"数据的问题"(脏数据、特殊工况)★ 这就是第 08 章之后、第 21 章(学习曲线)、第 22 章(正则化) 要去的地方:把"误差的形状"翻译成"下一步该做什么"。
8.4 ★ 对照:MAE 说"一样好",RMSE 说"差 3.4 倍"
这一节是这一章的"看起来对但其实错"。
造两个"MAE 几乎完全一样"的模型:
# 模型 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)真实输出:
模型 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² 意味着什么
这是本章最重要的一节,因为它最容易被误解成"代码坏了"。
先亲手造一个:
train = df[df["cycle"] <= 400] # 只用早期数据训练
test = df[df["cycle"] >= 1200] # 在晚期数据上评估
model = LinearRegression().fit(train[["cycle"]], train["soh"])
print(model.score(test[["cycle"]], test["soh"]))真实输出:
训练集: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.0896R² = −3.53 是什么意思? 回到定义:
模型的 MSE
R² = 1 - ──────────────────
用均值预测的 MSE真实输出:
负 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 ┘ 区别在"错得多离谱"★ 为什么这个模型会这么差?
真实输出:
★ 但它并不「乱」:RMSE/MAE = 1.0896 接近 1,说明误差是【整体偏了】
(预测均值 0.8032 vs 真实均值 0.8226,系统性低了 0.0193)拆开看:
① 训练数据只到 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.95 | 0.94 | ✅ 正常("形态"示意) |
| 0.99 | 0.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² 不能跨数据集比较
现在做第二个对照实验:同一个模型,在三份"标签方差不同"的数据上评估。
真实输出:
同一个模型(用全表训练的 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 怎么向业务方解释这四个数字
真实输出:
❌ 「R² = 0.8348」 → 业务方不知道这是什么,也不知道该不该信
❌ 「MSE = 0.00049689」 → 没有量纲,谁都读不懂
✅ 「MAE = 1.79 个百分点」→ 平均每个预测差 1.79 个百分点
✅ 「RMSE = 2.23 个百分点」→ 大误差也考虑进去之后是 2.23 个百分点
✅ 「R² = 0.8348」→ 比「一律报平均值」少犯错 83.5%(按平方误差算)★ 一个可以直接用的三句话模板:
① 平均误差:平均每次预测差 {MAE×100:.2f} 个百分点
② 波动幅度:如果碰上不太好的情况,误差会到 {RMSE×100:.2f} 个百分点
③ 相对提升:比"不管哪支设备都报 {y.mean():.3f}"这个基线少犯错 {R²×100:.1f}%用真实数字填进去:
① 平均每次预测差 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 常见坑
① 用 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 完整示例与输出
cd code
python demos/ch08-regression-metrics.py真实输出(节选):
=== ① 四个数字一起算出来(特征只用 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 单特征模型:
- 用 numpy 手算
MAE、MSE、RMSE、R²; - 和
sklearn.metrics的mean_absolute_error、mean_squared_error、r2_score逐个对照; - 再用
y.var()(pandas 默认ddof=1)算一次 R²,看差多少,并说明原因。
参考答案
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}")期望输出:
手算 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 圈)
- 用
cycle <= 600的行训练,在cycle >= 1200的行上评估,打印训练/测试 R²; - 打印测试集的
MAE、RMSE、RMSE/MAE; - 算"模型的 MSE 是均值基准的几倍",并写出这个倍数和 R² 的关系式;
- 用一句话解释为什么它是负的。
参考答案
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}")期望输出:
训练集 R² = 0.683957
测试集 R² = -8.738445
测试集 MAE = 0.029741 RMSE = 0.031011 比值 = 1.0427
模型 MSE / 标签方差 = 9.738445 倍 → R² = 1 - 9.738445 = -8.738445★ 第 3 问的关系式:
模型的 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.834759 | 0.683957 |
| 测试 R² | 0.834759(同一批数据) | −8.738445 |
| RMSE/MAE | 1.2478(正态误差) | 1.0427(系统性偏移) |
★ 这张对照表就是"为什么必须划分数据"的第一个证据 —— 第 09 章会把这件事做完。
练习 3:用 RMSE/MAE 比值判断"错误危不危险"
构造三组残差,算 MAE、RMSE、RMSE/MAE:
| 组 | 构造 |
|---|---|
| A | 每个误差都是 0.02 |
| B | ` |
| C | 97% 的行误差 0.005,3% 的行误差 0.25 |
然后回答:
- 哪一组的比值最接近 1?这代表什么?
- C 组的 MAE 比 B 组小,能说 C 比 B 好吗?
- 如果真实的误差比值是 1.2478,你下一步该查什么?如果比值突然变成 3.5 呢?
参考答案
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}")期望输出:
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%。
① 一份可以直接抄的评估报告模板
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" 这句话没法支撑任何决策。
② 三个必须写进报告、不能省的限定词
① 在哪份数据上评估的?(训练集 / 测试集 / 怎么划分的)
② 标签的基准是多少?("用均值预测"的均值具体是几)
③ 误差的量纲是什么?(百分点 / 圈 / ...)★ 第 ② 条最容易被省,也最容易被误解:
"R² = 0.83"
↓
读者默认基准是"0"
↓
但 R² 的基准是"标签均值 0.9088"
↓
所以完整的说法是:"比一律答 0.9088 少犯错 83%"③ 一个"两个模型比大小"的正确流程
# ① 先确认两个模型在【同一个测试集】上评估(分母相同)
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 章开始,我们要把这件看起来理所当然的事拆掉:数据必须先切开,而怎么切,决定了你的分数有没有意义。