Skip to content

第 06 章 · 最小线性回归:给退化画一条直线 ​

这一章要解决什么:把第 03 章手算的那条直线,交给 fit() 去算, 然后搞清它交给你的四个数字:coef_、intercept_、R²、以及反解出来的退役点。

最容易踩的坑: ① 拿 coef_ 的大小判断"哪个特征重要" —— 换个单位它就差 1000 倍; ② 以为"R² 最高的特征就是最好的特征" —— 这一章的排名会给你一个意外答案; ③ 把 intercept_ 当成有物理意义的数 —— 它是 cycle=0 的外推,而数据里没有这一行。

本章你需要完成的代码:demos/ch06-min-linear-regression.py

6.1 fit() 到底做了什么 ​

先看代码,只有三行:

python
from sklearn.linear_model import LinearRegression

model = LinearRegression().fit(df[["cycle"]], df["soh"])

这三行做完的事,用一句话说:

在所有可能的直线里,挑一条"平方误差和最小"的。

"所有可能的直线"有多少条? 无穷多条(斜率随便取、截距随便取)。 "平方误差和最小"的那一条只有一个,而且有公式可以直接算出来,不用迭代。

真实输出:

text
  model.coef_       = [-0.0001462]    ← 形状 (1,)
  model.intercept_  = 0.996891
  model.n_features_in_ = 1   (它记住了你喂了几个特征)
  预测公式:soh ≈ -0.00014620 × cycle + 0.996891
  翻译成人话:每多循环 100 圈,健康度平均下降 1.462 个百分点
  数据里 cycle 的范围:50 ~ 1440

★ fit() 的产物就是两个数字。第 03 章手算的 k 和 b,这里叫 coef_ 和 intercept_。

第 03 章的名字sklearn 的名字形状含义
k(斜率)model.coef_(1,)每单位特征 → 标签的变化
b(截距)model.intercept_() 标量特征为 0 时的预测值

★ 注意 coef_ 是数组,intercept_ 是标量:

python
model.coef_          # array([-0.0001462])   ← 一个数组,哪怕只有 1 个特征
model.coef_[0]       # -0.0001462            ← 取值要下标
model.intercept_     # 0.996891              ← 直接就是数字

为什么 coef_ 是数组:因为特征可以有多个。 7 个特征时 coef_.shape 就是 (7,) —— 每个特征一个权重(第 13 章)。

点开:为什么"平方"而不是"绝对值"?(这一步影响后面所有指标)

"误差最小"有很多种量化方式:

① 误差的绝对值和      Σ|x - 预测|        → 最小化它叫"最小绝对偏差"
② 误差的平方和        Σ(x - 预测)²       → 最小化它叫"最小二乘"     ← sklearn 用的
③ 误差的最大绝对值    max|x - 预测|       → 最小化它叫"极小化极大"

为什么选 ②:

① 绝对值:在"误差 = 0"那个点不可导(尖角),没有解析解,要迭代
② 平方  :处处可导,能直接解出一个公式   ← 这是关键优势
③ 最大值:完全被一个最差的点决定,非常不稳

★ "平方"带来的两个副作用,你要提前知道:

副作用一:大误差的权重被放大
   误差 2 和 误差 1 → 平方之后是 4 和 1(差 4 倍,不是 2 倍)
   ↓
   所以最小二乘"特别怕离群点"
   ↓
   一个错得离谱的点,能把整条直线拉偏

副作用二:量纲被平方
   标签单位是 soh(无量纲),平方之后是 soh²
   ↓
   MSE 没法直接读(第 08 章的大主题)

★ 平方误差的"最小点"有一个漂亮的几何解释

把每个样本看成高维空间里的一个点, "平方误差和最小"等价于"把标签向量垂直投影到特征张成的平面上"。

投影的残差 ⟂ 特征平面
   ↓
这条垂直关系直接给出公式:β = (XᵀX)⁻¹Xᵀy
   ↓
这就是 LinearRegression 内部在算的东西(实际用更稳的 SVD 解法)

所以 LinearRegression 没有"迭代次数"、没有"学习率"、也没有"随机种子":

python
LinearRegression().fit(X, y)     # 每次都一样,完全确定
# 对比:
# RandomForestRegressor(random_state=0)   ← 有随机性,要固定种子

★ 这条差异在第 09~12 章做对照实验时很重要:线性模型的分数是确定的, 所以"两次跑出来不一样"一定是数据或代码变了,不是模型在抖。

6.2 coef_ 的量纲,和你以为的不一样 ​

coef_ = -0.0001462 这个数字,本身什么也不是。

-0.0001462 是什么意思?
   ↓
"cycle 每 +1,soh 变化 -0.0001462"
   ↓
翻译成人话:每 100 圈,健康度下降 1.462 个百分点

代码里的翻译:

python
print(f"每多循环 100 圈,健康度平均下降 {abs(k) * 100 * 100:.3f} 个百分点")

× 100 × 100 的两步(第 03 章出现过,这里再走一遍):

text
k = -0.0001462      (每个 cycle 的 soh 变化)
    × 100            → 每 100 圈的变化:-0.01462
    × 100            → 变成"百分点":1.462 个百分点

★ 纪律(第 03 章立下的):

任何从模型里取出的数字,都要问一句"它的量纲是什么、说成人话是什么"。

这条纪律在 intercept_ 上更要小心:

真实输出:

text
  ★ 截距 0.996891 是 cycle=0 时的预测值 —— 数据里【没有 0 圈这一行】,
     它是一个外推出来的数字,读的时候要知道这一点。

为什么这是问题:

数据里最早的记录是 cycle = 50
   ↓
intercept_ = 0.996891 说的是"第 0 圈时 soh 是 0.996891"
   ↓
第 0 圈这个点,一个样本都没有
   ↓
它是一条直线的数学性质(y 轴截距),不是一条测量结论

⚠️ 危险的不是"截距没意义",而是"截距看起来很有意义":

text
"模型说设备出厂时健康度是 99.69%"
   ↓
听起来非常合理(新设备本来就该接近 100%)
   ↓
于是有人把它写进报告,当作"模型的合理性验证"
   ↓
但它只是"把直线往左延长到 0"的结果 —— 如果直线稍微转一点,
   这个数字可以变成 0.98 或 1.01,而数据完全支持

★ 一个更狠的说法:

intercept_ 是模型给你的、关于"你没见过的数据"的第一个预测。 它没有错,但它也没有任何数据支持。

这和预测 cycle = 3000 是同一类问题(第 03 章练习 2), 只是它在每次 fit() 之后都白送给你,所以最容易被忽略。

点开:什么时候 intercept_ 是重要的,什么时候可以不管

分三种情况:

① 特征的 0 点在物理上是"正常范围" → 截距有意义

用"月收入"预测"消费额":收入 = 0 是一个真实存在的人群
   ↓
截距 = "零收入时的基础消费"(比如靠储蓄/家庭支持的部分)
   ↓
有解释价值

② 特征的 0 点离数据范围很远 → 截距是纯外推

本书:cycle 从 50 开始,预测 cycle = 0
   ↓
不要解释它,也不要引用它

③ 只想比较两个特征的系数大小 → 截距无关

"cycle 和 resistance 哪个影响更大"
   ↓
看的是 coef_,intercept_ 完全不参与

★ 一个实用的检查(两行代码):

python
x_min = df[FEATS].min()
print(x_min)
# 如果某个特征的最小值离 0 很远(比如 cycle 最小 50、resistance 最小 30+),
# 那么对应的 intercept_ 就是外推,别解释

★ 进阶:想让截距"有意义",可以先把特征中心化

python
X_centered = df[["cycle"]] - df[["cycle"]].mean()
model = LinearRegression().fit(X_centered, df["soh"])

这时截距的含义变成了"在平均 cycle 处的预测值" —— 一个数据里有、而且很稳的量。

中心化之前:intercept_ = 0.996891   (cycle=0 的外推)
中心化之后:intercept_ = 0.908760   (cycle 均值的预测,正好等于 y 的均值)

注意后一个数字:中心化之后,截距恰好等于标签的均值 0.908760。 这不是巧合 —— 最小二乘的残差和恒为 0,所以回归线必然过均值点。

第 15 章讲标准化(StandardScaler)时会用到这个性质, 它是"缩放不改变 R²"的原因。

6.3 R² 的直觉:用均值预测做基准 ​

model.score(X, y) 返回什么? 一个 0~1 之间的数(一般是)。

它的定义只有一行,而且必须记住:

text
             模型的平方误差
R² = 1 -  ─────────────────────
           用均值预测的平方误差

真实输出:

text
  标签的均值 = 0.908760   标准差 = 0.054837
  基准模型(永远答 0.9088)的 MSE = 0.00300708
  直线模型的 MSE                    = 0.00049689
  手算 R² = 1 - 0.00049689 / 0.00300708 = 0.834759
  手算 R² 与 model.score() 一致吗 : True
  (用平方和也一样:1 - 10.5535 / 63.8674 = 0.834759)

★ 把 R² 读成一句话:

"我这个模型,比'什么都不学、一律答平均值'强多少?"

R² = 0.8348  →  模型的平方误差只有基准的 16.5%
             →  或者说:消掉了 83.5% 的平方误差

★ 这里有两个必须分清的"误差":

名字是什么代码
分子:模型的平方误差直线预测错多少((y - model.predict(X)) ** 2).mean()
分母:均值的平方误差一律答均值错多少((y - y.mean()) ** 2).mean()

而分母恰好就是标签的方差:

python
((y - y.mean()) ** 2).mean() == y.var(ddof=0)     # True

★ 由此得到两个重要推论:

text
推论一:R² 的分母是"这份数据的标签方差"
   → 标签方差小,分母小,R² 天然难看
   → 【R² 不能跨数据集比较】(第 08 章专门讲)

推论二:R² = 0 意味着"和均值一样差",R² < 0 是可能的
   → 模型比均值还差(第 08 章会亲手造一个负数)
点开:R² 的四个等价说法(哪个场合用哪个)

同一件事,四种说法:

说法公式 / 表达什么时候用
① 相对提升1 - MSE模型 / MSE均值解释给业务方:比"一律报平均"少犯错 83%
② 方差解释比例1 - Var(残差) / Var(标签)统计课、论文
③ 相关系数的平方R² = r²(单特征时)快速心算:看到 r = -0.9137 就知道 R² ≈ 0.835
④ 平方误差的减少比例(SS_tot - SS_res) / SS_tot手算验证

★ 说法 ③ 值得单独记,因为它能省很多时间:

python
# 单特征线性回归的 R² 恰好等于皮尔逊相关系数的平方
r = df[["cycle", "soh"]].corr().iloc[0, 1]      # -0.9137
print(r ** 2)                                    # 0.8348  ← 就是 R²

这不是巧合,是恒等式。所以:

看到一个特征的 r,你就能立刻估出它单打独斗的 R²
   ↓
第 04 章那张相关性表,其实已经是一张"单特征 R² 排行榜"的平方根

⚠️ 但注意:这个恒等式**只在"单特征 + 有截距 + 最小二乘"**时成立。 多特征时 R² ≠ 各特征 r² 之和(第 13 章会看到多特征是 0.94321, 而不是 7 个单特征 R² 的加总)。

★ 说法 ④ 的手算代码(用来验证你理解对了):

python
pred = model.predict(X)
ss_res = ((y - pred) ** 2).sum()
ss_tot = ((y - y.mean()) ** 2).sum()
print(1 - ss_res / ss_tot)      # 0.834759
print(model.score(X, y))        # 0.834759

两个数必须一模一样。如果不一样,先检查你用的是 ddof=0 还是 ddof=1:

python
y.var()          # pandas 默认 ddof=1(样本方差)
y.var(ddof=0)    # 总体方差 —— R² 的分母用的是这个

★ 一个 7.78e-06 的差别(第 08 章练习 1 会实测):

用 ddof=0 算 R² → 0.83475876   ← 和 sklearn 一致
用 ddof=1 算 R² → 0.83476654   ← 差 0.0000078

差别很小,但它提醒你:"指标的实现要对齐定义" —— 这也是为什么本书要求"和 model.score() 对照一遍"。

6.4 换特征对比:直觉 vs 数据 ​

现在做一件很自然的事:把 7 个特征各自单独拿来拟合一次,看看谁最强。

真实输出:

text
  特征                               单特征 R²          coef_         r
  coulombic_eff                  0.937603         49.727    0.9683
  cycle                          0.834759   -0.000146198   -0.9137
  resistance_mohm                0.771506    -0.00442195   -0.8784
  mean_charge_voltage_v          0.640406      -0.514707   -0.8003
  temp_std_c                     0.619120      -0.312879   -0.7868
  mean_temp_rise_c               0.561186     -0.0109579   -0.7491
  cap_fade_rate_ah_per_100       0.181253       -1.76753   -0.4257
  ★ 直觉上最该有用的 cycle 排第 2 名(R² = 0.834759)
    实测第一是 coulombic_eff(R² = 0.937603)

★ 这里有两个"意外",第二个更重要:

意外一:cycle 不是第一名。

"健康度随使用次数下降"  —— 这是最直观的物理直觉
   ↓
cycle 的 R² = 0.8348,不错,但不是最好
   ↓
库仑效率(coulombic_eff)的 R² = 0.9376,高了 0.10

意外二(真正的坑):如果就此下结论"coulombic_eff 是最好的特征",你会错。

为什么:

① 它是【整表】R² —— 也就是"训练分数",不是泛化分数
      同一批数据既训练又评分,天生偏乐观
      (第 09 章按设备划分之后,cycle 单特征掉到 0.82070)

② 它只有 47 个不同取值
      而 cycle 有 464 个、resistance 有 20,639 个
   ↓
      47 个取值意味着"它本来就只能在很窄的几个水平上跳"
   ↓
      这更像"当前状态的一个省事代理量",
      而不是"能指导改进的物理量"(第 20 章讲特征重要性时会回来)

★ 所以正确的说法是这样:

"在这份数据的整表评估里,库仑效率的单特征 R² 是 0.9376,", "比 cycle 的 0.8348 高 0.10。但这两个数都是训练分数, 而且库仑效率只有 47 个不同取值 —— 在下结论之前要先按设备划分重测。"

