Skip to content

第 11 章 · 缩放:让系数可比,让尺度敏感的模型能用 ​

这一章要解决什么:前 10 章把特征造出来了,从这一章开始把它们喂对。 第一件要喂对的事是量纲:7 个特征里最大的跨 1390,最小的跨 0.0046 —— 差 30 万倍。

最容易踩的坑: ① 把"标准化之后测试 R² 从 0.94253 涨到 0.95053"读成缩放提升了精度 —— 这 +0.008 的真因是求解器丢了一个方向(11.3 拆开); ② 以为"树不需要缩放"就等于"什么都无所谓" —— 缺失值、类别列照样要处理; ③ 拿 pipe.named_steps["model"].coef_ 直接当成原始单位的系数读 —— 它是标准化尺度的。

本章你需要完成的代码:demos/ch11-scaling.py

11.1 为什么需要缩放:7 列的量纲差 30 万倍 ​

先说清这一章的舞台。用第 03 章定下来的 7 列基线特征(Day2 口径), 按设备划分(GroupShuffleSplit(test_size=0.2, random_state=42)):

python
FEATS = ["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")
X, y, groups = df[FEATS], df["soh"], df["cell_id"]
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
              .split(X, y, groups))

真实输出:

text
数据:soh_features_v2.csv  21239 行 × 27 列,60 支设备
特征:Day2 基线 7 列
划分:按设备 GroupShuffleSplit(test_size=0.2, random_state=42)
      训练 17244 行 / 48 支   测试 3995 行 / 12 支
两边设备有交集:False

再看这 7 列的真实量纲:

text
        cycle  mean_charge_voltage_v  mean_temp_rise_c  resistance_mohm  coulombic_eff  cap_fade_rate_ah_per_100  temp_std_c
min    50.000                 3.5912            0.2571          33.8434         0.9937                    0.0020      0.0571
max  1440.000                 4.0545           23.6920          87.7078         0.9983                    0.0973      0.8070
std   342.705                 0.0853            3.7489          10.8928         0.0011                    0.0132      0.1379

  全距最大 / 全距最小 = 1390.0000 / 0.0046 = 302,174 倍

★ 把这句话翻译成模型里发生的事:

text
cycle 的一次变化 = 1(圈)
coulombic_eff 的一次变化 = 0.0001(效率差万分之一)

放进同一个线性模型:soh = w₁·cycle + … + w₅·coulombic_eff + b
    ↓
w₁ 只要 0.0001 就能产生 0.1 的预测变化
w₅ 要 1000 才能产生同样的 0.1
    ↓
"系数的大小"完全被量纲决定了 —— 它不再是"这个特征有多重要"

两个 scaler,一句话说清:

scaler公式结果学到的参数
StandardScalerz = (x − μ) / σ均值 0、标准差 1mean_、scale_
MinMaxScalerz = (x − min) / (max − min)压到 [0, 1]data_min_、data_max_

★ 两者都是仿射变换(先减一个数、再除一个数)。这个性质在本章会出现三次: 解释"为什么两个 scaler 给线性回归一模一样的答案"、解释"为什么树不在乎"、 也解释"为什么整表 fit_transform 在这份数据上测不出差别"(11.6、第 19 章)。

点开:`fit` 与 `transform` 的分工,为什么它是纪律而不是风格
python
scaler = StandardScaler()

scaler.fit(X_train)                    # ← 只从训练集学 mean_ / scale_
X_train_s = scaler.transform(X_train)  # 训练集用这套参数
X_test_s = scaler.transform(X_test)    # 测试集也用这套参数,不是自己再 fit 一次

为什么不能"各自用自己的均值":

text
离线评估时你手里确实有整个 X_test,用它的均值去标准化看起来毫无问题
    ↓
但上线时你只有一行新数据,"这一行的均值"没有定义
    ↓
于是训练和上线用了两套完全不同的预处理规则 → 模型当场失效

★ 一条命名约定:sklearn 里凡是下划线结尾的属性都是"学出来的",fit 之前不存在。

python
StandardScaler().mean_          # ❌ NotFittedError
StandardScaler().fit(X).mean_   # ✅

这一章所有例子都会把 scaler 放进 Pipeline —— 那是让"每一步都只在训练集上 fit" 从纪律变成结构(详见第 13 章)。手写 fit_transform 的顺序问题在第 19 章量化。

11.2 先看现象:三种配置,同一份数据 ​

python
for name, scaler in [("不缩放", None), ("StandardScaler", StandardScaler()),
                     ("MinMaxScaler", MinMaxScaler())]:
    model = (LinearRegression() if scaler is None
             else Pipeline([("s", scaler), ("m", LinearRegression())]))
    model.fit(Xtr, ytr)
    print(r2_score(yte, model.predict(Xte)))

