Skip to content

第 21 章 · 亲手做出 R² = 1.000000 ​

这一章要解决什么:亲手把一个模型做到 R² = 1.000000, 然后用系数反推出真相 —— 模型一个规律都没学到, 它只是把标签乘以 1 再加 0.8。

最容易踩的坑: ① 看到 1.000000 的第一反应是"我成功了"; ② 只盯着 R²,不看 coef_(那一列系数是 1,其余全是 0); ③ 以为这是"极端个案" —— 其实只要特征里出现标签的仿射函数, 任何线性模型都会精确地给出 1.000000。

本章你需要完成的代码:demos/ch21-perfect-score.py

本章的口径:soh_features_v2.csv 全部 21,239 行、 GroupShuffleSplit(test_size=0.2, random_state=42) 按设备划分、 模型是不加缩放的 LinearRegression(不加缩放是为了让系数可以直接读)。

21.1 对照组:诚实的 0.94253 ​

先把"诚实水平"钉死。同样按设备划分,只用 7 列基础特征,线性回归:

python
m7 = LinearRegression().fit(X.iloc[tr][BASE], y.iloc[tr])
print(m7.score(X.iloc[tr][BASE], y.iloc[tr]))         # 训练
print(m7.score(X.iloc[te][BASE], y.iloc[te]))         # 测试

真实输出:

text
① 对照组:7 列基础特征 + 线性回归(按设备划分)
  训练 R² = 0.94296   测试 R² = 0.94253   MAE = 0.010820
  |系数| 最大 = 0.5556(对应 cap_fade_rate_ah_per_100)   截距 = 1.3436
  这是这份数据在『只用横截面特征』时的真实水平:0.94 上下。

★ 记住这三个数,它们是本章的"标尺":

text
测试 R² = 0.94253      ← 诚实的水平
MAE     = 0.010820     ← 诚实的误差量级(1e-2)
max|coef| = 0.5556     ← 诚实的系数量级(0.5 上下)

21.2 只加一列:R² = 1.000000 ​

现在只做一件事:把 soh_margin 加进特征。

python
X8 = df[BASE + ["soh_margin"]]
lin8 = LinearRegression().fit(X8.iloc[tr], y.iloc[tr])

真实输出:

text
② 只加一列 soh_margin:训练 1.000000,测试 1.000000
  训练 R² = 1.000000   测试 R² = 1.000000
  测试 MAE = 2.100e-06(7 列时是 0.010820)
  测试集上『预测值 − 实测值』的最大绝对值 = 9.05e-06

★ 三个数字放在一起看:

text
R²:0.94253 → 1.000000   (涨了 0.05747)
MAE:0.010820 → 2.100e-06(小了 5,153 倍)
最大误差:9.05e-06
     ↓
在一次评估里,"误差小到 1e-6" 意味着什么?

★ 先做一个量级判断,避免误读:

text
标签 soh 的取值范围:0.8032 ~ 1.0017(跨度约 0.2)
测试集的最大绝对误差:9.05e-06
     ↓
相对跨度:9.05e-06 / 0.2 ≈ 4.5e-05
     ↓
也就是说:模型"预测"的误差是标签尺度的十万分之四

★ 而这里必须补一句精确的话(这一点和本书第 11 章的"tol 真因"是同一个机制):

text
2.100e-06 还不是"零",它是 LinearRegression 默认 tol=1e-6 的【截断残留】
     ↓
把 tol 设成 0(或者只用 soh_margin 一列),MAE 会掉到 1.2e-16
—— 那才是真正的浮点零
     ↓
本节先记住"1e-6 已经小得离谱",21.3 节把 1e-16 算出来

★ 一个不该被忽略的疑问:为什么"加一列"能让 R² 精确到 1.000000?

text
正常的特征工程:加一列 → 涨 0.005 ~ 0.04(第 20 章实测)
这里:加一列 → 从 0.94253 直接到 1.000000
     ↓
"恰好 1.000000" 这个形状本身就是一个信号:
真实现象不会恰好落在边界上
(就像"命中率恰好 100%、误差恰好 0"一样)

21.3 用系数反推真相 ​

这是本章的核心动作:把 coef_ 打印出来。

真实输出:

