第 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)):
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))真实输出:
数据:soh_features_v2.csv 21239 行 × 27 列,60 支设备
特征:Day2 基线 7 列
划分:按设备 GroupShuffleSplit(test_size=0.2, random_state=42)
训练 17244 行 / 48 支 测试 3995 行 / 12 支
两边设备有交集:False再看这 7 列的真实量纲:
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 倍★ 把这句话翻译成模型里发生的事:
cycle 的一次变化 = 1(圈)
coulombic_eff 的一次变化 = 0.0001(效率差万分之一)
放进同一个线性模型:soh = w₁·cycle + … + w₅·coulombic_eff + b
↓
w₁ 只要 0.0001 就能产生 0.1 的预测变化
w₅ 要 1000 才能产生同样的 0.1
↓
"系数的大小"完全被量纲决定了 —— 它不再是"这个特征有多重要"两个 scaler,一句话说清:
| scaler | 公式 | 结果 | 学到的参数 |
|---|---|---|---|
StandardScaler | z = (x − μ) / σ | 均值 0、标准差 1 | mean_、scale_ |
MinMaxScaler | z = (x − min) / (max − min) | 压到 [0, 1] | data_min_、data_max_ |
★ 两者都是仿射变换(先减一个数、再除一个数)。这个性质在本章会出现三次: 解释"为什么两个 scaler 给线性回归一模一样的答案"、解释"为什么树不在乎"、 也解释"为什么整表 fit_transform 在这份数据上测不出差别"(11.6、第 19 章)。
点开:`fit` 与 `transform` 的分工,为什么它是纪律而不是风格
scaler = StandardScaler()
scaler.fit(X_train) # ← 只从训练集学 mean_ / scale_
X_train_s = scaler.transform(X_train) # 训练集用这套参数
X_test_s = scaler.transform(X_test) # 测试集也用这套参数,不是自己再 fit 一次为什么不能"各自用自己的均值":
离线评估时你手里确实有整个 X_test,用它的均值去标准化看起来毫无问题
↓
但上线时你只有一行新数据,"这一行的均值"没有定义
↓
于是训练和上线用了两套完全不同的预处理规则 → 模型当场失效★ 一条命名约定:sklearn 里凡是下划线结尾的属性都是"学出来的",fit 之前不存在。
StandardScaler().mean_ # ❌ NotFittedError
StandardScaler().fit(X).mean_ # ✅这一章所有例子都会把 scaler 放进 Pipeline —— 那是让"每一步都只在训练集上 fit" 从纪律变成结构(详见第 13 章)。手写 fit_transform 的顺序问题在第 19 章量化。
11.2 先看现象:三种配置,同一份数据
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)))真实输出:
② 现象:线性回归 + 三种缩放
不缩放 训练 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 给出【一模一样】的测试分。这两条都是线索。多数教程讲到这里就结束了:
"看,缩放让测试 R² 从 0.94253 提升到 0.95053,涨了 0.008!"★ 本章要做两件事:先承认这三个数字是真的,再证明最后那句话的解释是错的。
先记住三条观察:
| 观察 | 数字 | 它为什么可疑 |
|---|---|---|
| 不缩放 | 训练 0.94296 / 测试 0.94253 | — |
| 两个 scaler 结果完全相同 | 0.95053 / 0.95053 | 两个变换的形状完全不同,凭什么给同一个答案 |
| 训练 R² 也一起涨了 | 0.94296 → 0.95970 | 一个没有正则化的线性模型,训练集分数凭什么涨 |
★ 第三条是最硬的线索:
线性回归没有正则化项,参数个数固定(7 个权重 + 1 个截距)
特征没有变多,也没有变少
↓
训练 R² 却涨了 0.017
↓
唯一的解释:之前那个"最优解",根本不是最优解★ 一条可以直接背下来的判据(本章之后一直用):
如果加了一个预处理之后,训练集和测试集的分数【一起】涨,先怀疑数值问题或泄漏,别急着归功于"更好的特征表示"。
11.3 ★ 拆掉"缩放提精度":容差截断的三条证据
真实输出:
③ ★ 真因:不是缩放提精度,是求解器在一个方向上被截断了
不缩放: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
raw = LinearRegression().fit(Xtr, ytr)
print(raw.rank_) # 6
print(Xtr.shape) # (17244, 7)传进去 7 个特征,解得出来的只有 6 个方向。
rank_ = 6 / 7
↓
"最优解"是在一个少了一维的空间里找的
↓
它当然不等于真正的最小二乘解★ 这个属性和第 10 章那个判据是同一类东西:它不改变模型类,它改变求解器实际走过的路。
证据二:截断阈值是怎么算出来的
上面那三行数字就是全部机制:
奇异值(从大到小):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为什么会这样:
scikit-learn 的 LinearRegression 默认 tol=1e-6,
它把这个值当作 scipy.linalg.lstsq 的 cond 参数使用:
"把小于 cond × σmax 的奇异值都当作 0"
而这份数据的特征量纲差 5~6 个数量级
↓
最小的那个方向 σ7 = 0.0345,只有 σ1 的 7.6e-7 倍
↓
它比阈值 1e-6 还小 → 被当成噪声扔掉★ 两个必须写清的技术细节(写论文/报告时会被人追问):
① 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。
两个都对,只是回答的问题不同 —— 报表里写条件数必须写清是哪一份矩阵。证据三:换个数值路径,答案就回来了
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 是这一章最重要的一个数字。
两条完全不同的路径:
① 特征标准化 → 拟合 → 系数除以 scale_ 还原回原始单位
② 原始单位 → 拟合(tol=0)
得到的 7 个系数,最大差 2.59e-13(约等于浮点数的机器精度)
↓
结论:这是【同一个最小二乘解】,只是走了两条数值路径
↓
所以 "+0.00801" 不是"缩放让模型变强",
而是"缩放让求解器终于算对了"★ 一句话结论(口径必须这样讲):
缩放的第一价值是"让求解器不丢方向"和"让系数可比", 不是"提高精度"。
判据再念一遍:训练分和测试分一起涨 → 先怀疑数值问题。
点开:既然 tol=0 就能修好,为什么还推荐标准化?
因为 tol=0 只是诊断手段,标准化顺手解决了三件事:
| 做法 | 修好数值问题 | 系数可比 | 尺度敏感的模型能用 |
|---|---|---|---|
LinearRegression(tol=0) | ✅ | ❌ 系数还是各带各的量纲 | ❌ 对 KNN/SVR 毫无帮助 |
StandardScaler + 模型 | ✅ | ✅ 系数变成"每个标准差" | ✅ |
★ 还有一条更重要的理由:
tol=0 是在"关掉一个保护" —— 它让求解器去解一个病态方程组
↓
本数据 7 列还行;换成 300 列、字段之间近似共线时,
tol=0 会把数值噪声也一起解进来,系数反而更不稳
↓
正确顺序是:先把量纲对齐(缩放),再让求解器用它自己的保护(默认 tol)什么时候该去看 tol/rank_:当你看到"加了一列特征,分数反而掉"的时候。 排查顺序是 —— 先看 rank_,再看量纲,再看相关性,最后才怀疑统计现象 (上一本第 13 章练过这个顺序,本章把第一步的机制讲完了)。
11.4 那缩放到底有什么用:三条真实价值
「不提升精度」不等于「没用」。 缩放的收益体现在三个地方:
价值一:让系数可比(也让它可读)
真实输出:
不缩放 |系数| 最大 = 0.5556
StandardScaler |系数| 最大 = 0.0289
MinMaxScaler |系数| 最大 = 0.1242看这三行数字的"单位":
不缩放: w = 0.5556 → "cycle 每多 1 圈,soh 变化多少"
标准化之后: w = 0.0289 → "cycle 每多 1 个标准差,soh 变化多少"
MinMax 之后: w = 0.1242 → "cycle 从最小到最大,soh 变化多少"为什么只有第二行能横向比大小:
不缩放:cycle 的标准差是 342.705,coulombic_eff 是 0.0011
→ 两个 w 的分母不同,比大小等于比量纲
标准化:每列的标准差都变成 1
→ w 的大小就是"这一列动一个自己的典型幅度,能推动标签多少"★ 三条使用纪律:
① 标准化系数只能说明"模型把多大的话语权分给了这一列",
不能说明"这一列是原因"(相关不等于因果,上一本第 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 | 每个节点只问"大于还是小于某个阈值" |
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 已经证完)
小结一下三件事的关系:
┌─ 让求解器不丢方向(11.3) ← 本章新增的认识
缩放的三重收益 ├─ 让系数可比(价值一) ← 上一本第 14 章用过
└─ 让尺度敏感的模型能用(价值二) ← 下一章之后一直用11.5 树为什么不在乎:切分点会跟着一起动
真实输出:
④ 随机森林 + 缩放:树只问'谁比谁大'
不缩放 测试 R² = 0.96503 3.7s
StandardScaler 测试 R² = 0.96502 3.2s
→ 两次几乎一样:缩放是单调变换,切分点的顺序不变,树看到的规则一个字没改。★ 机制(一句话):
树的每一次分裂只用到"某一列上,x ≤ 阈值?"
缩放是单调变换(x → (x − a)/b,b > 0)
↓
大小顺序不变 → 能切出来的划分集合完全不变
↓
树只需要把阈值从 611.0 换成 0.0(z 值)而已★ 但"树不敏感"有三个例外,别把它当免死金牌:
① 特征的标准差极小(例如 1e-9 量级):缩放能避免浮点比较退化;
② 你要读特征重要性:重要性不受缩放影响,但"阈值的物理含义"会变;
③ 树不敏感 ≠ 不用预处理:缺失值、类别列、量纲不同的"距离型派生特征"照样要处理。★ 真正的结论:
"树不用缩放"意味着你可以省掉这一步,不意味着你可以省掉"想清楚每一列的单位"这件事。
11.6 两种 scaler 的软肋:一个怕离群点,一个怕标准差极小
软肋一:MinMax 被一个极端值拉满
真实输出:
⑤ 两种 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] 这样的一小段里 —— 这就是它的软肋。
读这两个数字:
| scaler | 正常样本落在哪 | 后果 |
|---|---|---|
StandardScaler | 大约 [−1.5, +2.4],形状不变,只是平移压缩 | 离群点自己跑到很远的地方,其余样本的相对关系保住 |
MinMaxScaler | 全挤进 [0.006, 0.10] | 99% 的正常样本只用到 1/10 的区间,另外 9/10 被一个坏点占着 |
★ 为什么这对模型是灾难:
MinMax 之后的正常样本彼此相差 0.001 量级
↓
KNN 的"距离"几乎分不出邻居(大家挤在一起)
神经网络这一维的梯度接近 0
树要在 [0.006, 0.10] 里找阈值,可用精度被白白浪费本数据里的现成例子:resistance_mohm 的量纲是 mΩ,量程 33.8~87.7。 一个接触不良导致的读数事故(比如 500)就会让上面那张图成真。
软肋二:Standard 撞上"标准差极小"的列
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 常见坑
① 把 +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 完整示例与输出
cd tools/feature-engineering-pipeline/code
MPLCONFIGDIR=/tmp/fe-mpl /path/to/venv/bin/python demos/ch11-scaling.py真实输出(节选):
② 现象:线性回归 + 三种缩放
不缩放 训练 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★ 一页记住这一章:
不缩放 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 列基线特征:
- 用不缩放的
LinearRegression()拟合,报训练 R² / 测试 R² /rank_; - 换
Pipeline([("s", StandardScaler()), ("m", LinearRegression())]),再报一次; - 换
LinearRegression(tol=0)(不缩放),再报一次; - 用一句话回答:这三次里,哪一次模型变强了?凭什么这么判断?
参考答案
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")期望输出:
不缩放(默认 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④ 回答:三次里没有任何一次"模型变强了"。
第一步:三次用的是【同一个模型类、同一批特征、同一个训练集】→ 假设空间完全相同
第二步:标准化 与 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):
- 报三种配置(不缩放 /
StandardScaler/MinMaxScaler)在训练集干净时的测试 R²; - 把训练集里
resistance_mohm的 1 个样本、以及 1% 的样本(172 个)改成 500, 三种配置各再跑一次,报测试 R²; - 除了分数,再报一次"变换后 1%~99% 分位之间的跨度"(干净 vs 含 1% 坏点);
- 用一句话回答:为什么"分数没怎么掉"不能证明"这个 scaler 没问题"?
参考答案
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}")期望输出:
配置 干净 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④ 回答:因为"坏"首先发生在表示里,分数只是它的下游。
MinMax: z = (x − min) / (max − min),分母是极差
↓
1% 的坏点就把分母从 53.9 拉到 466.2(放大 8.6 倍)
↓
正常样本的 1%~99% 跨度从 0.8097 掉到 0.1094 —— 区间利用率 81% → 11%
↓
而 KNN 的分数只掉了千分之四:因为还有另外 6 个维度在提供距离信息★ 三条要一起说清的话:
① StandardScaler 的 μ/σ 只被 1/n 拉动,形状基本保住(1%~99% 跨度还有 27.8%,
拉伸方向甚至变大)—— 它对离群点更"钝";
② MinMax 的 μ/σ 不存在,它的分母只有 max/min 两个点 → 任何一点坏,全体被挤;
③ "分数只掉千分之四"不是"没问题"的证据 ——
这正是第 10 章那条判据的又一次现身:分数分不出"表示坏了",能分出来的是分布。★ 顺带复查"不缩放"那一行:它的 0.90001 本来就被 cycle 的量纲主导, 所以换个离群点注入到 cycle 上,它会掉得更多。三个数字要一起读,不能挑一行下结论。
练习 3:把标准化系数还原回原始单位,并说清它能不能用来比大小
用 Pipeline(StandardScaler + LinearRegression) 在训练集上拟合:
- 打印
named_steps的键; - 打印 7 个标准化系数,按 |系数| 从大到小排;
- 把系数除以
scaler.scale_还原回原始单位,与LinearRegression(tol=0)的原始系数比较最大差; - 回答:要回答"哪个特征更重要",该看标准化系数还是原始系数?
参考答案
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}")期望输出:
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④ 回答:要回答"哪个特征更重要",看标准化系数;但要解释"物理上变化多少", 必须用原始系数。
原始系数: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 个数字都会变。
面向工程实践:预处理是管道的第一段
这一章的结论落到工程上,就是三句话:
① 判断"要不要缩放"只有一个问题
问:"这个模型会不会用到特征的【绝对大小】?"
├─ 会(距离 / 核函数 / 梯度 / 正则化惩罚)→ 必须缩放
└─ 不会(只比较大小顺序的树) → 可以不缩放,但别省掉"想清单位"这一步② 判断"缩放到底管不管用",只有一个动作
把 scaler 装进 Pipeline,然后看【训练 R² 和测试 R² 的变化方向】:
两个一起涨且涨法一致 → 数值路径被修好(本章 11.3),不是泛化变强
只有测试涨 → 这才可能是泛化收益
两个一起跌 → 缩放把某个信息抹掉了(检查标准差极小的列)③ 一个可以复制到项目里的自检函数
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 会放大它的噪声")★ 这个函数的三行输出对应本章的三个坑:
rank_ < 特征数 → 容差截断(11.3)
训练/测试一起涨 → 数值问题,不是泛化问题(11.2)
标准差极小的列 → 缩放会放大噪声(11.6)★ 最后一句,也是通往下一章的桥:
量纲对齐之后,还有一类"看起来是数字、其实不是数字"的列 —— 类别列。它们不能减均值、不能除标准差,只能换一种方式进模型。那是下一章的事。