真实输出:

text
② 现象:线性回归 + 三种缩放

  不缩放              训练 R² = 0.94296   测试 R² = 0.94253   |系数| 最大 =   0.5556   rank_ = 6/7   0.00s
  StandardScaler   训练 R² = 0.95970   测试 R² = 0.95053   |系数| 最大 =   0.0289   rank_ = 7/7   0.01s
  MinMaxScaler     训练 R² = 0.95970   测试 R² = 0.95053   |系数| 最大 =   0.1242   rank_ = 7/7   0.01s

  → 测试 R² 从 0.94253 涨到 0.95053,差 +0.00801
  → 但训练 R² 也一起涨了(0.94296 → 0.95970),
    而且两个完全不同的 scaler 给出【一模一样】的测试分。这两条都是线索。

多数教程讲到这里就结束了:

text
"看,缩放让测试 R² 从 0.94253 提升到 0.95053,涨了 0.008!"

★ 本章要做两件事:先承认这三个数字是真的,再证明最后那句话的解释是错的。

先记住三条观察:

观察数字它为什么可疑
不缩放训练 0.94296 / 测试 0.94253—
两个 scaler 结果完全相同0.95053 / 0.95053两个变换的形状完全不同,凭什么给同一个答案
训练 R² 也一起涨了0.94296 → 0.95970一个没有正则化的线性模型,训练集分数凭什么涨

★ 第三条是最硬的线索:

text
线性回归没有正则化项,参数个数固定(7 个权重 + 1 个截距)
特征没有变多,也没有变少
    ↓
训练 R² 却涨了 0.017
    ↓
唯一的解释:之前那个"最优解",根本不是最优解

★ 一条可以直接背下来的判据(本章之后一直用):

如果加了一个预处理之后,训练集和测试集的分数【一起】涨,先怀疑数值问题或泄漏,别急着归功于"更好的特征表示"。

11.3 ★ 拆掉"缩放提精度":容差截断的三条证据 ​

真实输出:

text
③ ★ 真因:不是缩放提精度,是求解器在一个方向上被截断了

  不缩放:rank_ = 6/7   (传进去 7 个特征,只当 6 个用)
          条件数 cond = 1.322e+06   (中心化后 σmax/σmin)
          奇异值谱(从大到小):
            σ1 =   45644.206438
            σ2 =     711.701111
            σ3 =     376.849542
            σ4 =      12.046378
            σ5 =       6.820326
            σ6 =       1.283168
            σ7 =       0.034526  ← 被截断
          截断阈值 = tol × σmax = 1e-06 × 45644.2064 = 0.045644
          最小 σ = 0.034526 < 阈值 0.045644  → 这个方向被当成噪声扔掉

  把截断关掉(tol=0)但不缩放:
          rank_ = 7/7   训练 R² = 0.95970   测试 R² = 0.95053

  两条不同数值路径得到的系数(都还原到原始单位):
    路径 A:标准化 → 拟合 → 系数 ÷ scale_
    路径 B:原始单位 → 拟合(tol=0)
    7 个系数的最大差 = 2.59e-13   ← 机器精度级别 = 同一个最小二乘解

缩放与容差截断:不缩放时求解器悄悄丢掉一个方向

证据一:rank_ 只有 6 ​

python
raw = LinearRegression().fit(Xtr, ytr)
print(raw.rank_)          # 6
print(Xtr.shape)          # (17244, 7)

传进去 7 个特征,解得出来的只有 6 个方向。

text
rank_ = 6 / 7
    ↓
"最优解"是在一个少了一维的空间里找的
    ↓
它当然不等于真正的最小二乘解

★ 这个属性和第 10 章那个判据是同一类东西:它不改变模型类,它改变求解器实际走过的路。

证据二:截断阈值是怎么算出来的 ​

上面那三行数字就是全部机制:

text
奇异值(从大到小):45644.206438, 711.701111, 376.849542, 12.046378,
                    6.820326, 1.283168, 0.034526
截断阈值 = tol × σmax = 1e-6 × 45644.2064 = 0.045644

最小的 σ (0.034526) < 阈值 (0.045644)  →  这个方向被扔掉  →  rank_ = 6

为什么会这样:

text
scikit-learn 的 LinearRegression 默认 tol=1e-6,
它把这个值当作 scipy.linalg.lstsq 的 cond 参数使用:
    "把小于 cond × σmax 的奇异值都当作 0"

而这份数据的特征量纲差 5~6 个数量级
    ↓