text
③ 用 coef_ 与 intercept_ 反推真相同:模型学到的是 soh = soh_margin + 0.80
  8 列线性回归学到的系数:
    cycle                       +0.00000001
    mean_charge_voltage_v       -0.00000093
    mean_temp_rise_c            -0.00000006
    resistance_mohm             +0.00000015
    coulombic_eff               +0.01087291
    cap_fade_rate_ah_per_100    +0.00005252
    temp_std_c                  +0.00000267
    soh_margin                  +0.99988176   ← 只有它是非零
    截距                          +0.78917045

★ 读这张表只要三步:

第一步:谁是主角?

text
soh_margin 的系数 = 0.99988176 ≈ 1
其余 7 列的系数都是 0.0000xxxx 量级(最大 0.01087291)
     ↓
8 列特征里,模型只用了一列

第二步:其余 7 列"退化"到什么程度?

text
刚才 7 列对照时:max|coef| = 0.5556(cap_fade_rate_ah_per_100)
现在 8 列时    :同一列的系数 = 0.00005252
     ↓
从 0.5556 掉到 0.00005252,缩小了约 10,000 倍
     ↓
也就是说:模型把 7 列真实物理量【全部丢掉了】

第三步:把系数代回去,看看它到底学到了什么。

text
模型学到的函数:
    soh = 0.99988176 × soh_margin + 0.78917045

把 soh_margin = soh − 0.80 代进去:
    soh = 0.99988176 × (soh − 0.80) + 0.78917045
        = soh                                (恒等式)
     ↓
它没有学到温度、内阻、库仑效率与 SOH 的任何关系
它学到的是:把标签减掉 0.80,再加上 0.80

★ 一句话:

模型一个规律都没学到。它只是把标签乘以 1 再加 0.8。

21.3.1 验证一:soh_margin 恒等于 soh − 0.80 ​

真实输出:

text
  ③-1 验证一:soh_margin 是否恒等于 soh − 0.80
        max |soh_margin − (soh − 0.80)| = 1.11e-16(机器精度)
        → 这一列不是『和标签很相关』,它就是标签的一个仿射变换。

★ 这一步把"相关"和"等价"分开了:

text
|r| = 1.000000   → 两者有【完美的线性关系】(可能是 a·y + c,a ≠ 1)
max|差| = 1.1e-16 → 两者在浮点意义上是【同一个量的平移】(a = 1, c = −0.80)
     ↓
前者是警告,后者是判决:
     a = 1 时,这一列是标签减常数;
     a ≠ 1 时,这一列是标签乘系数再平移(同样是泄漏,只是系数不同)

21.3.2 验证二:只用这一列,系数精确等于 1、截距精确等于 0.80 ​

真实输出:

text
  ③-2 验证二:只用 soh_margin 一列,照样 1.000000
        系数 = 1.0000000000   截距 = 0.8000000000   测试 R² = 1.0000000000
        测试 MAE = 1.167e-16   最大绝对误差 = 3.331e-16
        → 8 列时系数是 0.99988176、截距是 0.78917045,
           1 列时才是精确的 1 和 0.80。差在哪?差在 tol(下一段)。

★ 1.167e-16 是什么概念?

text
IEEE 754 双精度浮点的机器精度 ε ≈ 2.22e-16
     ↓
MAE = 1.167e-16 ≈ ε/2,最大误差 3.331e-16 ≈ 1.5ε
     ↓
这不是"预测得很准",这是【浮点意义上的零】

★ "模型"和"恒等式"的区别,就在这个数量级上:

现象典型 MAE说明
真实的物理规律1e-3 ~ 1e-2有噪声、有未观测因素
很强的轨迹特征(历史 SOH)1e-3 量级依然是"预测"
标签的仿射函数1e-16恒等式,没有预测的成分

21.3.3 验证三:tol 把那个"精确"挡在了 1e-6 之外 ​

真实输出:

text
  ③-3 验证三:默认 tol=1e-6 把方向截断了(与第 11 章同一个真因)
        tol=1e-06    coef(soh_margin) = 0.9998817630   截距 = 0.7891704538   rank_ = 7/8   MAE = 2.100e-06
        tol=0        coef(soh_margin) = 1.0000000000   截距 = 0.8000000000   rank_ = 8/8   MAE = 2.228e-16
        → tol=0 时,模型给出的正是那个恒等式:系数 1、截距 0.80。
           默认 tol 下它少用了一个方向(rank_ 7/8),
           所以把 0.8 的截距『分摊』了一点到其他列上。