这句话比"coulombic_eff 最好"长得多,但它才是能支撑决策的说法。 (第 02 章讲的"给数字加限定词"。)

点开:为什么"最相关的特征"不一定是"最好用的特征"?

先给一个让人安心的结论:

单特征线性回归:R² = r²     (恒等式,前面说过)
   ↓
所以这张 R² 排行榜,就是第 04 章那张相关性表的平方
   ↓
【在单特征层面】,相关性排序 = R² 排序,没有意外

真正的意外发生在两个地方:

① 单特征强 ≠ 多特征里重要

两个特征都强,而且它们高度相关(比如都用同样的传感器测同一个东西)
   ↓
放进同一个模型时,它们会"抢功劳"
   ↓
coef_ 可能一个很大、一个接近 0,甚至符号反转
   ↓
第 14 章专讲这件事(多重共线性)

② 相关性只测"单调的线性关系"

皮尔逊相关系数 r 测的是"线性关系的强度"
   ↓
如果真实关系是 U 形的(先降后升)
   ↓
线性相关系数可能是 0,于是这个特征在排行榜上垫底
   ↓
但它的信息量其实很大(只是线性模型用不上)

★ 一张速查表:相关性 vs 有用性

现象相关性在模型里有用吗
强线性关系|r| 大✅ 有用
U 形关系|r| ≈ 0⚠️ 线性模型用不上,树模型能用(第 16 章)
两个特征高度相关(相关系数 0.95 以上)各自都大⚠️ 会互相抢,系数不稳
与标签是函数关系相关系数 0.99 以上❌ 先怀疑泄漏(第 04 章)
取值种类极少|r| 可能很大⚠️ 像代理量,问它"能指导什么动作"

★ 一个可操作的问题:

"如果这个特征的数值变了,我能不能做点什么?"

库仑效率低 → 我能怎么调设备?(不太行,它是结果)
温度过高   → 我可以改散热    (可以,它是原因)
   ↓
"能不能指导动作"比"R² 高不高"更接近业务价值

这也是第 20 章"特征重要性之后要做什么"的伏笔。

6.5 ★ 对照:coef_ 大就是重要吗? ​

这是一个几乎人人都犯过的错。这一节亲手拆掉它。

真实输出:

text
  用「每 1 圈」做特征:coef_ = -0.00014620   R² = 0.834759
  用「每千圈」做特征:coef_ = -0.14619844   R² = 0.834759
  coef_ 差了 1000 倍,R² 一模一样:
    True
  ★ 所以「coef_ 大 = 这个特征重要」是错的。要比重要性必须先统一量纲(第 14~15 章)。

看这段代码就明白了:

python
# 版本 A:特征单位是"圈"
df_a = df[["cycle"]]
# coef_ = -0.00014620

# 版本 B:特征单位是"千圈" —— 只改了一个单位
df_b = pd.DataFrame({"cycle_k": df["cycle"] / 1000.0})
# coef_ = -0.14619844        ← 大了 1000 倍

两个模型是同一个模型:

text
版本 A:soh = -0.00014620 × cycle + 0.996891
版本 B:soh = -0.14619844 × (cycle / 1000) + 0.996891
              ↑ 同一个式子,只是写法不同
   ↓
预测值完全相同:True
R² 完全相同:0.834759 == 0.834759

★ 结论:

coef_ 的数值大小 = "物理影响" × "单位大小"。 它丈量的是单位,不是重要性。

一个更扎眼的例子:

如果把 cycle 的单位改成"百万圈":coef_ = -146.198
如果把某个特征的单位从"米"改成"毫米":coef_ × 1000

比较系数的正确做法(第 15 章会做):

① 把所有特征先标准化(减均值、除标准差)再拟合
      → 这时 coef_ 的含义变成"特征每变动 1 个标准差,标签变动多少"
      → 单位统一了,大小才可以比
② 或者用"树模型的特征重要性"(第 20 章)
      → 那是另一种定义,不依赖线性假设

★ 顺便记住"什么变了、什么没变":

量换个单位之后
coef_变(和单位成反比)
intercept_不变(特征为 0 时的预测值不受单位影响)
R²不变(无量纲)
MAE / RMSE不变(只和预测误差有关)

这张表本身就是"量纲思维"的练习: 一个量有没有量纲,决定了它能不能跨数据集/跨单位比较。

6.6 反解:直线说什么时候退役 ​

直线的用法有两种:

正向:给 cycle,问 soh        →  model.predict()
反向:给 soh,问 cycle        →  解方程(模型没有这个 API,要自己算)

反向用的场景很实际:"健康度掉到 0.80 的时候,是第几圈?"

真实输出:

text
  直线方程 soh = -0.00014620 × cycle + 0.996891
  令 soh = 0.8,反解 cycle = (0.8 - 0.996891) / -0.00014620 = 1347 圈
  它落在训练范围(50 ~ 1440)之内吗:True
  再对照每支设备的【末次记录】cycle:
    min 752 / 中位 1117 / max 1440
  1347 落在末次记录分布的第 80 百分位
  ★ 也就是说:80% 的设备在 1347 圈之前就已经停止记录了。
    这条直线回答的是「平均什么时候退役」,不是「某一支什么时候退役」。

反解的代码只有一行:

python
x_eol = (0.80 - model.intercept_) / model.coef_[0]

⚠️ 但反解有一个"量纲陷阱",非常值得单独说:

反解出来的数字,单位永远是【那个特征的单位】。

用 cycle 反解        →  1347,单位是"圈"          (看起来像时间)
用 resistance 反解   →  78.28,单位是"mΩ"         (完全不是时间!)

第二条是真的(第 06 章练习 1 会实测):

python
m = LinearRegression().fit(df[["resistance_mohm"]], df["soh"])
# coef_ = -0.00442195, intercept_ = 1.146154
x = (0.80 - 1.146154) / -0.00442195      # 78.28
print(f"{x:.2f}")                        # 78.28

如果把这个 78.28 读成"78 圈退役",你会得到一个荒谬到可笑的结论。 它的实际意思是:"内阻达到 78.28 mΩ 时,健康度约等于 0.80"。

★ 所以反解必须做两件事:

① 写清单位:不是"78.28",而是"78.28 mΩ"
② 确认这个量在你的问题里真的意味着"时间"或"寿命"

这一节的 1347 之所以能读成时间,只是因为特征的物理含义恰好就是"循环次数"。

点开:"平均什么时候退役"和"这一支什么时候退役"差多少?(量化)

这一节的数字已经给出答案的一半,第 07 章会把它全部量化:

池化直线(全体共用):        1347 圈
各支设备自己的末次记录:      752 ~ 1440 圈(中位 1117)
   ↓
1347 落在第 80 百分位
   ↓
"1347" 这个答案,对 80% 的设备来说【说晚了】

用一句话表达这个差距:

"平均退役点是 1347 圈"(正确)
   ↓ 如果被理解成
"每支设备都会在第 1347 圈退役"(错误,而且危险)

★ 为什么这个误解特别容易发生

因为直线上没有任何"个体差异"的痕迹:

python
model.predict(pd.DataFrame({"cycle": [1347]}))
# array([0.8])       ← 它只给一个数,语气非常确定

模型不会说"这是平均" —— 这句话要靠你自己加上去。

★ 一个可操作的改法:把"点"变成"分布"

❌ "这批设备在 1347 圈左右退役"
✅ "这批设备的退役点中位数是 1117 圈,范围 752~1440;
    全表拟合成一条直线会得到 1347 圈,这个数字偏乐观"

第二种说法里,"中位数 + 范围"是数据直接告诉你的(没有模型假设), "1347"是模型给的(有线性假设)。

把两者的来源分开写,读者就知道该信哪个更多一点。

★ 这一节的最后一句,也是第 07 章的主题:

横截面回归回答的是"平均什么时候退役", 而不是"某一支什么时候退役"。

6.7 常见坑 ​

text
① coef_ 大 = 重要          → 换个单位差 1000 倍,R² 却一模一样
② intercept_ 当物理量      → 它是 cycle=0 的外推,数据里没有这一行
③ R² 最高 = 最好的特征     → 那是整表训练分数,而且要问"能不能指导动作"
④ 反解出来的数字不写单位   → 78.28 是 mΩ 不是圈
⑤ 用 y.var() 手算 R²       → pandas 默认 ddof=1,和 sklearn 差 7.78e-06
⑥ 只看 model.score() 就完事 → 它没有告诉你"在哪一段数据上、有没有划分"

★ 其中 ① 和 ③ 是"看起来对但其实错"的典型:

① 在你的笔记本上,coef_ 的数值永远"看起来有道理"
   因为它至少带着正负号,你会不自觉地把它读成"影响力"

③ R² 排行榜永远给你一个明确的冠军
   你会不自觉地把它读成"结论",而它其实是"线索"

6.8 完整示例与输出 ​

bash
cd code
python demos/ch06-min-linear-regression.py

真实输出(节选):

text
=== ① fit 一次,看它到底存下了什么 ===
  model.coef_       = [-0.0001462]    ← 形状 (1,)
  model.intercept_  = 0.996891
  翻译成人话:每多循环 100 圈,健康度平均下降 1.462 个百分点

=== ② R² 的直觉:先造一个「什么都不学」的模型 ===
  基准模型(永远答 0.9088)的 MSE = 0.00300708
  直线模型的 MSE                    = 0.00049689
  手算 R² = 1 - 0.00049689 / 0.00300708 = 0.834759
  手算 R² 与 model.score() 一致吗 : True