最小的那个方向 σ7 = 0.0345,只有 σ1 的 7.6e-7 倍
    ↓
它比阈值 1e-6 还小 → 被当成噪声扔掉

★ 两个必须写清的技术细节(写论文/报告时会被人追问):

text
① fit_intercept=True 时,scipy 是对【中心化之后】的 X 做分解的。
   所以奇异值谱要在 X − X.mean(0) 上算,否则阈值会算错、对不上 rank_。

② "条件数"这个数字有两套口径:
   中心化矩阵 Xc 的 σmax/σmin = 45644.206438 / 0.034526 = 1.322e+06(本章用这套,
     因为它正是参与截断判定的那份矩阵);
   把截距列一起放进去的 [1, X] 条件数是 3.795e+06。
   两个都对,只是回答的问题不同 —— 报表里写条件数必须写清是哪一份矩阵。

证据三:换个数值路径,答案就回来了 ​

python
fix = LinearRegression(tol=0).fit(Xtr, ytr)      # 关掉截断
print(fix.rank_)                     # 7        ← 方向回来了
print(fix.score(Xte, yte))           # 0.95053  ← 和 StandardScaler 完全一样

# 标准化解的系数还原回原始单位
pipe = Pipeline([("s", StandardScaler()), ("m", LinearRegression())]).fit(Xtr, ytr)
coef_std_back = pipe.named_steps["m"].coef_ / pipe.named_steps["s"].scale_
print(np.abs(coef_std_back - fix.coef_).max())    # 2.59e-13

★ 2.59e-13 是这一章最重要的一个数字。

text
两条完全不同的路径:
   ① 特征标准化 → 拟合 → 系数除以 scale_ 还原回原始单位
   ② 原始单位 → 拟合(tol=0)

得到的 7 个系数,最大差 2.59e-13(约等于浮点数的机器精度)
    ↓
结论:这是【同一个最小二乘解】,只是走了两条数值路径
    ↓
所以 "+0.00801" 不是"缩放让模型变强",
而是"缩放让求解器终于算对了"

★ 一句话结论(口径必须这样讲):

缩放的第一价值是"让求解器不丢方向"和"让系数可比", 不是"提高精度"。

判据再念一遍:训练分和测试分一起涨 → 先怀疑数值问题。

点开:既然 tol=0 就能修好,为什么还推荐标准化?

因为 tol=0 只是诊断手段,标准化顺手解决了三件事:

做法修好数值问题系数可比尺度敏感的模型能用
LinearRegression(tol=0)✅❌ 系数还是各带各的量纲❌ 对 KNN/SVR 毫无帮助
StandardScaler + 模型✅✅ 系数变成"每个标准差"✅

★ 还有一条更重要的理由:

text
tol=0 是在"关掉一个保护" —— 它让求解器去解一个病态方程组
    ↓
本数据 7 列还行;换成 300 列、字段之间近似共线时,
tol=0 会把数值噪声也一起解进来,系数反而更不稳
    ↓
正确顺序是:先把量纲对齐(缩放),再让求解器用它自己的保护(默认 tol)

什么时候该去看 tol/rank_:当你看到"加了一列特征,分数反而掉"的时候。 排查顺序是 —— 先看 rank_,再看量纲,再看相关性,最后才怀疑统计现象 (上一本第 13 章练过这个顺序,本章把第一步的机制讲完了)。

11.4 那缩放到底有什么用:三条真实价值 ​

「不提升精度」不等于「没用」。 缩放的收益体现在三个地方:

价值一:让系数可比(也让它可读) ​

真实输出:

text
  不缩放               |系数| 最大 =   0.5556
  StandardScaler    |系数| 最大 =   0.0289
  MinMaxScaler      |系数| 最大 =   0.1242

看这三行数字的"单位":

text
不缩放:       w = 0.5556   →  "cycle 每多 1 圈,soh 变化多少"
标准化之后:   w = 0.0289   →  "cycle 每多 1 个标准差,soh 变化多少"
MinMax 之后:  w = 0.1242   →  "cycle 从最小到最大,soh 变化多少"

为什么只有第二行能横向比大小:

text
不缩放:cycle 的标准差是 342.705,coulombic_eff 是 0.0011
        → 两个 w 的分母不同,比大小等于比量纲
标准化:每列的标准差都变成 1
        → w 的大小就是"这一列动一个自己的典型幅度,能推动标签多少"

★ 三条使用纪律:

text
① 标准化系数只能说明"模型把多大的话语权分给了这一列",
   不能说明"这一列是原因"(相关不等于因果,上一本第 14 章)。
② 共线特征之间的系数会互相抢功劳,符号都可能反转
   (本书第 14 章会看到 soh_lag1 / soh_lag5 / soh_win_mean5 的 |r| 都在 0.999 以上)。