★ 这里要非常小心地讲,因为它和第 11 章的"缩放真因"是同一件事:

text
LinearRegression 用 SVD 求解,默认 tol=1e-6 用来判断"哪些奇异值算 0"
     ↓
本数据 7 列基础特征的量纲差异极大:
    cycle 的尺度 ~ 10²(几百圈)
    coulombic_eff 的尺度 ~ 10⁰(0.99 左右)
     ↓
设计矩阵的条件数很大 → 最小奇异值被 tol 判为 0 → rank_ 从 8 变 7
     ↓
于是"精确的恒等式解"被牺牲掉一点点:
    系数 0.99988176、截距 0.78917045、MAE 2.1e-06
     ↓
tol=0 时 rank_ = 8,解回到精确的 1 和 0.80,MAE = 2.2e-16

★ 所以本章有两个"真因"要分清:

text
真因一(本章主线):soh_margin 是标签的仿射函数 → R² 必然是 1,与模型无关
真因二(数值细节):默认 tol 让系数从"精确的 1"变成"0.99988"
     ↓
真因一是【方法问题】,必须修(删列);
真因二是【数值细节】,改 tol 或缩放就能对齐 ——
它只是解释了"为什么系数不是恰好 1",不能用来替泄漏开脱

★ 反过来,这个细节还提供了一个额外的证据:

text
如果 soh_margin 只是一个"和标签高相关的普通特征",
那 tol=0 不会让它变成精确的恒等式
     ↓
tol=0 之后系数精确等于 1、截距精确等于 0.80、残差降到 1e-16
     ↓
这是"它本来就是恒等式"的强证据

21.4 三组反证:它学到的是"这一列",不是一个规律 ​

前两节说的是"模型学到了恒等式"。本节要证明"它除了这一列,什么都没学到"。

21.4.1 把标签随机打乱 ​

真实输出:

text
④ 三组反证:它学到的是『这一列』,不是一个规律
  ④-1 把标签随机打乱再拟合 → R² 崩塌
        打乱后测试 R² = -0.00066   coef(soh_margin) = -0.04040
        → 恒等式一旦被破坏,模型立刻什么都不会了。

★ 这个实验的设计逻辑:

text
如果模型学到的是"温度 + 内阻 + 库仑效率 → SOH"这个物理规律,
那把【标签】打乱,模型应该还能靠特征拟合出一部分结构(R² 不会变成负的那么彻底)
     ↓
但实测 R² = -0.00066,系数变成 −0.04040
     ↓
说明它依赖的不是"特征里的规律",而是"标签与这一列的恒等关系":
关系一断,模型立刻归零

21.4.2 把这一列换成随机噪声 ​

真实输出:

text
  ④-2 把 soh_margin 换成随机噪声 → 分数正好掉回对照组
        测试 R² = 0.94253   (7 列对照组 = 0.94253)
        → 把钩子换掉,分数就回到 0.94253。这 1.000000 全部来自那一列。

★ 这是最干净的一个反证:

text
换掉钩子前:0.94253(7 列)+ 钩子 → 1.000000
换掉钩子后:0.94253(7 列)+ 噪声 → 0.94253
     ↓
变化量精确地等于"那一列的价值":
    1.000000 − 0.94253 = 0.05747 全部来自 soh_margin
    噪声版本与 7 列对照【完全一致】(0.94253 = 0.94253)
     ↓
也就是说:其余 7 列在这两种配置里贡献完全相同,
          唯一的变量就是那一列

21.4.3 把钩子乘 a(系数变成 1/a) ​

真实输出:

text
  ④-3 把钩子改成 2 倍 / 0.5 倍 → 系数变成倒数,R² 仍然 1
        新列                                  系数          截距       测试 R²
        soh_margin                    1.000000    0.800000    1.000000
        2 × soh_margin                0.500000    0.800000    1.000000
        0.5 × soh_margin              2.000000    0.800000    1.000000
        soh − 0.50(换常数)               1.000000    0.500000    1.000000
        → 新列 = a × soh_margin 时系数 = 1/a、截距 = 0.80;
           新列 = soh − c 时系数 = 1、截距 = c。
           模型学到的永远是『把这个新列还原成 soh』。