=== ③ 换特征:直觉 vs 数据 ===
  coulombic_eff                  0.937603         49.727    0.9683
  cycle                          0.834759   -0.000146198   -0.9137

=== ④ 对照:把同一个特征换个单位,coef_ 会变,R² 不变 ===
  用「每 1 圈」做特征:coef_ = -0.00014620   R² = 0.834759
  用「每千圈」做特征:coef_ = -0.14619844   R² = 0.834759

=== ⑤ 反解:直线说什么时候退役 ===
  令 soh = 0.8,反解 cycle = (0.8 - 0.996891) / -0.00014620 = 1347 圈
    min 752 / 中位 1117 / max 1440
  1347 落在末次记录分布的第 80 百分位

本章练习 ​

练习 1:换个特征,把系数翻译成人话(并小心反解)

用 resistance_mohm 做唯一的特征,拟合 soh:

  1. 打印 coef_、intercept_、R²;
  2. 把 coef_ 翻译成"内阻每增加 1 mΩ,soh 平均变化几个百分点";
  3. 反解 soh = 0.80 对应的 resistance_mohm,并写清它的单位。
参考答案
python
import pandas as pd
from sklearn.linear_model import LinearRegression

df = pd.read_csv("data/battery/soh_features.csv")
y = df["soh"]

m = LinearRegression().fit(df[["resistance_mohm"]], y)
k, b = float(m.coef_[0]), float(m.intercept_)
print(f"soh ≈ {k:.8f} × resistance_mohm + {b:.6f}")
print(f"R² = {m.score(df[['resistance_mohm']], y):.6f}")
print(f"内阻每增加 1 mΩ,soh 平均变化 {k * 100:.4f} 个百分点")
print(f"反解 soh = 0.80:resistance = (0.80 - {b:.6f}) / {k:.8f} = {(0.80 - b) / k:.2f}")

期望输出:

text
soh ≈ -0.00442195 × resistance_mohm + 1.146154
R² = 0.771506
内阻每增加 1 mΩ,soh 平均变化 -0.4422 个百分点
反解 soh = 0.80:resistance = (0.80 - 1.146154) / -0.00442195 = 78.28

★ 第 2 问的答案要带量纲:

内阻每增加 1 mΩ,健康度平均下降 0.4422 个百分点。

★ 第 3 问的答案是一个陷阱:

78.28   ←  这个数字的单位是 mΩ,不是"圈"

它的意思是:"内阻达到 78.28 mΩ 时,这条直线预测 soh = 0.80"。

如果读成"78 圈退役":

78 圈 vs 数据里的末次记录 752~1440 圈
   ↓
差了 10~18 倍
   ↓
但你从 78.28 这个数字本身看不出任何异常

★ 为什么这两个反解差这么多

cycle  →  soh :[50, 1440] → [1.00, 0.80]     跨度为 20 个百分点
resistance → soh:[30, 90] mΩ → [1.00, 0.80]  跨度为 20 个百分点
   ↓
两条直线的斜率都是"每单位特征掉多少 soh"
   ↓
cycle 的单位很小(1 圈),所以 coef_ 很小(-0.000146)
resistance 的单位较大(1 mΩ),所以 coef_ 大 30 倍(-0.00442)
   ↓
反解出来的数字,量纲完全不同,绝对不能混着读

★ 一个"看起来对但其实错"的收尾

"内阻到 78 mΩ 就退役了"     ← 这句话有可能成立(如果 78 是内阻的退役阈值)
"78 圈就退役了"             ← 这句话和数据直接矛盾(数据最少的也有 752 圈)
   ↓
两个说法共用同一个数字 78.28
   ↓
区别只在于你有没有看【特征是什么】

这就是第 03 章那条纪律的第二次实战:

任何从模型里取出的数字,先问量纲,再问含义。

练习 2:换个单位,验证"coef_ 变、R² 不变"
  1. 用 cycle(单位:圈)拟合,记录 coef_ 和 R²;
  2. 用 cycle / 1000(单位:千圈)拟合,记录 coef_ 和 R²;
  3. 验证两次的预测值完全相同;
  4. 用一句话回答:为什么 coef_ 不能用来比较"哪个特征重要"?
参考答案
python
import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression

df = pd.read_csv("data/battery/soh_features.csv")
y = df["soh"]

mA = LinearRegression().fit(df[["cycle"]], y)
df_k = pd.DataFrame({"cycle_k": df["cycle"] / 1000.0})
mB = LinearRegression().fit(df_k, y)

