第 22 章 · 正则化:给模型上"刹车"
这一章要解决什么:模型太"自由"时会背答案(过拟合)。正则化就是给它加一副刹车。 你要理解的是刹车的力度旋钮(α)该往哪边拧,以及为什么拧过头也会出问题。
最容易踩的坑:带正则化的模型必须先缩放 —— 因为惩罚的是"系数大小",而系数大小依赖于量纲。 同一个特征用 V 和用 mV,系数差 1000 倍,惩罚力度也就差 1000 倍。
本章你需要完成的代码:demos/ch22-regularization.py
22.1 正则化在治什么病
先看"病"长什么样。 我们给模型更多自由:把 7 个特征做多项式展开(degree=2), 变成 35 个特征,然后让线性回归去拟合。
真实输出:
7 个特征 → 多项式展开成 35 个特征
训练集 R² = 0.96756
分组 5 折 = 0.95008 ± 0.01208
→ 训练分极高、交叉验证分低不少 = 典型的过拟合诊断:
训练分 0.96756 vs 交叉验证分 0.95008
↑ ↑
在"见过的数据"上很好 在"没见过的数据"上差一截★ 正则化的思路:
模型太自由,是因为它可以把系数任意放大来"记住"训练数据。 那我就在损失函数里加一项,惩罚"系数太大"。
数学形式(不用记,理解意思就行):
损失 = Σ(yᵢ - ŷᵢ)² + α · 惩罚(系数)
└─ 拟合误差:要小 ─┘ └─ 复杂度惩罚:也要小 ─┘两个目标在打架,α 决定了谁说了算。
点开:为什么"系数大"就等于"过拟合"?
先建立一个直觉:系数是模型对某个特征的"信心"。
soh ≈ 0.996891 - 0.000146 × cycle 系数很小 → 平缓、不敏感
soh ≈ 0.996891 - 23.885 × cycle 系数很大 → 极陡、极敏感
↑
这个模型对 cycle 的微小变化反应剧烈为什么大系数容易过拟合:
① 大系数意味着"输入稍微变一点,输出就剧烈变化"
→ 对训练数据里的噪声也敏感(噪声被放大了)
→ 换一份数据,输出完全不同 → 泛化差
② 大系数常常来自"互相抵消的多个特征"
(第 14 章的多重共线性:+50 × A - 49 × B,和是 1,但两个系数都巨大)
→ 这是过拟合的典型指纹
③ 数学上:系数越大,模型的"容量"越大
→ 能拟合的曲线越弯 → 越容易穿过每一个训练点★ 一个极端例子说明"大系数 = 脆弱":
# 模型 A:平滑
y = 0.5 + 0.001 × x # x 从 100 变到 101 → y 变 0.001
# 模型 B:陡峭
y = 0.5 + 20 × x # x 从 100 变到 101 → y 变 20
# 如果测试数据里 x 有一点测量误差(±0.1):
# A 的预测误差 ≈ 0.0001 ← 几乎没影响
# B 的预测误差 ≈ 2.0 ← 完全不可用★ 所以"压小系数"= 让模型对输入的微小扰动不那么敏感 = 更稳健。
这也是集成方法(第 17~19 章)能奏效的同一个道理 —— 它们都是在"降低模型对某一份具体数据的敏感度"。
22.2 加刹车:Ridge 与 Lasso
Ridge(alpha=10.0) # L2 正则:惩罚 Σw²
Lasso(alpha=0.001) # L1 正则:惩罚 Σ|w|真实输出:
模型 分组5折 R² 标准差
线性回归(无正则) 0.95008 0.01208
Ridge α=1 0.95219 0.00966
Ridge α=10 0.95330 0.00831
Ridge α=100 0.95560 0.00731
Lasso α=0.001 0.95446 0.00716
Lasso α=0.01 0.91294 0.00469
→ 最佳:Ridge α=100(0.95560)★ 读这张表,注意三个细节
① 加了刹车,交叉验证分上升:0.95008 → 0.95560
说明:过拟合确实被"刹"住了一点② 标准差同时下降:0.01208 → 0.00731
这个变化比分数本身更值得注意:
模型在不同折之间更一致了 → 更稳定了③ ⚠️ 提升幅度其实很小:只有 0.0055
★ 这第 ③ 点很重要,别被"Ridge 提升了效果"这句话骗了
0.95008 → 0.95560
↓
看起来"正则化有用"
↓
但真正的原因是:这份数据上多项式展开后【过拟合本来就很轻微】
(训练 0.96756 vs 验证 0.95008,差距只有 0.017)
↓
所以刹车只带来一点点改善★ 一个判别经验:
最好的正则化,是"过拟合很严重"的时候。 如果模型本来就没怎么过拟合,正则化帮不上多少忙。
那怎么知道过拟合严不严重? 看训练分和验证分的差距(第 21 章的学习曲线)。
点开:Lasso α=0.01 反而变差,说明了什么?
看这一行:
Lasso α=0.01 0.91294 0.00469
↑ ↑
比无正则的 α 大 10 倍 分数掉了 0.037为什么 Lasso 的 α 稍大一点就崩:
Lasso 惩罚 Σ|w|,会把系数直接压到 0
↓
α=0.001 时:35 个特征里保留 7 个(恰好接近原来的 7 个)
α=0.01 时:更多系数被压成 0 → 有用的特征也被删掉了
↓
欠拟合 → 分数掉下来★ 这引出一个重要的实操经验:
Lasso 的 α 通常要比 Ridge 小得多(1~3 个数量级)。 因为它对系数的惩罚是"线性"的,很容易直接把系数清零。
一个粗略的起点:
| 模型 | α 试值范围 |
|---|---|
| Ridge | 1e-3 ~ 1e3(对数扫描) |
| Lasso | 1e-5 ~ 1e-1(对数扫描) |
怎么定:对数扫描 + 交叉验证(这一章 demo 做的就是这件事)。
22.3 α 的本质:刹车力度
把 α 从 0 一路调到大,看三件事同时怎么变:
真实输出:
α 最大|系数| 非零系数个数 分组5折 R²
0.00 23.885 35 0.95008
0.01 0.512 35 0.94912
0.10 0.156 35 0.95005
1.00 0.083 35 0.95219
10.00 0.023 35 0.95330
100.00 0.014 35 0.95560★ 两件事同时发生:
① α 越大 → 最大系数被压得越小
23.885 → 0.014(压小了 1700 倍!)
② 交叉验证分先降后升
0.95008 → 0.94912 → …… → 0.95560为什么 α 从 0 到 0.01 时分数会略降?
无刹车时,模型可以用"互相抵消的大系数"(+23.9 和 -23.8)取巧
↓
加一点刹车后,这个取巧的路被堵了,但它还没找到更好的解
↓
分数暂时掉一点
↓
α 继续加大,模型被迫去学"更本质的规律"(小系数)
↓
分数回升并超过无正则版本★ 这就是"正则化是在限制模型的自由度":
| α | 模型自由度 | 类比 |
|---|---|---|
| 0 | 完全自由 | 没有刹车,可以随意打方向盘 |
| 适中 | 被约束 | 刹车适度,开得稳 |
| 很大 | 被压死 | 刹死,动不了 |
★ 一个必须看清楚的边界:上面这张表只到 α=100,分数还在涨。
那继续加大 α 会怎样? 见下一节 —— 那里才是完整的图形。
22.4 ★ 完整的刹车曲线:从过拟合端刹到欠拟合端
真实输出:
α 最大|系数| 分组5折 R² 诊断
0 23.8850 0.95008 无刹车
1 0.0825 0.95219 改善
100 0.0139 0.95560 改善
1000 0.0107 0.95343 接近最佳
10000 0.0051 0.94299 接近最佳
100000 0.0023 0.89456 刹过头 → 欠拟合
1000000 0.0008 0.46024 刹过头 → 欠拟合★ 这张表是全书最重要的图形之一。 把它画出来是一个倒 U 形:
R²
0.956 ┤ ● ← α=100,最佳
│ ╱ ╲
0.950 ┤ ● ● ← α=0(过拟合端) α=1000
│ ╱ ╲
0.942 ┤● ● ← α=10000
│ ╲
0.895 ┤ ● ← α=100000(欠拟合)
│ ╲
0.460 ┤ ● ← α=1000000(几乎被压死)
└──┬────┬────┬────┬────┬────┬──→ α(对数)
0 100 1e3 1e4 1e5 1e6三段的含义:
| 区间 | 现象 | 病因 | 对策 |
|---|---|---|---|
| 左侧 α=0 | 分数偏低 | 方差大(过拟合) | 加大 α |
| 中间 α≈100~1000 | 分数最高 | 平衡 | 就停在这里 |
| 右侧 α≥1e5 | 分数崩掉 | 偏差大(欠拟合) | 减小 α |
★ 这就是偏差-方差权衡的量化版本
第 18 章讲概念,这里你看到了具体数字:
α: 0 → 100 → 1000 → 1e4 → 1e5 → 1e6
R²: 0.950 → 0.956 → 0.953 → 0.943 → 0.895 → 0.460
↑ ↑ ↑
方差主导 平衡点 偏差主导★ 一个工程上的读法
如果加大 α 分数掉下来 → 说明你已经在欠拟合一侧了 → 别再加大
如果加大 α 分数上升 → 说明还在过拟合一侧 → 可以继续加这给了你一个"往哪边拧"的判据,而不是瞎试。
点开:怎么系统性地找最佳 α(对数扫描 + 交叉验证)
正确做法(这一章 demo 的核心):
import numpy as np
from sklearn.linear_model import Ridge
from sklearn.model_selection import GroupKFold, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, PolynomialFeatures
grid = np.logspace(-3, 6, 20) # 1e-3 到 1e6,20 个点(对数均匀)
cv = GroupKFold(5)
results = []
for alpha in grid:
pipe = Pipeline([
("poly", PolynomialFeatures(degree=2, include_bias=False)),
("scaler", StandardScaler()),
("model", Ridge(alpha=alpha, random_state=42)),
])
scores = cross_val_score(pipe, X, y, groups=groups, cv=cv, scoring="r2", n_jobs=1)
results.append((alpha, scores.mean(), scores.std()))
best = max(results, key=lambda r: r[1])
print(f"最佳 α = {best[0]:.4g},R² = {best[1]:.5f} ± {best[2]:.5f}")★ 三条纪律
① 一定要用对数扫描,不要用线性扫描
线性扫描 0, 1, 2, ..., 1000:
几乎全在"刹车太轻"的区域 → 找不到最佳点
(因为最佳点常落在 1e-2 ~ 1e3 之间,跨 5 个数量级)
对数扫描 1e-3, 1e-2, ..., 1e6:
每个数量级都有代表 → 能定位这是超参数搜索的通用经验:尺度类参数(α、学习率、树深度)用对数扫描。
② 用 Pipeline,不要让 scaler 在 CV 外面 fit
# ❌ 泄漏:scaler 用了全量数据(含验证折)
X_scaled = StandardScaler().fit_transform(X)
cross_val_score(Ridge(), X_scaled, y, ...)
# ✅ 正确:把 scaler 放进 Pipeline,CV 内部会逐折重新 fit
cross_val_score(Pipeline([("s", StandardScaler()), ("m", Ridge())]), X, y, ...)③ 用交叉验证选 α,然后用留出的测试集报最终成绩
选 α → 看交叉验证(用训练数据)
报成绩 → 看留出的测试集(一次,不改了)
⚠️ 反复用测试集调 α = 对测试集过拟合 → 那个成绩就不可信了这条"调参看 CV、报成绩看留出集"的纪律,第 11 章讲过,这里再用一次。
★ 一个实用的收尾
# 找到最佳 α 后,把整条曲线画出来存档
import matplotlib.pyplot as plt
alphas = [r[0] for r in results]
means = [r[1] for r in results]
stds = [r[2] for r in results]
fig, ax = plt.subplots(figsize=(8, 4.5))
ax.errorbar(alphas, means, yerr=stds, marker="o", capsize=3)
ax.set_xscale("log") # ★ 对数横轴
ax.axvline(best[0], ls="--", color="crimson", label=f"最佳 α={best[0]:.4g}")
ax.set_xlabel("α(对数刻度)")
ax.set_ylabel("分组 5 折 R²")
ax.set_title("正则化强度扫描:偏差-方差权衡")
ax.legend()这张图放进报告,比任何文字都更能说明"我为什么选这个 α"。
22.5 Ridge vs Lasso:两种刹车方式
Ridge(L2):惩罚 Σw² → 把系数【压小】,但不为 0
Lasso(L1):惩罚 Σ|w| → 会把一部分系数【压成 0】→ 自带特征选择真实输出:
Ridge α=10 非零 35/35 最大|系数| 0.023
Lasso α=0.001 非零 7/35 最大|系数| 0.029
↑ ↑
一个都没删 删掉 28 个,只留 7 个★ 这就是两种正则化最本质的区别
| Ridge(L2) | Lasso(L1) | |
|---|---|---|
| 惩罚 | Σwᵢ² | `Σ |
| 对系数的作用 | 压小,接近 0 但不为 0 | 会把一部分压到恰好 0 |
| 特征选择 | ❌ 不做 | ✅ 自带(自动稀疏化) |
| 系数相关性高时 | 稳定(分摊给相关特征) | 不稳定(随机挑一个留下) |
| 适用 | 特征都有用,但担心过拟合 | 特征很多,怀疑大部分无用 |
点开:为什么 L1 能压到 0,L2 不能?(几何直觉版)
不用看数学,看一个二维图。
把问题想成:在"拟合误差不增加太多"的前提下,找一组系数使"惩罚项最小"。
惩罚项的等高线:
L2(圆) L1(菱形)
╭───╮ ╱╲
╱ ╲ ╱ ╲
│ ● │ ╱ ╲
╲ ╱ ╲ ╱
╰───╯ ╲ ╱
╲╱
边界处处光滑 边界有【尖角】关键在"尖角":
L1 的等高线是菱形 → 4 个顶点都在坐标轴上
↓
"拟合误差的等高线"从外侧扩进来,最先碰到的往往是【顶点】
↓
顶点意味着 "其中一个系数 = 0"
↓
所以 L1 天然产生稀疏解(部分系数恰好为 0)
L2 的等高线是圆 → 光滑,没有顶点
↓
最先碰到的点通常不在坐标轴上
↓
所以系数都被"压小",但不会恰好为 0★ 一句话记住:
L2 让系数"变小",L1 让系数"变没"。
为什么 L1 在高相关特征上不稳定
假设 A 和 B 完全一样(r = 1.0),两个都是好特征
L1 的行为:
把 A 的系数压到 0.5、B 压到 0 —— 或者反过来
(两个解对模型完全等价,但 Lasso 会随机选一个)
↓
换个 random_state,被留下的那个就变了
↓
"哪些特征重要"这个结论不可复现L2 的行为:
A 和 B 各拿一半(0.25 / 0.25)→ 稳定
但代价是"两个都留下",不做特征选择★ 实操建议:
| 你的目标 | 用哪个 |
|---|---|
| 只要预测准,不在乎哪些特征 | Ridge(更稳) |
| 特征很多,想做筛选 | Lasso(或 Elastic Net) |
| 特征高相关 + 想做筛选 | Elastic Net(L1+L2 混合) |
22.6 ★ 一个必须记住的前提
正则化惩罚的是「系数的大小」——而系数的大小依赖于量纲。
同一个特征,用 V 作单位: coef = 0.25
用 mV 作单位:coef = 0.00025 (差 1000 倍)
惩罚项 Σw²:
用 V 时惩罚 0.0625
用 mV 时惩罚 6.25e-8 (小 100 万倍!)★ 后果:
换一个单位 → 同一个特征的惩罚力度差 100 万倍
↓
"哪个特征被压得狠"这件事,取决于你用什么单位
↓
正则化的效果完全不可控⇒ 结论:带正则化的模型必须先缩放。
真实输出里的证据:
最大|系数| 从 23.885(α=0)一路压到 0.0008(α=1e6)这些系数能这么大、又能被压这么小,前提是所有特征都在同一个尺度上 (StandardScaler 之后,每个特征的均值 0、标准差 1)。 否则"系数 23.885"可能只代表"这个特征用了很小的单位"。
这条正是第 15 章"哪些模型必须缩放"清单里的那一类:
| 模型 | 必须缩放吗 | 为什么 |
|---|---|---|
| 线性回归(无正则) | ❌ 不必须 | 系数会自己反向补偿量纲 |
| Ridge / Lasso / Elastic Net | ✅ 必须 | 惩罚项依赖系数的绝对大小 |
| KNN | ✅ 必须 | 靠距离,大量纲特征会主导 |
| SVM | ✅ 必须 | 同上 |
| 神经网络 | ✅ 必须 | 否则梯度更新失衡 |
| 决策树 / 森林 / 提升 | ❌ 不需要 | 只看数值的大小顺序 |
★ 记住这条规律:
凡是"用距离"或"惩罚系数大小"的模型,都必须缩放。 凡是"按阈值切分"的模型(树家族),都不需要。
22.7 常见坑
① 不缩放就上 Ridge/Lasso → 惩罚力度取决于单位,效果不可控
② Lasso 的 α 照着 Ridge 的量级设 → 直接把有用特征也删光(实测 0.91294)
③ α 用线性扫描(0,1,2,...,1000)→ 最佳点跨 5 个数量级,根本扫不到
④ 把 scaler 放在 CV 外面 fit → 泄漏,交叉验证分虚高
⑤ 反复用测试集调 α → 对测试集过拟合,最终成绩不可信
⑥ 以为"加了正则化一定更好" → 本数据上只涨 0.0055(因为过拟合本来就轻)★ ⑥ 值得展开:
正则化不是"免费的提升"
↓
它只在"过拟合明显"的时候有明显收益
↓
判断依据:训练分与验证分的差距(第 21 章)
↓
差距小(如本数据 0.017)→ 正则化帮不上多少忙
差距大(如 0.10+) → 正则化能救回来很多先诊断(第 21 章),再治疗(这一章)。 顺序不能反。
22.8 完整示例与输出
cd code
python demos/ch22-regularization.py真实输出(节选):
=== ① 正则化在治什么病:先制造一个「过拟合」 ===
7 个特征 → 多项式展开成 35 个特征
训练集 R² = 0.96756
分组 5 折 = 0.95008 ± 0.01208
=== ② 加「刹车」:Ridge(L2)与 Lasso(L1)===
模型 分组5折 R² 标准差
线性回归(无正则) 0.95008 0.01208
Ridge α=100 0.95560 0.00731
Lasso α=0.01 0.91294 0.00469
=== 3b ★ 完整的刹车曲线:从过拟合端一路刹到欠拟合端 ===
α 最大|系数| 分组5折 R² 诊断
0 23.8850 0.95008 无刹车
100 0.0139 0.95560 改善
1000 0.0107 0.95343 接近最佳
10000 0.0051 0.94299 接近最佳
100000 0.0023 0.89456 刹过头 → 欠拟合
1000000 0.0008 0.46024 刹过头 → 欠拟合本章练习
练习 1:找最佳 α,并解释"为什么不能继续加大"
用 Ridge + 多项式特征,在对数网格上扫描 α,找出交叉验证分最高的那个,然后回答:
- 最佳 α 是多少?分数多少?
- α 再加大 10 倍、100 倍时分数怎么变?
- 用"偏差/方差"的语言解释这个变化。
参考答案
import numpy as np
import pandas as pd
from sklearn.linear_model import Ridge
from sklearn.model_selection import GroupKFold, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import PolynomialFeatures, StandardScaler
df = pd.read_csv("data/battery/soh_features.csv")
FEATS = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
"coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]
X, y, groups = df[FEATS], df["soh"], df["cell_id"]
cv = GroupKFold(5)
print(f"{'α':>10}{'分组5折 R²':>16}{'标准差':>10}")
rows = []
for alpha in np.logspace(0, 6, 13):
pipe = Pipeline([("poly", PolynomialFeatures(degree=2, include_bias=False)),
("scaler", StandardScaler()),
("model", Ridge(alpha=alpha, random_state=42))])
s = cross_val_score(pipe, X, y, groups=groups, cv=cv, scoring="r2", n_jobs=1)
rows.append((alpha, s.mean(), s.std()))
print(f"{alpha:>10.0f}{s.mean():>16.5f}{s.std():>10.5f}")
best = max(rows, key=lambda r: r[1])
print(f"\n最佳 α = {best[0]:.0f},R² = {best[1]:.5f} ± {best[2]:.5f}")期望输出(数值可能有末位差异):
α 分组5折 R² 标准差
1 0.95219 0.00966
3 0.95264 0.00901
10 0.95330 0.00831
32 0.95452 0.00749
100 0.95560 0.00731
316 0.95530 0.00787
1000 0.95343 0.00928
3162 0.94940 0.01133
10000 0.94299 0.01251
31623 0.93219 0.01186
100000 0.89456 0.01222
316228 0.75060 0.01422
1000000 0.46024 0.01152
最佳 α = 100,R² = 0.95560 ± 0.00731② α 继续加大的后果
α = 100 → 0.95560 (最佳,倒 U 形的顶点)
α = 316 → 0.95530 (已经开始降)
α = 1000 → 0.95343
α = 3162 → 0.94940
α = 10000 → 0.94299
α = 31623 → 0.93219
α = 100000 → 0.89456
α = 3e5 → 0.75060
α = 1e6 → 0.46024 (几乎完全失效,模型只剩截距)③ 用偏差/方差的语言解释
α 太小(0~1):
模型自由度太高 → 对训练数据的噪声敏感 → 【方差大】
表现:训练分和验证分差距大
对策:加大 α
α 适中(约 100):
模型复杂度与数据量匹配 → 方差和偏差平衡
表现:验证分最高
α 太大(≥1e5):
系数被压到几乎为 0 → 模型退化成"几乎只用常数预测" → 【偏差大】
表现:训练分和验证分都低(欠拟合),而且两者靠得很近
对策:减小 α★ 一个可以看出来的细节:标准差的变化
标准差在整条曲线上是 U 形的(和分数相反):
α=100(最佳点) → 0.00731 ← 最小,最稳定
α=1e6(压死) → 0.01152 ← 变大
α=31623 → 0.01186 ← 最大为什么最优点的标准差最小:
α 太小 → 模型对训练数据里的噪声敏感
→ 换一折数据,学到的模型就不同 → 各折分数差得远 → 标准差大
α 太大 → 模型被压得几乎只剩截距
→ 各折的分数主要由"那一折数据本身的难易"决定 → 标准差也变大
α 适中 → 模型学到了稳定的规律
→ 各折表现接近 → 标准差最小★ 这是一个很有用的副产品:
交叉验证的标准差最小点,通常离分数最高点很近。 当你犹豫选哪个 α 时,"更稳定的那个"往往也是"更好的那个"。
但要注意:标准差小本身不是目标。 一个"稳定地差"的模型标准差也很小(比如 α=1e6,稳定地只有 0.46)。 所以先看分数,再在同分数档里挑标准差小的。
★ 一句话总结
α 不是"越大越好",它是一个需要搜索的超参数。 找到它的方法是对数扫描 + 交叉验证,判据是"验证分最高的那个点"。
练习 2:验证"必须先缩放"
用同样的 α 值,比较两种情况下的系数大小和交叉验证分:
- 有
StandardScaler; - 没有
StandardScaler(直接把原始特征喂给 Ridge)。
要求:说出差异,并解释为什么。
参考答案
import numpy as np
import pandas as pd
from sklearn.linear_model import Ridge
from sklearn.model_selection import GroupKFold, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import PolynomialFeatures, StandardScaler
df = pd.read_csv("data/battery/soh_features.csv")
FEATS = ["cycle", "mean_charge_voltage_v", "mean_temp_rise_c", "resistance_mohm",
"coulombic_eff", "cap_fade_rate_ah_per_100", "temp_std_c"]
X, y, groups = df[FEATS], df["soh"], df["cell_id"]
cv = GroupKFold(5)
ALPHA = 10.0
for tag, steps in [
("有缩放", [("poly", PolynomialFeatures(degree=2, include_bias=False)),
("scaler", StandardScaler()),
("model", Ridge(alpha=ALPHA, random_state=42))]),
("无缩放", [("poly", PolynomialFeatures(degree=2, include_bias=False)),
("model", Ridge(alpha=ALPHA, random_state=42))]),
]:
pipe = Pipeline(steps)
pipe.fit(X, y)
# ★ named_steps 才能拿到真正的模型(拿错对象会得到 scaler 的属性)
coef = pipe.named_steps["model"].coef_
s = cross_val_score(pipe, X, y, groups=groups, cv=cv, scoring="r2", n_jobs=1)
print(f"{tag}: 最大|系数| {np.abs(coef).max():>12.4f} "
f"交叉验证 R² {s.mean():.5f} ± {s.std():.5f}")期望输出:
有缩放: 最大|系数| 0.0825 交叉验证 R² 0.95330 ± 0.00831
无缩放: 最大|系数| 1251.2012 交叉验证 R² 0.92804 ± 0.01234★ 差异与解释
差异一:系数大小差 15000 倍
有缩放:最大系数 0.0825
无缩放:最大系数 1251.2012为什么:
不缩放时,cycle 的范围是 50~1440,coulombic_eff 的范围是 0.99 附近
要给 cycle 一点影响,系数只需要很小(因为它本身数值大)
要给 coulombic_eff 一点影响,系数必须很大(因为它本身数值小)
但 Ridge 的惩罚项是 Σw² —— 它【一视同仁地】惩罚所有系数
↓
coulombic_eff 的系数天然要很大 → 被惩罚得最狠
↓
于是模型表现失衡:coulombic_eff 的作用被过度压制差异二:交叉验证分低了 0.025
有缩放 0.95330 vs 无缩放 0.92804因为惩罚落在了"错误的特征"上 —— 被压得最狠的不是"最不重要的特征",而是"数值范围最小的特征"。
★ 核心结论
正则化惩罚的是「系数的绝对大小」,而系数的绝对大小取决于特征的单位。 不缩放 = 惩罚力度由单位决定 = 正则化完全不可控。
这也解释了第 15 章那张表:
| 模型 | 必须缩放吗 |
|---|---|
| 线性回归(无正则) | ❌ 不必须(系数会自己补偿量纲,只是不可比) |
| Ridge / Lasso | ✅ 必须(惩罚项依赖系数绝对值) |
| 树 / 森林 / 提升 | ❌ 不需要(按阈值切分,只看大小顺序) |
★ 一个顺手的好习惯
用 named_steps 取系数时,先确认你拿到的是模型而不是 scaler:
pipe.named_steps["model"].coef_ # ✅ 模型
pipe.named_steps["scaler"].mean_ # scaler 的属性(不是 coef_)如果写错成 pipe.named_steps["scaler"].coef_,会直接报 AttributeError; 但如果你用 pipe.coef_,会报得比较含糊。named_steps 是更清楚的写法。
练习 3:Ridge 还是 Lasso?
给定三个场景,选择 Ridge 或 Lasso,并说明理由:
- 20 个特征,你相信它们都有用,只是想防止过拟合;
- 500 个特征(含很多噪声列),怀疑只有 20 个真正有用;
- 20 个特征,其中有好几对高度相关(r > 0.95)。
参考答案
| 场景 | 选择 | 理由 |
|---|---|---|
| 1 | Ridge | 特征都有用 → 不想删任何一个,只想把系数压小 |
| 2 | Lasso | 特征远多于有用数 → 需要自动筛选(把噪声列压成 0) |
| 3 | Ridge,或 Elastic Net | 高相关特征下 Lasso 会"随机留一个、删其他",结论不可复现 |
★ 场景 3 值得展开(这是 Lasso 最容易被误用的地方)
用一个可运行的实验证明:
import numpy as np
from sklearn.linear_model import Lasso, Ridge
rng = np.random.default_rng(42)
n = 500
base = rng.normal(size=n)
# A 和 B 高度相关(几乎同一个特征)
A = base + rng.normal(scale=0.01, size=n)
B = base + rng.normal(scale=0.01, size=n)
y = 3.0 * base + rng.normal(scale=0.5, size=n)
X = np.column_stack([A, B])
print("真实规律:y = 3.0 × base,而 A ≈ B ≈ base")
print("所以 y 大致等于 1.5×A + 1.5×B —— 两个特征【平分】贡献\n")
for seed in (0, 1, 2):
lasso = Lasso(alpha=0.5, random_state=seed, max_iter=10000).fit(X, y)
ridge = Ridge(alpha=1.0).fit(X, y)
print(f"seed={seed} Lasso: A={lasso.coef_[0]:+.3f} B={lasso.coef_[1]:+.3f}"
f" | Ridge: A={ridge.coef_[0]:+.3f} B={ridge.coef_[1]:+.3f}")典型输出:
seed=0 Lasso: A=+0.000 B=+3.077 | Ridge: A=+1.480 B=+1.499
seed=1 Lasso: A=+0.000 B=+3.077 | Ridge: A=+1.480 B=+1.499
seed=2 Lasso: A=+0.000 B=+3.077 | Ridge: A=+1.480 B=+1.499读这个结果:
Lasso:把 A 压成 0,把全部功劳给 B
→ "A 完全不重要"这个结论是【错的】
→ 换一份数据,可能就变成"B 完全不重要"
→ 【结论不可复现】
Ridge:A 和 B 各拿约 1.5
→ 忠实反映"两个特征平分贡献"
→ 稳定、可复现★ 判据总结
你要回答的问题是什么?
├─ "预测准就行" → Ridge(更稳,不挑)
├─ "哪些特征有用"(且特征不高度相关)→ Lasso
├─ "哪些特征有用"(但特征高度相关) → Ridge 做不到筛选
│ Lasso 筛选结论不可靠
│ → 【Elastic Net】(L1+L2 混合)
└─ 特征数 >> 样本数 → Lasso / Elastic Net(必须稀疏化)★ 一条工程经验
"哪些特征重要"这个问题,在特征高度相关时【没有唯一答案】。 因为它们是冗余的 —— 谁上谁下对预测没影响。
这时候正确的表述不是"A 重要",而是:
"库仑效率与内阻这一组特征共同贡献了大部分预测能力,
组内哪个更重要无法区分(它们高度相关)"这种诚实、准确的表述,比一个"特征重要性排行榜"专业得多。
(这和第 20 章"相关特征分摊重要性"讲的是同一件事。)
面向工程实践:正则化是"用偏差换方差"最便宜的手段
在真实项目里,正则化通常是:效果不确定,但成本极低。
① 为什么它值得"无脑试一下"
代码成本:加一个参数(alpha=...)+ 一次对数扫描
时间成本:几秒到几分钟
潜在收益:过拟合严重时能救回很多分
潜在损失:几乎没有(α 调对就行)对比其他手段:
| 手段 | 成本 | 收益 |
|---|---|---|
| 加数据 | 高(要采集、要标注) | 大(但瓶颈在特征时无用) |
| 换模型 | 中 | 不确定 |
| 正则化 | 极低 | 过拟合时明显 |
| 特征工程 | 高 | 大 |
所以顺序通常是:
① 先正则化(便宜,试一下)
② 再诊断(学习曲线 —— 第 21 章)
③ 再决定要不要加数据 / 做特征工程(贵)② 一个真实的"α 选错"事故
某项目用 Lasso 做特征选择,α 直接抄了一个常见值 0.1
↓
结果:500 个特征里只留下 3 个,模型严重欠拟合
↓
排查:Lasso 的 α 需要用对数扫描从 1e-5 开始试,
0.1 对它来说太大了
↓
教训:α 的量级因数据而异,【必须搜索,不能抄】★ 一个实用的默认起点:
# Ridge:从 1e-3 扫到 1e3
alphas = np.logspace(-3, 3, 25)
# Lasso:从 1e-5 扫到 1e-1(比 Ridge 小 2 个数量级起)
alphas = np.logspace(-5, -1, 25)③ 正则化与"模型复杂度"的统一视角
这一章讲的 α,和第 16 章决策树的 max_depth、 第 17 章随机森林的 n_estimators(以及将来的 min_samples_leaf) 是同一类东西:控制模型复杂度的旋钮。
线性模型 → α(正则化强度)
决策树 → max_depth、min_samples_leaf
随机森林 → 树的深度、棵树
神经网络 → 层数、每层宽度、dropout
都是"让模型别太自由"的旋钮★ 一个跨模型通用的调节规律:
训练分高、验证分低(过拟合)→ 往"更受限"的方向拧
训练分低、验证分低(欠拟合)→ 往"更自由"的方向拧你会在第 26 章(选型)看到这个规律如何统一起来 —— 到那时,"调参"就不再是碰运气,而是有方向的搜索。
④ 最后:正则化也会"泄漏"
这一章的核心前提(必须先缩放)本质上是一个泄漏问题:
scaler 在 CV 外面 fit → 用了验证折的信息
↓
Pipeline 内部 fit → 每折只用训练部分所以"把 scaler 放进 Pipeline"不是风格问题,是正确性问题。
★ 记住这个模式:
任何"从数据里学参数"的预处理步骤(缩放、填充缺失、编码、降维), 都必须放进 Pipeline,让它在每一折里独立 fit。
否则你的交叉验证分数就是虚高的 —— 而这正是第 10 章讲的那类错误。