★ 这组实验说明了一个一般规律(下一节会给出推导):

text
只要特征 h 是标签的仿射函数 h = a·y + c,
线性回归就会学出 w = 1/a、b = −c/a,并且 R² 精确等于 1
     ↓
**这不是模型的成绩,这是代数结论**
     ↓
"模型很聪明地发现了规律"这个说法在这里完全失效 ——
它只是解了一个一元一次方程

★ 而这一组实验还有一个实用价值:它解释了"为什么有些泄漏的系数看起来很怪"。

text
如果你在真实项目里看到某个"重要特征"的系数是 12.7,
不要先怀疑数据错误 —— 先怀疑它是不是【标签的缩放版本】
     ↓
一个系数明显偏离其他系数量级的特征,本身就是一条线索
(第 21.6 节的"体检一")

21.5 一般化的结论:标签的仿射函数必然给出 R² = 1 ​

把上面的现象写成一条定理,它比"这个数据里有一个钩子"有用得多。

text
命题:
    设标签为 y,特征 h = a·y + c(a ≠ 0)。
    用 h 做一元线性回归(含截距),最小二乘解为:
        w = 1/a,  b = −c/a
    并且在【任何】训练集上训练、在【任何】测试集上评估,
    都有 R² = 1、MAE = 0(浮点意义)。

直观证明(两行):
    ŷ = w·h + b = (1/a)·(a·y + c) − c/a = y
        ↓
    预测值恒等于真值 → 残差恒为 0 → R² = 1

★ 这条命题的三个推论,每一条都有工程价值:

推论说明工程含义
与数据无关不管训练集多大、划分怎么切、有多少噪声"换数据再验证一次"对它无效
与模型无关线性回归、带正则的回归(在 alpha→0 时)、树模型都会拟合到饱和"换个模型试试"也无效
与 a、c 无关a = 1 时系数 1、a = 2 时系数 0.5不能靠"系数是不是 1"来判定

★ 第三个推论尤其重要,因为它否掉了一个常见的检查方法:

text
"我看系数不是 1,所以应该不是恒等式"   ❌ 错
     ↓
只要 a ≠ 1,系数就不是 1(而是 1/a),
但它仍然是标签的仿射函数,仍然是泄漏
     ↓
正确的检查不是"系数等不等于 1",而是:
    ① 残差是不是浮点零(MAE ~ 1e-16)
    ② 这一列能不能【不依赖标签】算出来
    ③ max|coef| 是不是远大于其他列(单列独大)

★ 与之对应的"漏报清单":

text
下面这些列都是"标签的仿射函数",但 R² 不一定是 1、系数不一定是 1:
    · 标签减去退役线:soh − 0.80          → 系数 1,本数据实测
    · 标签的百分比形式:soh / 1.0 × 100    → 系数 0.01
    · 标签的对数形式:log(soh)            → 线性模型 R² < 1(非线性变换)
    · 标签的移动平均:rolling(y).mean()    → R² 高但不为 1(因为混入了别的行)
    · 标签的排名:rank(y)                 → 线性模型 R² < 1(单调但非线性)
     ↓
关键不是"它长什么样",而是"它的计算里有没有用到 y"

★ 这也是为什么第 20 章要给三种家族:soh_margin 只是家族①里最极端的一种形态。

21.6 把本章变成三条可自动化的体检 ​

这三条体检都可以挂在训练脚本的末尾,每次训练自动打印。

体检一:系数体检(最便宜) ​

真实输出:

text
⑤ 工程动作:把这一章变成三条可自动化的体检
  体检一(便宜、必做):训练完把所有线性系数打印出来,看最大 |系数|。
      对照组最大 |系数| = 0.5556(量级正常)
      泄漏组最大 |系数| = 0.9999(并且它对应的是 soh_margin)
      规则:某一列系数 ≈ 1 且其余列系数 ≈ 0 → 立刻停手,去看那一列是怎么算的。
python
c = np.abs(model.coef_)
top1 = c.max() / c.sum()          # 单列占全部 |系数| 的比例
print(f"max|coef| = {c.max():.4f}   单列占比 = {top1:.1%}")

★ 本数据的两组取值:

配置max|coef|单列占比
干净 7 列0.555679.9%
带钩子 8 列0.999998.9%
text
单列占比 98.9% 意味着:模型把几乎所有决策权重都放在了一列上
     ↓