print(f"特征 cycle(圈)    :coef_ = {float(mA.coef_[0]):.8f}   R² = {mA.score(df[['cycle']], y):.10f}")
print(f"特征 cycle_k(千圈) :coef_ = {float(mB.coef_[0]):.8f}   R² = {mB.score(df_k, y):.10f}")
print(f"coef_ 比值 = {float(mB.coef_[0]) / float(mA.coef_[0]):.1f}")
print(f"预测值完全相同:{bool(np.allclose(mA.predict(df[['cycle']]), mB.predict(df_k)))}")

期望输出:

text
特征 cycle(圈)    :coef_ = -0.00014620   R² = 0.8347587555
特征 cycle_k(千圈) :coef_ = -0.14619844   R² = 0.8347587555
coef_ 比值 = 1000.0
预测值完全相同:True

★ 第 4 问的标准答案:

因为 coef_ 的数值 = "物理影响" × "单位的倒数"。 换一个单位,物理影响一点没变,coef_ 却成比例地变了。 所以它丈量的是单位大小,不是特征的重要性。

★ 三个数字一起看,结论最清楚:

量换单位前换单位后变了吗
coef_-0.00014620-0.14619844变了 1000 倍
intercept_0.9968910.996891没变
R²0.83475875550.8347587555没变
预测值逐个相同逐个相同没变

★ 一个"看起来很合理但其实错"的推理

"cycle 的 coef_ 是 -0.000146,resistance 的 coef_ 是 -0.00442
 → resistance 的系数大 30 倍
 → 所以内阻对健康度的影响比循环次数大 30 倍"
                    ↑
              这个推理完全错

正确的说法是:

"内阻每多 1 mΩ,soh 平均少 0.44 个百分点"
"循环次数每多 1 圈,soh 平均少 0.0146 个百分点"
   ↓
两个数字的单位不同(mΩ vs 圈),无法直接比较
   ↓
要比,必须先让两个特征的量纲一致(第 15 章标准化)

★ 想立刻比一下也可以(不改模型的近似做法)

python
# 把两个系数都换成"特征变动 1 个标准差时,标签变动多少"
for name in ("cycle", "resistance_mohm"):
    m = LinearRegression().fit(df[[name]], y)
    beta_std = float(m.coef_[0]) * float(df[name].std())
    print(f"{name:<18s} 每变动 1 个标准差 → soh 变动 {abs(beta_std):.4f}")

这才是可以横向比较的量(第 15 章会给出更规范的版本)。

练习 3:单特征 R² 排名第一的是谁?先别下结论

把 7 个特征各自单独拟合,按 R² 从高到低排一张表,并额外打印每个特征的不同取值个数。然后回答:

  1. 第一名是谁?cycle 排第几?
  2. 为什么不能就此说第一名是"最好的特征"?给出两个理由。
  3. 什么类型的问题才能帮你判断一个特征"值不值得用"?
参考答案
python
import pandas as pd
from sklearn.linear_model import LinearRegression

df = pd.read_csv("data/battery/soh_features.csv")
y = df["soh"]
FEATS = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c",
         "resistance_mohm", "coulombic_eff",
         "cap_fade_rate_ah_per_100", "temp_std_c"]

rows = []
for f in FEATS:
    m = LinearRegression().fit(df[[f]], y)
    rows.append((f, m.score(df[[f]], y), float(m.coef_[0]), int(df[f].nunique())))
board = pd.DataFrame(rows, columns=["特征", "R2", "coef", "不同取值数"]).sort_values("R2", ascending=False)
for _, r in board.iterrows():
    print(f"{r['特征']:<28s} {r['R2']:>10.6f} {r['coef']:>13.6g} {r['不同取值数']:>10d}")

期望输出:

text
coulombic_eff                  0.937603        49.727         47
cycle                          0.834759  -0.000146198        464
resistance_mohm                0.771506   -0.00442195      20639
mean_charge_voltage_v          0.640406     -0.514707       3811
temp_std_c                     0.619120     -0.312879       5685
mean_temp_rise_c               0.561186    -0.0109579      19625
cap_fade_rate_ah_per_100       0.181253      -1.76753        768

① 第一名是 coulombic_eff(R² = 0.937603);cycle 排第 2(R² = 0.834759)。

② 两个理由:

text
理由一:这是【整表】R²,也就是训练分数
   同一批 21,239 行既用来拟合、又用来打分
   → 它衡量的是"拟合得多好",不是"对没见过的设备准不准"
   → 第 09 章按设备划分之后,cycle 单特征从 0.834759 掉到 0.82070

理由二:coulombic_eff 只有 47 个不同取值
   → 它的信息量被"量化"得很粗
   → 它更像"当前状态的一个省事代理量"
   → 换个数据集、换一批设备,未必还能排第一

★ 一个补充观察:coef_ 那一列完全是"单位动物园"

coulombic_eff           49.727        (特征范围 0.9~1.0,所以系数很大)
cycle                   -0.000146     (特征范围 50~1440,所以系数很小)
resistance_mohm         -0.00442