③ 换了 scaler,系数的大小就换了一把尺子 ——
   报表里写"标准化系数",必须同时写清用的是哪个 scaler。

价值二:让尺度敏感的模型能工作 ​

回归模型可以粗分成两类:

类别例子不缩放会怎样为什么
必须缩放KNN、SVM/SVR、神经网络、Ridge/Lasso直接吃亏(上一本实测 KNN −0.055、SVR −0.062、Ridge −0.021)它们依赖距离或系数的尺度
不敏感决策树、随机森林、梯度提升差 ≤ 0.00001每个节点只问"大于还是小于某个阈值"
text
KNN 问:"离这个样本最近的 5 个邻居是谁?"
    → 距离 = √Σ(xᵢ − yᵢ)²
    → cycle 差 100 圈贡献 10000,coulombic_eff 差 0.001 贡献 1e-6
    → 距离几乎只由 cycle 决定,其余 6 列等于白带

决策树问:"coulombic_eff 大于 0.9959 吗?"
    → 把 coulombic_eff 乘以 1000,阈值从 0.9959 变成 995.9,判断结果一模一样

价值三:让数值路径稳定(11.3 已经证完) ​

小结一下三件事的关系:

text
              ┌─ 让求解器不丢方向(11.3)      ← 本章新增的认识
缩放的三重收益 ├─ 让系数可比(价值一)          ← 上一本第 14 章用过
              └─ 让尺度敏感的模型能用(价值二)  ← 下一章之后一直用

11.5 树为什么不在乎:切分点会跟着一起动 ​

真实输出:

text
④ 随机森林 + 缩放:树只问'谁比谁大'

  不缩放              测试 R² = 0.96503     3.7s
  StandardScaler   测试 R² = 0.96502     3.2s
  → 两次几乎一样:缩放是单调变换,切分点的顺序不变,树看到的规则一个字没改。

★ 机制(一句话):

text
树的每一次分裂只用到"某一列上,x ≤ 阈值?"
缩放是单调变换(x → (x − a)/b,b > 0)
    ↓
大小顺序不变 → 能切出来的划分集合完全不变
    ↓
树只需要把阈值从 611.0 换成 0.0(z 值)而已

★ 但"树不敏感"有三个例外,别把它当免死金牌:

text
① 特征的标准差极小(例如 1e-9 量级):缩放能避免浮点比较退化;
② 你要读特征重要性:重要性不受缩放影响,但"阈值的物理含义"会变;
③ 树不敏感 ≠ 不用预处理:缺失值、类别列、量纲不同的"距离型派生特征"照样要处理。

★ 真正的结论:

"树不用缩放"意味着你可以省掉这一步,不意味着你可以省掉"想清楚每一列的单位"这件事。

11.6 两种 scaler 的软肋:一个怕离群点,一个怕标准差极小 ​

软肋一:MinMax 被一个极端值拉满 ​

真实输出:

text
⑤ 两种 scaler 的软肋:MinMax 怕离群点,Standard 怕标准差极小

  注入一个 resistance_mohm = 500 之后,StandardScaler :
    变换后 1% / 50% / 99% 分位 = -1.5015 / -0.1568 /  2.3920   全距 41.1992
  注入一个 resistance_mohm = 500 之后,MinMaxScaler   :
    变换后 1% / 50% / 99% 分位 =  0.0062 /  0.0388 /  0.1007   全距 1.0000
    对照:没有离群点时 MinMax 的全距 = 1.0000
  纯正常样本在 MinMax 之后被挤进 [0, 0.08] 这样的一小段里 —— 这就是它的软肋。

MinMax 被一个极端值拉满,正常样本全挤在左端

读这两个数字:

scaler正常样本落在哪后果
StandardScaler大约 [−1.5, +2.4],形状不变,只是平移压缩离群点自己跑到很远的地方,其余样本的相对关系保住
MinMaxScaler全挤进 [0.006, 0.10]99% 的正常样本只用到 1/10 的区间,另外 9/10 被一个坏点占着

★ 为什么这对模型是灾难:

text
MinMax 之后的正常样本彼此相差 0.001 量级
    ↓
KNN 的"距离"几乎分不出邻居(大家挤在一起)
神经网络这一维的梯度接近 0
树要在 [0.006, 0.10] 里找阈值,可用精度被白白浪费

本数据里的现成例子:resistance_mohm 的量纲是 mΩ,量程 33.8~87.7。 一个接触不良导致的读数事故(比如 500)就会让上面那张图成真。

软肋二:Standard 撞上"标准差极小"的列 ​