正常的多特征模型不会这样(除非那一列真的携带了绝大部分信息)
     ↓
这条检查不需要标签、不需要测试集,训练完立刻能算 —— 成本接近零

体检二:MAE 量级体检(便宜) ​

真实输出:

text
  体检二(便宜、必做):看 MAE 的量级。
      正常 MAE ≈ 1e-2(0.010820)
      泄漏 MAE ≈ 1e-6(2.100e-06,tol=0 时 2.228e-16)
      规则:MAE 比正常水平小三个数量级以上,先怀疑恒等式或重复行。

★ 为什么用"相对量"而不是绝对值?

text
"MAE < 1e-6 就是泄漏"        ❌ 不可移植:标签尺度不同,阈值就不同
"MAE 比历史最好水平小 1000 倍" ✅ 可移植:它衡量的是"误差消失"这个异常现象
     ↓
本数据:MAE 从 1.08e-2 掉到 2.1e-6,缩小 5,153 倍 —— 触发

体检三:标签打乱体检(贵一点,但能抓未知的坑) ​

真实输出:

text
  体检三(贵一点,但能抓住未知的坑):把标签打乱再跑一次。
      规则:如果打乱标签后分数不塌,说明评估管路里有一条『不依赖标签』的捷径;
            如果打乱后分数照旧很高(比如还是 0.99),那更糟 —— 去看 y 有没有传错。
python
y_shuf = y.sample(frac=1.0, random_state=0).reset_index(drop=True)
m_shuf = LinearRegression().fit(X_tr, y_shuf_tr)
print(f"打乱标签后 R² = {m_shuf.score(X_te, y_shuf_te):.5f}")   # 本数据:-0.00066

★ 这个实验能抓到三类问题:

打乱标签后的现象说明
分数崩塌到 ~0 或负✅ 正常:模型确实在学 y 与 X 的关系
分数仍然很高(如 0.99)❌ 泄漏:评估里有一条不依赖标签的捷径(比如索引对齐错误)
分数完全不变(一模一样)❌ 严重 bug:y 根本没传对(模型在预测常数或另一个量)

多一列 soh_margin,点全部落到对角线上;系数只有一列不是 0

图的读法:

  • 左图:横轴实测 soh、纵轴预测 soh。蓝点是 7 列线性(散布在对角线两侧,R² = 0.94253); 红点是 8 列(+soh_margin),全部精确落在对角线上(R² = 1.000000)。 虚线是 y = x 的理想线。
  • 右图:8 列线性回归的系数条形图。 soh_margin 的系数是 0.9999,其余 7 列全部贴着 0 —— 一眼就能看出"模型只用了一列"。

21.7 常见坑 ​

text
① 看到 R² = 1.000000 先高兴                   → 先怀疑,再查
② 只看 R²,不看 coef_                         → 系数表一眼就能定案
③ 以为"系数不是 1 就不是恒等式"                → h = 2y 时间样是恒等式,系数是 0.5
④ 用"换个模型/换个种子"验证                    → 代数结论与模型、数据无关,验证无效
⑤ 把 2.1e-6 的 MAE 当成"精度极高"             → 它是 tol 截断残留;tol=0 时是 1e-16
⑥ 只删掉这一列,不在账本里记录原因              → 下一个人会把它加回来(业务解释很动听)
⑦ 用"业务上说得通"替这一列背书                  → soh_margin 的业务名字叫"距退役线余量"

★ 第 ④ 条值得再强调一次,因为它是很多人"验证"泄漏的方式:

text
"我换了 random_state,R² 还是 1.000000,所以应该没问题"   ❌
     ↓
恒等式与随机种子无关 —— 换 100 个种子都会是 1.000000
     ↓
要验证"它是不是恒等式",正确的动作是看 coef_ / 看 MAE / 看那一列的定义

★ 第 ⑦ 条是本章最需要被记住的:

text
soh_margin 的业务含义是"距退役线的余量(0.80)还有多少"
     ↓
这个解释听起来完全合理 —— 甚至像是"领域知识驱动的特征工程"
     ↓
但它要求你先知道 soh。而 soh 就是你要预测的东西。
     ↓
**所有"由标签推导出来的业务指标",都长得非常合理。**
这就是为什么不能靠直觉筛查,要靠流程(第 22 章)。