这一列的前后大小关系没有任何意义(练习 2 刚证明过)。 本书后面所有涉及 coef_ 的表格,都会先标准化或者明确说明单位。

③ 能帮你判断"值不值得用"的问题:

"如果这个特征变了,我能做点什么?"

库仑效率低 → 我能怎么调?(它是结果,不是可操作的原因)
温度过高   → 我可以改散热、降倍率   ← 可操作
   ↓
"能不能指导动作"比"R² 高不高"更接近业务价值
   ↓
但如果你的任务只是"监测当前健康度",代理量也可以用 —— 关键是想清用途

★ 一个隐藏的对偶问题:排名最后的就一定没用吗?

cap_fade_rate_ah_per_100  R² = 0.181253   ← 单特征最差
   ↓
但它测的是"衰减速率",物理上和"寿命"直接相关
   ↓
单特征线性 R² 低,只说明"它和 soh 的关系不是线性的"
   ↓
第 16 章的树模型可能会把它用得很好(第 13 章多特征 R² = 0.94321)

★ 所以这张排行榜的正确读法:

它不是"特征好坏排行榜",而是"每个特征单独用一条直线时的解释力排行榜"。它是最便宜的第一次筛选,不是结论。

面向工程实践:把"模型读懂"变成一套固定动作 ​

这一章第一次真的训练了模型。真实项目里,"训练完"只是开始 —— 你必须能把模型里的数字翻译成别人敢用的句子。

① 一份可以抄的"读模型"清单

python
def describe_linear(model, feats, X, y):
    """把一个线性模型读成人话。"""
    print(f"样本数 {len(X):,d},特征数 {len(feats)}")
    for name, c in zip(feats, model.coef_):
        print(f"  {name:<28s} coef_ = {c:+.8g}")
    print(f"  intercept_ = {model.intercept_:.6f}")
    print(f"  R² = {model.score(X, y):.6f}   ← 整表(训练)分数,不是泛化分数")
    print(f"  残差均值 = {(y - model.predict(X)).mean():.3e}  ← 最小二乘的残差和恒为 0")
    print(f"  标签均值 = {y.mean():.6f}  ← R² 的基准")

★ 注意最后两行:

残差均值恒为 0     → 这是最小二乘的数学性质,不是"模型没问题"的证据
标签均值           → 写出来,"R² 是相对谁"这个问题就自动回答了

② 四个必须交叉验证的数字

任何一个线性模型的结果,至少和下面四个对一遍:

① 手算 R²(1 - ss_res/ss_tot)== model.score()
② 反解出来的点是否落在训练范围内
③ 系数翻译成人话之后,符不符合业务常识(符号尤其重要)
④ 有没有"高到不正常"的 R²(> 0.99 → 先查泄漏,第 04、10 章)

第 ③ 条最容易被跳过,但它最值钱:

"内阻上升 → 健康度下降"     符号是负的  ✅ 符合常识
"循环次数上升 → 健康度上升" 符号是正的  ❌ 立刻停下查
   ↓
符号错了通常意味着:特征定义搞错、标签对错列、或者数据里有泄漏

③ 用这个函数读本章那个模型,真实输出

text
样本数 21,239,特征数 1
  cycle                        coef_ = -0.00014619844
  intercept_ = 0.996891
  R² = 0.834759   ← 整表(训练)分数,不是泛化分数
  残差均值 = -4.784e-17  ← 最小二乘的残差和恒为 0
  标签均值 = 0.908760  ← R² 的基准

★ 这份输出里,只有 R² = 0.834759 那一行是"分数", 而且它是整表分数 —— 能对外的版本必须带上"在什么数据上、怎么划分的"。第 09 章会把这个数字换成按设备划分的版本(0.82070), 那才是可以写进报告的形态。

④ 一个可以立刻执行的习惯

每次训练完,先不要看 R²。 先把 coef_ 逐个翻译成"带单位的一句话",再回头看 R²。

原因:

先看 R²:
   R² = 0.94  →  "不错!" → 收工
   ↓
   错失发现"某个系数符号不对"的机会

先翻译系数:
   "内阻每 +1 mΩ,soh 降 0.44 个百分点"   ✅ 合理
   "健康度随 cycle 上升"                  ❌ 停
   ↓
   顺序换了,发现问题的概率完全不同

这一章的知识,一句话:

线性模型只给你四个数字:coef_、intercept_、R²、和反解点。 四个数字各有各的量纲和前提,读错任何一个, 后面的结论都会看起来很有道理、但完全站不住。

下一章我们把这条直线画出来,并且让它回答一个业务问题: "我这支设备什么时候退役?" —— 你会看到它答得有多不靠谱。


下一章:第 07 章 · 预测与可视化:一条直线能回答"什么时候退役"吗