text
coulombic_eff 的 std = 0.0011
    ↓
StandardScaler 除以 0.0011 → 把 1e-4 的差异放大成 0.09
    ↓
如果这一列的测量噪声恰好是 1e-4 量级,噪声也被同比放大

这不是 scaler 的错,是"这列本身的信噪比低"。 正确的动作是回去看数据 (第 09 章讲滚动统计时踩过同样的坑),而不是换个 scaler 掩盖。

★ 一张选择表:

情况选谁理由
一般情况(默认)StandardScaler对离群点更稳,保留分布形状
明确知道没有离群点、且下游需要固定区间(例如神经网络输入层)MinMaxScaler压到 [0, 1],边界明确
离群点明显且不能删StandardScaler + 先处理离群点MinMax 会把坏点的影响摊给所有样本
稀疏特征(大量 0)MaxAbsScaler保住稀疏性
分布极度偏斜QuantileTransformer / PowerTransformer形状也一起改

★ 最后一条纪律:

scaler 的参数是"从数据里学出来的",所以它和模型一起,必须只在训练集上 fit。 这一章的所有例子都写成了 Pipeline;违规写法的代价在第 19 章量化。

11.7 常见坑 ​

text
① 把 +0.008 读成"缩放提精度"     → rank_ 6/7 就是反证;tol=0 不缩放也是 0.95053
② 判据记错方向                   → 训练分和测试分【一起】涨 = 先怀疑数值问题
③ 拿 named_steps["model"].coef_ 当原始系数
                                  → 它是标准化尺度,要 ÷ scaler.scale_ 才回到原始单位
④ pipe.coef_                     → AttributeError: 'Pipeline' object has no attribute 'coef_'
⑤ 给树加 scaler 还期待收益        → 0.96503 vs 0.96502,白多一个环节(不是错误,是浪费)
⑥ 用 MinMaxScaler 处理含离群点的列 → 正常样本被挤进 [0, 0.08](11.6)
⑦ 在划分之前 fit scaler           → 预处理泄漏;这份数据上分数可能一模一样(第 19 章)
⑧ 把 tol=0 当成常规操作           → 它是诊断手段;常规解法是缩放
⑨ 只报 |系数| 最大就下结论        → 量纲、共线性都会影响它(第 14 章)

11.8 完整示例与输出 ​

bash
cd tools/feature-engineering-pipeline/code
MPLCONFIGDIR=/tmp/fe-mpl /path/to/venv/bin/python demos/ch11-scaling.py

真实输出(节选):

text
② 现象:线性回归 + 三种缩放

  不缩放              训练 R² = 0.94296   测试 R² = 0.94253   |系数| 最大 =   0.5556   rank_ = 6/7
  StandardScaler   训练 R² = 0.95970   测试 R² = 0.95053   |系数| 最大 =   0.0289   rank_ = 7/7
  MinMaxScaler     训练 R² = 0.95970   测试 R² = 0.95053   |系数| 最大 =   0.1242   rank_ = 7/7

③ ★ 真因:不是缩放提精度,是求解器在一个方向上被截断了

  不缩放:rank_ = 6/7
          截断阈值 = tol × σmax = 1e-06 × 45644.2064 = 0.045644
          最小 σ = 0.034526 < 阈值 0.045644  → 这个方向被当成噪声扔掉

  把截断关掉(tol=0)但不缩放:
          rank_ = 7/7   训练 R² = 0.95970   测试 R² = 0.95053
     7 个系数的最大差 = 2.59e-13

④ 随机森林 + 缩放:树只问'谁比谁大'
  不缩放              测试 R² = 0.96503
  StandardScaler   测试 R² = 0.96502

★ 一页记住这一章:

text
不缩放 0.94253(rank_ 6/7)
标准化 0.95053(rank_ 7/7)
tol=0 不缩放 0.95053(rank_ 7/7)  ← 与标准化解的系数最大差 2.59e-13

⇒ 那个 +0.008 是【容差截断】被修好,不是【泛化能力】被提高。
⇒ 缩放的收益:系数可比 / 尺度敏感的模型能用 / 数值路径稳定。

本章练习 ​

练习 1:同一件事的三条路径,哪一次"模型变强了"?

按设备划分(GroupShuffleSplit(test_size=0.2, random_state=42)),用 7 列基线特征:

  1. 用不缩放的 LinearRegression() 拟合,报训练 R² / 测试 R² / rank_;
  2. 换 Pipeline([("s", StandardScaler()), ("m", LinearRegression())]),再报一次;
  3. 换 LinearRegression(tol=0)(不缩放),再报一次;
  4. 用一句话回答:这三次里,哪一次模型变强了?凭什么这么判断?