21.8 完整示例与输出 ​

bash
cd tools/feature-engineering-pipeline/code
MPLCONFIGDIR=/tmp/fe-mpl /Users/jini/Projects/MyBooks/Basic-Tutorials/.pytmp/venv/bin/python \
    demos/ch21-perfect-score.py

真实输出(节选):

text
① 对照组:7 列基础特征 + 线性回归(按设备划分)
  训练 R² = 0.94296   测试 R² = 0.94253   MAE = 0.010820
  |系数| 最大 = 0.5556(对应 cap_fade_rate_ah_per_100)   截距 = 1.3436

② 只加一列 soh_margin:训练 1.000000,测试 1.000000
  测试 MAE = 2.100e-06
  测试集上『预测值 − 实测值』的最大绝对值 = 9.05e-06

③ 用 coef_ 与 intercept_ 反推真相
    soh_margin                  +0.99988176   ← 只有它是非零
    截距                          +0.78917045
  → soh = 1.0000 × soh_margin + 0.8000 = soh

  ③-1 max |soh_margin − (soh − 0.80)| = 1.11e-16
  ③-2 单列 soh_margin:系数 = 1.0000000000   截距 = 0.8000000000   测试 MAE = 1.167e-16
  ③-3 tol=1e-06  coef = 0.9998817630   截距 = 0.7891704538   rank_ = 7/8   MAE = 2.100e-06
       tol=0      coef = 1.0000000000   截距 = 0.8000000000   rank_ = 8/8   MAE = 2.228e-16

④ 三组反证
  ④-1 打乱标签    测试 R² = -0.00066   coef = -0.04040
  ④-2 换成噪声    测试 R² = 0.94253(7 列对照 = 0.94253)
  ④-3 2 × 钩子    系数 = 0.500000   截距 = 0.800000   测试 R² = 1.000000

本章练习 ​

练习 1:复现 0.94253 → 1.000000,并打印 8 列系数
  1. 用 7 列基础特征 + 线性回归(按设备划分),打印训练 R²、测试 R²、max|coef|;
  2. 加上 soh_margin 再跑一次,打印训练 R²、测试 R²、测试 MAE、max|coef|;
  3. 打印 8 列的全部系数与截距;
  4. 用两句话说明:从这张系数表里,你怎么判断"模型只学了这一列"?
参考答案
python
import numpy as np, pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error, r2_score
from sklearn.model_selection import GroupShuffleSplit

BASE = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
        "coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]
df = pd.read_csv("data/battery/soh_features_v2.csv")
y = df["soh"]
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
              .split(df[BASE], y, df["cell_id"]))

m7 = LinearRegression().fit(df[BASE].iloc[tr], y.iloc[tr])
print(f"7 列   训练 R² = {m7.score(df[BASE].iloc[tr], y.iloc[tr]):.5f}   "
      f"测试 R² = {r2_score(y.iloc[te], m7.predict(df[BASE].iloc[te])):.5f}   "
      f"max|coef| = {np.abs(m7.coef_).max():.4f}")

feats8 = BASE + ["soh_margin"]
m8 = LinearRegression().fit(df[feats8].iloc[tr], y.iloc[tr])
pred = m8.predict(df[feats8].iloc[te])
print(f"8 列   训练 R² = {m8.score(df[feats8].iloc[tr], y.iloc[tr]):.6f}   "
      f"测试 R² = {r2_score(y.iloc[te], pred):.6f}   "
      f"测试 MAE = {mean_absolute_error(y.iloc[te], pred):.3e}   "
      f"max|coef| = {np.abs(m8.coef_).max():.4f}")
print()
for c, v in zip(feats8, m8.coef_):
    print(f"  {c:<28s}{v:+.8f}")
print(f"  {'截距':<28s}{m8.intercept_:+.8f}")

期望输出:

text
7 列   训练 R² = 0.94296   测试 R² = 0.94253   max|coef| = 0.5556
8 列   训练 R² = 1.000000   测试 R² = 1.000000   测试 MAE = 2.100e-06   max|coef| = 0.9999

  cycle                       +0.00000001
  mean_charge_voltage_v       -0.00000093
  mean_temp_rise_c            -0.00000006
  resistance_mohm             +0.00000015
  coulombic_eff               +0.01087291
  cap_fade_rate_ah_per_100    +0.00005252
  temp_std_c                  +0.00000267
  soh_margin                  +0.99988176
  截距                          +0.78917045

★ 第 4 问的答案

text
① 横向比较:soh_margin 的系数是 0.99988176,其余 7 列全是 0.0000xxxx 量级 ——
   只有一列不是零。

② 纵向比较:同一批 7 列在"7 列对照"里的 max|coef| 是 0.5556,
   加进 soh_margin 之后,它们全部塌到 1e-4 以下 ——
   模型主动放弃了它们。
     ↓
"一列独大 + 其余归零" 就是"模型只学了这一列"的直接证据。
练习 2:拿到精确的"系数 1、截距 0.80",并手工验算残差
  1. 只用一个 soh_margin 做线性回归,打印系数、截距、测试 R²、测试 MAE、最大绝对误差;
  2. 用 8 列做两次回归:tol=1e-6(默认)与 tol=0,打印各自的 coef(soh_margin)、截距、rank_、测试 MAE;
  3. 打印 max |soh_margin − (soh − 0.80)|;
  4. 用两句话说明:tol 在这里改变了什么?它能不能替泄漏开脱?
参考答案
python
import numpy as np, pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error, r2_score
from sklearn.model_selection import GroupShuffleSplit

BASE = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
        "coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]
df = pd.read_csv("data/battery/soh_features_v2.csv")
y = df["soh"]
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
              .split(df[BASE + ["soh_margin"]], y, df["cell_id"]))

m1 = LinearRegression().fit(df[["soh_margin"]].iloc[tr], y.iloc[tr])
pred1 = m1.predict(df[["soh_margin"]].iloc[te])
print(f"单列:系数 = {m1.coef_[0]:.10f}  截距 = {m1.intercept_:.10f}  "
      f"测试 R² = {r2_score(y.iloc[te], pred1):.10f}")
print(f"      测试 MAE = {mean_absolute_error(y.iloc[te], pred1):.3e}  "
      f"最大绝对误差 = {np.abs(pred1 - y.iloc[te].to_numpy()).max():.3e}")

for tol in (1e-6, 0.0):
    m = LinearRegression(tol=tol).fit(df[BASE + ["soh_margin"]].iloc[tr], y.iloc[tr])
    pr = m.predict(df[BASE + ["soh_margin"]].iloc[te])
    print(f"8 列 tol={tol:<7g}:coef = {m.coef_[-1]:.10f}  截距 = {m.intercept_:.10f}  "
          f"rank_ = {m.rank_}/8  MAE = {mean_absolute_error(y.iloc[te], pr):.3e}")

print(f"\nmax |soh_margin − (soh − 0.80)| = "
      f"{(df['soh_margin'] - (df['soh'] - 0.80)).abs().max():.2e}")

期望输出:

text
单列:系数 = 1.0000000000  截距 = 0.8000000000  测试 R² = 1.0000000000
      测试 MAE = 1.167e-16  最大绝对误差 = 3.331e-16
8 列 tol=1e-06  :coef = 0.9998817630  截距 = 0.7891704538  rank_ = 7/8  MAE = 2.100e-06
8 列 tol=0      :coef = 1.0000000000  截距 = 0.8000000000  rank_ = 8/8  MAE = 2.228e-16

max |soh_margin − (soh − 0.80)| = 1.11e-16

★ 第 4 问的答案

text
tol 改变的是【数值求解的截断】:
7 列基础特征量纲差异大(cycle ~ 10²、coulombic_eff ~ 10⁰),
设计矩阵条件数大,默认 tol=1e-6 把最小奇异值判为 0 → rank_ 从 8 掉到 7,
于是精确解(1, 0.80)被牺牲成(0.99988, 0.78917),MAE 从 1e-16 抬到 2e-6。
     ↓
但它不能替泄漏开脱:
    · 泄漏的判定依据是"这一列用到了标签",与 tol 无关
    · 反过来,tol=0 之后残差降到 1e-16,恰好【加强】了"这是恒等式"的结论
     ↓