参考答案
python
import os

import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.metrics import r2_score
from sklearn.model_selection import GroupShuffleSplit
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

HERE = os.path.dirname(os.path.abspath(__file__))
CODE = os.path.abspath(os.path.join(HERE, "..", ".."))
DATA = os.path.join(CODE, "data", "battery")

FEATS = ["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(os.path.join(DATA, "soh_features_v2.csv"))
X, y, g = df[FEATS], df["soh"], df["cell_id"]
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
              .split(X, y, g))
Xtr, Xte, ytr, yte = X.iloc[tr], X.iloc[te], y.iloc[tr], y.iloc[te]

CASES = [
    ("不缩放(默认 tol)", LinearRegression()),
    ("StandardScaler", Pipeline([("s", StandardScaler()), ("m", LinearRegression())])),
    ("不缩放 + tol=0", LinearRegression(tol=0)),
]
for name, model in CASES:
    model.fit(Xtr, ytr)
    inner = model.named_steps["m"] if isinstance(model, Pipeline) else model
    print(f"{name:18s} 训练 R² = {r2_score(ytr, model.predict(Xtr)):.5f}   "
          f"测试 R² = {r2_score(yte, model.predict(Xte)):.5f}   rank_ = {inner.rank_}/7")

期望输出:

text
不缩放(默认 tol)      训练 R² = 0.94296   测试 R² = 0.94253   rank_ = 6/7
StandardScaler      训练 R² = 0.95970   测试 R² = 0.95053   rank_ = 7/7
不缩放 + tol=0        训练 R² = 0.95970   测试 R² = 0.95053   rank_ = 7/7

④ 回答:三次里没有任何一次"模型变强了"。

text
第一步:三次用的是【同一个模型类、同一批特征、同一个训练集】→ 假设空间完全相同
第二步:标准化 与 tol=0 给出完全一样的 rank_(7/7) 与测试 R²(0.95053)
        → 它们解的是同一个最小二乘问题,只是数值路径不同
第三步:既然第二步成立,那么"不缩放(默认)"的 0.94253
        不是"模型更弱",而是"求解器没算到最优解"(rank_ = 6/7 是证据)

★ 判据复习:一个没有正则化的线性模型,如果训练 R² 也一起涨 (0.94296 → 0.95970),那涨的绝不可能是泛化能力 —— 只能是数值路径被修好了。

★ 所以推荐的写法是标准化而不是 tol=0:标准化同时解决"数值路径"和"系数可比", 而 tol=0 只是把一个保护关掉。

练习 2:KNN 与离群点,把两个 scaler 的软肋一起量出来

用同样的划分,对 KNeighborsRegressor(n_neighbors=5):

  1. 报三种配置(不缩放 / StandardScaler / MinMaxScaler)在训练集干净时的测试 R²;
  2. 把训练集里 resistance_mohm 的 1 个样本、以及 1% 的样本(172 个)改成 500, 三种配置各再跑一次,报测试 R²;
  3. 除了分数,再报一次"变换后 1%~99% 分位之间的跨度"(干净 vs 含 1% 坏点);
  4. 用一句话回答:为什么"分数没怎么掉"不能证明"这个 scaler 没问题"?
参考答案
python
import os

import numpy as np
import pandas as pd
from sklearn.metrics import r2_score
from sklearn.model_selection import GroupShuffleSplit
from sklearn.neighbors import KNeighborsRegressor
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import MinMaxScaler, StandardScaler

HERE = os.path.dirname(os.path.abspath(__file__))
CODE = os.path.abspath(os.path.join(HERE, "..", ".."))
DATA = os.path.join(CODE, "data", "battery")

FEATS = ["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(os.path.join(DATA, "soh_features_v2.csv"))
X, y, g = df[FEATS], df["soh"], df["cell_id"]
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
              .split(X, y, g))
Xtr, Xte, ytr, yte = X.iloc[tr].copy(), X.iloc[te], y.iloc[tr], y.iloc[te]
rng = np.random.RandomState(0)
COL = Xtr.columns.get_loc("resistance_mohm")


def inject(frac):
    """把 frac 比例的训练样本的 resistance_mohm 改成 500(读数事故)。"""
    Xb = Xtr.copy()
    if frac == 0:
        return Xb
    k = max(1, int(len(Xb) * frac))
    Xb.iloc[rng.choice(len(Xb), k, replace=False), COL] = 500.0
    return Xb


def make(name):
    if name == "不缩放":
        return KNeighborsRegressor(n_neighbors=5)
    scaler = StandardScaler() if name == "StandardScaler" else MinMaxScaler()
    return Pipeline([("s", scaler), ("m", KNeighborsRegressor(n_neighbors=5))])