一个方便的记忆:tol 只影响"我们把这个恒等式解得有多精确",
不影响"它是不是恒等式"
练习 3:三组反证
  1. 把标签随机打乱后拟合 8 列,打印测试 R² 与 coef(soh_margin);
  2. 把 soh_margin 换成随机噪声,打印测试 R²,并与 7 列对照比较;
  3. 对 1.0 × / 2.0 × / 0.5 × soh_margin 三个版本(tol=0)打印系数、截距、测试 R²;
  4. 用一句话说明第 3 步的规律。
参考答案
python
import numpy as np, pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.metrics import r2_score
from sklearn.model_selection import GroupShuffleSplit

BASE = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
        "coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]
df = pd.read_csv("data/battery/soh_features_v2.csv")
y = df["soh"]
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
              .split(df[BASE + ["soh_margin"]], y, df["cell_id"]))
feats8 = BASE + ["soh_margin"]

y_shuf = y.sample(frac=1.0, random_state=0).reset_index(drop=True)
m_shuf = LinearRegression().fit(df[feats8].iloc[tr], y_shuf.iloc[tr])
print(f"① 打乱标签    测试 R² = "
      f"{r2_score(y_shuf.iloc[te], m_shuf.predict(df[feats8].iloc[te])):.5f}   "
      f"coef = {m_shuf.coef_[-1]:+.5f}")

rng = np.random.RandomState(0)
Xn = df[BASE].copy()
Xn["h"] = rng.normal(size=len(df))
m_n = LinearRegression().fit(Xn.iloc[tr], y.iloc[tr])
print(f"② 换成噪声    测试 R² = {r2_score(y.iloc[te], m_n.predict(Xn.iloc[te])):.5f}")
m7 = LinearRegression().fit(df[BASE].iloc[tr], y.iloc[tr])
print(f"   (7 列对照 = {r2_score(y.iloc[te], m7.predict(df[BASE].iloc[te])):.5f})")

print("③ 钩子乘 a:")
for name, col in [("1.0 × soh_margin", df["soh_margin"]),
                  ("2.0 × soh_margin", 2 * df["soh_margin"]),
                  ("0.5 × soh_margin", 0.5 * df["soh_margin"])]:
    Xh = df[BASE].copy()
    Xh["h"] = col
    mh = LinearRegression(tol=0).fit(Xh.iloc[tr], y.iloc[tr])
    print(f"     {name:<20}系数 = {mh.coef_[-1]:.6f}   截距 = {mh.intercept_:.6f}   "
          f"测试 R² = {r2_score(y.iloc[te], mh.predict(Xh.iloc[te])):.6f}")

期望输出:

text
① 打乱标签    测试 R² = -0.00066   coef = -0.04040
② 换成噪声    测试 R² = 0.94253
   (7 列对照 = 0.94253)
③ 钩子乘 a:
     1.0 × soh_margin    系数 = 1.000000   截距 = 0.800000   测试 R² = 1.000000
     2.0 × soh_margin    系数 = 0.500000   截距 = 0.800000   测试 R² = 1.000000
     0.5 × soh_margin    系数 = 2.000000   截距 = 0.800000   测试 R² = 1.000000

★ 第 4 问的答案

text
新列 = a × soh_margin 时,学到的系数 = 1/a(截距仍是 0.80),R² 恒为 1。
     ↓
模型做的事永远是"把这个新列线性还原成 soh":
     ŷ = (1/a)·(a·soh_margin) + 0.80 = soh_margin + 0.80 = soh
     ↓
所以"系数是不是 1"不能当判据 —— 判据是"这一列的计算里有没有用到标签"。

面向工程实践 ​

场景怎么用
训练脚本结尾固定打印 max|coef|、单列占比、MAE、以及 |r| > 0.999 的列
模型评审要求提供系数表;出现"一列独大 + 其余归零"必须有解释
异常排查R² ≥ 0.9995 或 MAE 比历史小 1000 倍 → 按本章三条体检逐个跑
特征准入任何"由标签派生的业务指标"(余量、达标率、健康分)一律不许入模
教学/复盘本章是团队内最好的一次"反面示范":亲手做出 1.000000 再拆掉它
写文档把本章的恒等式结论写进规范:"特征不得是标签的仿射函数或其函数"

★ 一句话总结本章:

R² = 1.000000 不是成果,是事故。 它衡量的不是模型有多强,而是我们有多不小心 —— 一个恒等式被喂进模型,模型就只能把它原样还给你。

下一章:第 22 章 · 防泄漏检查清单