print(f"{'配置':16s} {'干净':>10s} {'1 个坏点':>10s} {'1% 坏点':>10s}")
for name in ["不缩放", "StandardScaler", "MinMaxScaler"]:
    scores = [r2_score(yte, make(name).fit(inject(f), ytr).predict(Xte))
              for f in (0.0, 1 / len(Xtr), 0.01)]
    print(f"{name:16s} {scores[0]:10.5f} {scores[1]:10.5f} {scores[2]:10.5f}")

for name, scaler in [("StandardScaler", StandardScaler()),
                     ("MinMaxScaler", MinMaxScaler())]:
    span = [np.ptp(np.percentile(scaler.fit_transform(Xb[["resistance_mohm"]])[:, 0],
                                 [1, 99])) for Xb in (Xtr, inject(0.01))]
    print(f"{name:16s} 1%~99% 跨度:干净 {span[0]:.4f} → 含 1% 坏点 {span[1]:.4f}")

期望输出:

text
配置                       干净      1 个坏点      1% 坏点
不缩放                 0.90001    0.90001    0.89969
StandardScaler      0.95504    0.95545    0.95310
MinMaxScaler        0.95576    0.95176    0.95172
StandardScaler   1%~99% 跨度:干净 4.0197 → 含 1% 坏点 1.1165
MinMaxScaler     1%~99% 跨度:干净 0.8097 → 含 1% 坏点 0.1094

④ 回答:因为"坏"首先发生在表示里,分数只是它的下游。

text
MinMax: z = (x − min) / (max − min),分母是极差
    ↓
1% 的坏点就把分母从 53.9 拉到 466.2(放大 8.6 倍)
    ↓
正常样本的 1%~99% 跨度从 0.8097 掉到 0.1094 —— 区间利用率 81% → 11%
    ↓
而 KNN 的分数只掉了千分之四:因为还有另外 6 个维度在提供距离信息

★ 三条要一起说清的话:

text
① StandardScaler 的 μ/σ 只被 1/n 拉动,形状基本保住(1%~99% 跨度还有 27.8%,
   拉伸方向甚至变大)—— 它对离群点更"钝";
② MinMax 的 μ/σ 不存在,它的分母只有 max/min 两个点 → 任何一点坏,全体被挤;
③ "分数只掉千分之四"不是"没问题"的证据 ——
   这正是第 10 章那条判据的又一次现身:分数分不出"表示坏了",能分出来的是分布。

★ 顺带复查"不缩放"那一行:它的 0.90001 本来就被 cycle 的量纲主导, 所以换个离群点注入到 cycle 上,它会掉得更多。三个数字要一起读,不能挑一行下结论。

练习 3:把标准化系数还原回原始单位,并说清它能不能用来比大小

用 Pipeline(StandardScaler + LinearRegression) 在训练集上拟合:

  1. 打印 named_steps 的键;
  2. 打印 7 个标准化系数,按 |系数| 从大到小排;
  3. 把系数除以 scaler.scale_ 还原回原始单位,与 LinearRegression(tol=0) 的原始系数比较最大差;
  4. 回答:要回答"哪个特征更重要",该看标准化系数还是原始系数?
参考答案
python
import os

import numpy as np
import pandas as pd
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import GroupShuffleSplit
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

HERE = os.path.dirname(os.path.abspath(__file__))
CODE = os.path.abspath(os.path.join(HERE, "..", ".."))
DATA = os.path.join(CODE, "data", "battery")

FEATS = ["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(os.path.join(DATA, "soh_features_v2.csv"))
X, y, g = df[FEATS], df["soh"], df["cell_id"]
tr, te = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
              .split(X, y, g))
Xtr, ytr = X.iloc[tr], y.iloc[tr]

pipe = Pipeline([("s", StandardScaler()), ("m", LinearRegression())]).fit(Xtr, ytr)
sc, md = pipe.named_steps["s"], pipe.named_steps["m"]
print(f"named_steps 的键:{list(pipe.named_steps)}")

print("标准化系数(按 |系数| 从大到小):")
for c, w in sorted(zip(FEATS, md.coef_), key=lambda t: -abs(t[1])):
    print(f"  {c:26s} {w:+.6f}")

back = md.coef_ / sc.scale_
fix = LinearRegression(tol=0).fit(Xtr, ytr)
print(f"还原回原始单位的最大差 = {np.abs(back - fix.coef_).max():.2e}")
print("原始系数(其中两个):")
for c in ["cycle", "coulombic_eff"]:
    i = FEATS.index(c)
    print(f"  {c:26s} 标准化 {md.coef_[i]:+.6f}  原始 {back[i]:+.8f}")

期望输出:

text
named_steps 的键:['s', 'm']
标准化系数(按 |系数| 从大到小):
  coulombic_eff              +0.028881
  cycle                      -0.013282
  mean_temp_rise_c           -0.006670
  mean_charge_voltage_v      -0.004971
  temp_std_c                 -0.004154
  cap_fade_rate_ah_per_100   -0.003403
  resistance_mohm            +0.001261
还原回原始单位的最大差 = 2.59e-13
原始系数(其中两个):
  cycle                      标准化 -0.013282  原始 -0.00003823
  coulombic_eff              标准化 +0.028881  原始 +27.00535348

④ 回答:要回答"哪个特征更重要",看标准化系数;但要解释"物理上变化多少", 必须用原始系数。

text
原始系数:coulombic_eff 是 +26.06 —— 意思是"库仑效率每 +1(不可能发生),soh 变化 26"
          cycle 是 −0.000038 —— 意思是"每多 1 圈,soh 掉 3.8e-5"
          → 两个数没法比大小,因为单位不同

标准化系数:+0.0289 vs −0.0133
          → 意思是"库仑效率动 1 个标准差,soh 动 0.0289;cycle 动 1 个标准差,动 0.0133"
          → 分母统一了,可以比

★ 一张三行的对照表:

问题看哪个系数为什么
哪个特征"话语权"更大标准化系数分母(标准差)统一
内阻升高 1 mΩ 会怎样原始系数单位就是业务单位
这是不是因果关系两个都不能共线特征会互相抢功劳(第 14 章)

★ 最后一条纪律:报表里写"标准化系数",必须同时写清 scaler 是哪个、在哪个数据集上 fit 的 —— 换个 scaler 或者换份数据,这 7 个数字都会变。

面向工程实践:预处理是管道的第一段 ​

这一章的结论落到工程上,就是三句话:

① 判断"要不要缩放"只有一个问题

text
问:"这个模型会不会用到特征的【绝对大小】?"
    ├─ 会(距离 / 核函数 / 梯度 / 正则化惩罚)→ 必须缩放
    └─ 不会(只比较大小顺序的树)           → 可以不缩放,但别省掉"想清单位"这一步

② 判断"缩放到底管不管用",只有一个动作

text
把 scaler 装进 Pipeline,然后看【训练 R² 和测试 R² 的变化方向】:
    两个一起涨且涨法一致 → 数值路径被修好(本章 11.3),不是泛化变强
    只有测试涨            → 这才可能是泛化收益
    两个一起跌            → 缩放把某个信息抹掉了(检查标准差极小的列)

③ 一个可以复制到项目里的自检函数

python
import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler


def report_scaling(X_tr, y_tr, X_te, y_te, model=None):
    """报出缩放前后的训练/测试分数与 rank_,用来判断"涨的是哪一类分数"。"""
    model = model or LinearRegression()
    raw = model.fit(X_tr, y_tr)
    std = Pipeline([("s", StandardScaler()), ("m", model)]).fit(X_tr, y_tr)
    inner = std.named_steps["m"]
    rank = getattr(raw, "rank_", None)
    print(f"不缩放    训练 {raw.score(X_tr, y_tr):.5f}  测试 {raw.score(X_te, y_te):.5f}"
          f"  rank_ = {rank}/{X_tr.shape[1] if rank else '—'}")
    print(f"标准化    训练 {std.score(X_tr, y_tr):.5f}  测试 {std.score(X_te, y_te):.5f}"
          f"  rank_ = {getattr(inner, 'rank_', '—')}/{X_tr.shape[1]}")
    if rank is not None and rank < X_tr.shape[1]:
        print("⚠️ rank_ < 特征数:先怀疑容差截断,再怀疑统计现象")
    s = np.asarray(X_tr).std(axis=0)
    for i in np.argsort(s)[:3]:
        print(f"  ⚠️ 第 {i} 列标准差极小({s[i]:.3e}):StandardScaler 会放大它的噪声")

★ 这个函数的三行输出对应本章的三个坑:

text
rank_ < 特征数        → 容差截断(11.3)
训练/测试一起涨       → 数值问题,不是泛化问题(11.2)
标准差极小的列        → 缩放会放大噪声(11.6)

★ 最后一句,也是通往下一章的桥:

量纲对齐之后,还有一类"看起来是数字、其实不是数字"的列 —— 类别列。它们不能减均值、不能除标准差,只能换一种方式进模型。那是下一章的事。


下一章:第 12 章 · 类别编码:OneHot 与 Label 到底差在哪