先说说我为什么想整理这一篇。做机器学习项目的同学应该都有过这种体验:模型在训练集上跑出接近满分的成绩,一丢到验证集上立刻原形毕露,损失曲线往上翻,预测误差大得离谱。这就是典型的过拟合。而机器学习里对付过拟合最直接、最标准的手段之一,就是正则化。我打算把 L1、L2、弹性网、Dropout 这些常用正则化手段的原理讲透,配套给出可以直接运行的案例代码,也把这几年调正则化系数踩过的坑一并写出来。无论你是刚接触正则化的学生,还是已经在用 sklearn 和 PyTorch 做项目、想系统补一遍基本功的同学,都能从这篇文章里拿到可以直接上手的干货。
1. 正则化到底在治什么病:先给过拟合把个脉
1.1 过拟合的典型症状与判断方法
先聊一个很直观的场景。你用多项式去拟合一条 sin 曲线,次数从 3 次提到 7 次的时候,训练误差会低到让人兴奋,可一旦用独立的测试集去算,误差反而比 3 次多项式大好几倍。这个现象不是某个模型特有的,线性回归、逻辑回归、神经网络、梯度提升树全都逃不掉。本质上,模型在训练阶段“记”住了数据本身的噪声,而不是真正学到了背后的规律。
判断过拟合不需要什么高深工具,三个信号就够了:第一,训练集损失持续下降,但验证集损失出现先降后升的反转点;第二,权重向量的绝对值普遍偏大,模型对输入的细微变化极其敏感;第三,把训练样本稍微加一点扰动,预测结果就会发生很大的波动。如果你发现自己训练的模型符合其中任意两条,就该考虑上正则化了。
我自己的习惯是:任何模型训练完,第一件事不是看精度,而是画一张训练/验证损失曲线。曲线图上如果两条线之间的“沟壑”越来越宽,说明方差在主导误差;如果两条线都比较高、贴在一起,说明偏差在主导。后一种情况加正则化基本没用,真正需要的是增加特征、换更强模型或者加深网络。
1.2 偏差-方差权衡:正则化的理论根基
正则化能生效,背后靠的是偏差-方差分解这个统计学习里的基本框架。一个模型的泛化误差大致可以拆成三块:噪声本身、偏差的平方、方差。写出来就是:
Err(x) = σ² + Bias² + Variance
噪声是数据天生带来的,任何模型都躲不掉,能控制的就是偏差和方差。偏差衡量的是模型平均下来和真实规律的偏离程度,方差衡量的是模型在不同训练集上表现的不稳定性。用打靶来类比:偏差是瞄得准不准,方差是手抖不抖。一个模型拟合能力太强,相当于瞄得很准但手一直在抖,打出去的子弹分布很散,这对应方差过大;模型过于简单,则是手稳了但瞄点本身偏了,对应偏差过大。
正则化做的事情,是在原来的经验风险上叠加一个结构风险惩罚项,新的目标函数写作:
J(w) = Loss(w) + λ·Ω(w)
Loss 是原始损失,Ω 是惩罚项,λ 是控制惩罚强度的正则化系数。加上惩罚项之后,模型不再只追求把训练集拟合到位,还要兼顾权重不要过于极端。这样做的代价通常是偏差略微上升,但换来的是方差大幅下降,总误差反而更小。理解这一点非常重要,因为它决定了正则化调参的方向:当验证集误差的高方差特征明显时,加大 λ;当训练集本身都拟合不好时,反而应该减小 λ 或者换模型。
2. L1 与 L2 的数学原理与几何直觉:为什么一个选边一个压小
2.1 L2 权重衰减的数学逻辑和更新公式
正则化家族里最常碰面的就是 L2,也叫岭回归,在神经网络里它还有个更常见的名字叫权重衰减(weight decay)。L2 的惩罚项是所有参数的平方和,目标函数是:
J = MSE + λ·Σ wᵢ²
为什么这个惩罚能把权重压小?把梯度下降的更新公式展开就一目了然。假设原始损失对 w 的梯度是 ∇L,加上 L2 惩罚后整体梯度变成 ∇L + 2λw,于是梯度下降更新为:
w ← w − η·(∇L + 2λw) = w·(1 − 2ηλ) − η·∇L
前面这一项 (1 − 2ηλ) 小于 1,相当于每一步更新都先把当前的权重等比压缩一圈,再沿梯度负方向移动。学习率 η 和惩罚系数 λ 越大,压缩比例就越狠。这也是“权重衰减”这个名字的由来:权重在每个 iteration 里都在被“缓慢磨损”,磨损的方向与梯度无关,纯粹向零收缩。
从另一个角度看,L2 给损失函数增加的是一个二次凸项,在最小化时会把原来可能病态的 Hessian 矩阵往对角方向拉一拉。岭回归的闭式解是 (XᵀX + λI)⁻¹Xᵀy,这里的 λI 相当于给 XᵀX 的对角线加了一个正的扰动,即使原矩阵接近奇异,求逆也稳定得多。所以 L2 在我看来有双重身份:既是过拟合的刹车,也是数值稳定性的保护垫。
2.2 L1 的特殊之处:稀疏解是怎么来的
L1 正则化(LASSO)的惩罚项是权重的绝对值之和:λ·Σ|wᵢ|。同样是“罚”,它和 L2 的脾气完全不同。L2 的梯度在接近零时会因为 2λw 这一项而变小,所以权重会无限趋近零但很少真正等于零;L1 的梯度则是常数符号:λ·sign(w)。这意味着只要权重不为零,惩罚对它的推力就恒定不变,不会因为“快接近零了”就温柔起来。而在 w=0 这个不可导点上,优化过程会用次梯度处理,结果就是许多权重被精确地推到零。
用几何图形来理解更直观。假设只有两个权重 w₁、w₂,L2 对应的约束区域是一个圆形,L1 对应的是一个菱形。损失函数的等值线是中心在最优解处的椭圆。不加约束时,椭圆中心落在哪里就是哪里;加了约束后,最优点必须在约束区域上。椭圆向外扩张的过程中,最先碰到圆形的点大概率不在坐标轴上,但碰到菱形的点却很容易落在菱形的顶点上,而顶点恰好就是 w₁=0 或 w₂=0 所在的坐标轴。因此,L1 天然具备稀疏化倾向,它真正做到了“不重要的特征直接归零”,相当于把特征选择和模型训练一步完成。
2.3 L1 与 L2 的对比和选型建议
把两者的差别整理成一个表,用起来就很清晰:
| 对比维度 | L2/权重衰减 | L1/LASSO |
|---|---|---|
| 惩罚表达式 | λ·Σ wᵢ² | λ·Σ绝对值 wᵢ |
| 梯度作用 | 按比例衰减,越接近 0 推力越弱 | 恒定符号推力,能精确归零 |
| 解的形态 | 权重变小但几乎不出现硬 0 | 部分权重精确为 0 |
| 典型场景 | 特征基本都有用,侧重防过拟合 | 高维稀疏、特征筛选、可解释性 |
| 主要风险 | 不具备特征选择能力 | 强相关特征会被随机选中一个 |
选型上我给一个务实的建议:如果你做的是表格型数据,特征几十维到几百维,且事先不知道哪些特征有用,优先试 L1 或弹性网,它能帮你完成一轮特征粗筛;如果特征之间相关性很高,直接用 L1 会有“随机挑选一个代表”的问题,这时候 L2 或弹性网表现更稳;如果特征数量超过样本数量,L1 最多只能选出与样本数相同数量的非零特征,这时候就要考虑先做降维,或者直接用弹性网。
3. 正则化系数 λ 怎么调:三步实操法
3.1 λ 的两极效应与量纲范围
λ 是正则化方法里最关键的旋钮。设成 0,正则化失效,原模型该怎么过拟合还怎么过拟合;设得太大,惩罚项压过原始损失,所有权重会被压向零,模型退化成近似常量预测。这个“中间找平衡”的过程,说白了就是偏差和方差的拉锯战。
实际操作中,λ 的搜索范围我一般不看线性等距,而是用对数网格,比如从 1e-4 到 1e2 之间均匀取 20 个点。原因很简单:λ 对模型效果的影响通常跨越多个数量级,线性网格会把大量计算浪费在无效区间。还有一点要特别注意——λ 的合理范围受特征尺度影响。如果特征没有标准化,取值范围大的特征对应的系数天然就小,惩罚对它不起作用;取值范围小的特征系数天然偏大,反而更容易被惩罚。所以我在所有需要加 L1/L2 的模型里,第一步永远是 StandardScaler 标准化,没有例外。
3.2 三步调参:标准化、粗搜索、看曲线
第一步,对所有数值特征做标准化,让每个维度的方差差不多是 1,这样惩罚项对不同特征的约束才是公平的。
第二步,用带交叉验证的模型直接粗搜 λ。以 sklearn 为例:
from sklearn.linear_model import RidgeCV, LassoCV, ElasticNetCV import numpy as np # 对数网格取 20 个候选 alphas = np.logspace(-3, 2, 20) ridge = RidgeCV(alphas=alphas, cv=5) ridge.fit(X_train, y_train) print("Ridge 最优 alpha:", ridge.alpha_) lasso = LassoCV(alphas=alphas, cv=5, random_state=42, max_iter=50000) lasso.fit(X_train, y_train) print("Lasso 最优 alpha:", lasso.alpha_) enet = ElasticNetCV( alphas=alphas, l1_ratio=[0.1, 0.5, 0.7, 0.9, 1.0], cv=5, random_state=42, max_iter=50000 ) enet.fit(X_train, y_train) print("ElasticNet 最优 alpha:", enet.alpha_, "l1_ratio:", enet.l1_ratio_)代码里l1_ratio是弹性网里 L1 惩罚所占的比重,取 1 时退化为 Lasso,取 0 时退化为 Ridge。实际运行时这套代码会把四个候选模型的最优 λ 一次跑完,数据量不大的情况下几秒就能出结果。粗搜结束后,如果你还想精调,可以在最优值周围的 0.5 倍到 2 倍区间内再细分一个网格,效果提升通常有限,但能做到心中有数。
第三步,也是最容易被跳过的一步:观察正则化路径图。把不同 λ 下各特征系数画出来,横轴是 λ,纵轴是系数,L1 会呈现一条条“断崖式”落到零的线,L2 则是平滑地向零收缩。画一次路径图,你就能直观看到 λ 在什么区间开始真正压制某个特征,这比只看一个交叉验证的最优点有用得多。
3.3 学习曲线验证:别只看训练集
网格搜索选出 α 之后,我还习惯再用学习曲线复查一遍。学习曲线展示的是训练集误差和验证集误差随训练样本量变化的曲线。如果两条曲线随着样本量增大始终保持着很宽的间距,说明模型当前依然是方差主导,可以继续增大 λ 验证一下;如果训练集本身误差就很高,说明偏差主导,加正则化意义不大。
这里尤其要强调一个容错心态:不要追求验证集上那个“理论最优 λ”的精确值。交叉验证本身带有随机性,λ 在最优值附近小范围波动对最终泛化效果的影响往往微乎其微。我在实践中经常遇到“α=0.013 和 α=0.021 差不了多少”的情况,与其纠结小数点,不如多跑几次不同的随机种子,确认结果稳定。
4. 正则化家族进阶:弹性网、Dropout 与 Early Stopping
4.1 弹性网:当 L1 和 L2 相遇
弹性网(ElasticNet)是把 L1 和 L2 揉在一起,目标函数是:
J = MSE + λ₁·Σ|wᵢ| + λ₂·Σ wᵢ²
它的价值在于解决了两个实际问题。第一个问题是高相关特征下的 L1 不稳定:两组强相关特征放在一起,Lasso 会随机选其中一个,换一组训练数据可能就换一个,这导致模型可解释性很差。弹性网里 L2 项的存在会让强相关的特征组“有难同当”,系数被同时压缩但不会被单独剔掉。第二个问题是 p > n 场景:Lasso 最多只能选出 n 个非零特征,但业务里常常特征上万、样本只有几千,弹性网通过 L2 项的稳定作用,可以绕过这个限制。
sklearn 中弹性网的主要参数有两个:alpha控制总惩罚强度,l1_ratio控制 L1 占比。我一般把l1_ratio当作一个连续旋钮来理解:0 是纯 L2,1 是纯 L1,0.5 就是一半一半。做业务项目时,如果对特征是否稀疏没有先验,我通常从l1_ratio=[0.1, 0.5, 0.7, 0.9]开始,让 CV 自己定。
4.2 Dropout 与 Early Stopping:深度学习的隐式正则化
神经网络里权重衰减对应 L2,但深度学习最常用的正则化其实是 Dropout 和 Early Stopping。Dropout 的思路特别朴素:训练时每个神经元以概率 p 随机“失联”,让网络不能过度依赖某几个节点;测试时再把所有神经元按保留概率缩放恢复。这个操作迫使模型学到冗余的、分布式的特征表示,本质上等价于同时训练了大量不同结构的子网络,最后取平均效果,方差自然就降下来了。
Early Stopping 连参数都不用动,做的是“管住训练轮数”。验证集指标连续多轮不涨就停,因为模型在训练后期往往已经开始把噪声学进权重,多跑一步都是伤害。它算是迭代轮数的隐式正则化,我会在所有神经网络训练里都开启,配合 10 到 20 轮的 patience,稳定效果立竿见影。
4.3 一批不太像正则化的正则化手段
还有一些手段不叫正则化,但实际效果和正则化殊途同归。数据增强是给输入加旋转、裁剪、噪声扰动,等于不断提醒模型“这些变化不该改变预测”,对决策边界的平滑效果非常明显。标签平滑把 one-hot 的硬标签变成 0.9/0.05 这类软标签,能抑制模型对训练样本过强的置信度。批量归一化也有一定的隐式正则化作用,因为每个 batch 的统计量都不同,等价于往中间层注入了一点噪声,只是它的主要作用还是加速收敛,不能当作主正则化来用。
在实际项目里,我不会把正则化手段全部堆上,而是按这个优先级走:先做 Early Stopping 和随机种子稳定,再加 Weight Decay,数据量小或类别不平衡时加数据增强,最后才考虑 Dropout。原因很简单:不加验证的无脑堆叠会掩盖真正起作用的那一个。
5. 三种框架的代码实现:numpy、sklearn 与 PyTorch
5.1 自造轮子:用 numpy 实现带 L2 的梯度下降
理解正则化最好的方式,是自己手写一次带惩罚项的梯度下降。下面这个例子用 10 维模拟数据,真实权重只有 3.0、-2.0、1.0、0.5 这 4 个非零,剩下的都是噪声特征,天然适合验证正则化能不能把无效特征压下去。
import numpy as np from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler np.random.seed(42) n, d = 500, 10 true_w = np.array([3.0, 0.0, 0.0, -2.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.5]) X = np.random.randn(n, d) y = X @ true_w + np.random.randn(n) * 0.3 scaler = StandardScaler() X = scaler.fit_transform(X) X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, random_state=42 ) def l2_gradient_descent(X, y, lr=0.01, lambd=0.0, epochs=3000): w = np.zeros(X.shape[1]) loss_history = [] for _ in range(epochs): pred = X @ w loss = np.mean((pred - y) ** 2) + lambd * np.sum(w ** 2) grad = 2 / len(y) * X.T @ (pred - y) + 2 * lambd * w w -= lr * grad loss_history.append(loss) return w, loss_history w_no_reg, _ = l2_gradient_descent(X_train, y_train, lambd=0.0) w_reg, _ = l2_gradient_descent(X_train, y_train, lambd=0.1) val_mse_no_reg = np.mean((X_val @ w_no_reg - y_val) ** 2) val_mse_reg = np.mean((X_val @ w_reg - y_val) ** 2) print("不加正则化 验证集 MSE:", round(val_mse_no_reg, 4)) print("加 L2 正则化 验证集 MSE:", round(val_mse_reg, 4))运行之后你会发现,不加正则化的权重会去拟合噪声,验证集 MSE 通常明显高于加了lambd=0.1的版本。把w_reg打印出来,权重普遍被压实到真值的附近,这就是 L2 的直观效果。这个手写版本虽然不会用在正式项目里,但它把公式里的每一项都变成了可看见的数值变化,对理解正则化机制帮助很大。
5.2 sklearn 标准流程:标准化、CV 与模型对比
正规的工程项目我一般直接上 sklearn,核心是别把标准化落下,以及用带 CV 的模型来选 α。完整流程延续前面模拟数据,代码把 Ridge、Lasso、ElasticNet 放在一起对比:
from sklearn.linear_model import Ridge, Lasso, ElasticNet from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from sklearn.model_selection import cross_val_score alphas = np.logspace(-3, 2, 20) for name, model_class in [("Ridge", Ridge), ("Lasso", Lasso), ("ElasticNet", ElasticNet)]: # ElasticNet 需要单独给 l1_ratio,这里取默认 0.5 if name == "ElasticNet": model = model_class(alpha=0.1, l1_ratio=0.5, max_iter=50000) else: model = model_class(alpha=0.1, max_iter=50000) pipe = make_pipeline(StandardScaler(), model) # 或者直接用前面已经标准化的 X 也可以 cv_score = cross_val_score(pipe, X_train, y_train, cv=5, scoring="neg_mean_squared_error").mean() print(f"{name} CV R2: {cv_score:.4f}")实际跑的时候,更推荐直接用RidgeCV、LassoCV、ElasticNetCV这三个内置 CV 的类,它们在内部完成 α 的搜索。如果你想知道具体每个模型在 α 变化时表现如何,可以把 α 网格传入alphas参数,让模型自动输出最优值。这里有一个容易被忽视的体验:LassoCV默认的max_iter在高维数据下可能不够,训练时如果出现警告,就把max_iter提到 50000 或者更大,同时加重tol的精度设置。
5.3 PyTorch 里的 weight_decay 与手动 L1
神经网络里的 L2 正则化不用自己往 loss 里加惩罚项,直接在优化器里设weight_decay参数就行。PyTorch 的 Adam 和 SGD 都支持这个参数,它在梯度下降内部等价于加入 L2 惩罚:
import torch import torch.nn as nn X_t = torch.tensor(X_train, dtype=torch.float32) y_t = torch.tensor(y_train, dtype=torch.float32).view(-1, 1) model = nn.Linear(X_t.shape[1], 1) optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=0.01) loss_fn = nn.MSELoss() for epoch in range(5000): optimizer.zero_grad() pred = model(X_t) loss = loss_fn(pred, y_t) loss.backward() optimizer.step() with torch.no_grad(): val_pred = model(torch.tensor(X_val, dtype=torch.float32)).squeeze() val_loss = loss_fn(val_pred, torch.tensor(y_val, dtype=torch.float32)) print("PyTorch + weight_decay 验证集 loss:", val_loss.item())很多人第一次用weight_decay会好奇它和手动往 loss 里加lambd * sum(w**2)是否完全等价。严格说,PyTorch 是在梯度上直接扣除了衰减项,而手动加惩罚是在 loss 里计算后再对整体求梯度,两者在标准 SGD 下是等价的,在 Adam 这类自适应优化器下会有细微差别。不过在实际调参中,这个差别对最终效果的影响通常可以忽略,你把weight_decay当作 L2 系数用就行。
如果项目里确实需要 L1 稀疏性,PyTorch 没有内置的简单开关,需要手动把惩罚项加到 loss 里。注意只对权重做惩罚、不对 bias 做,因为 bias 没有“特征相关性”的语义:
# 在每个 step 里,loss 单独加上 L1 惩罚 l1_lambd = 0.001 l1_penalty = l1_lambd * sum(p.abs().sum() for p in model.parameters() if p.dim() > 0) loss_to_backward = loss_fn(pred, y_t) + l1_penalty6. 正则化实战避坑清单与常见问题
6.1 高频翻车点:七个踩过的坑
这七年里,我在正则化上踩过的坑比任何一种技巧都多。挑七个最常见的整理成表,看到任何一个都建议直接绕道:
| 坑点 | 现象 | 正确做法 |
|---|---|---|
| 忘了标准化 | 大范围特征被“豁免”,小范围特征被罚过度 | 任何 L1/L2 前先 StandardScaler |
| α 搜索范围用线性网格 | 要么全在无效区间,要么错过最优数量级 | 用 logspace 覆盖多个数量级 |
| 用全量数据选 α 再交叉验证 | 验证结果虚高,泛化表现差 | α 选择必须在 CV 内部完成 |
| L1 筛完特征不复查 | 强相关特征被随机删除,业务解释失真 | 配合相关性分析检查被删特征 |
| 深度模型用 L1 做稀疏 | 训练损失很难降,优化不稳定 | 默认用 weight_decay,特殊场景再用 L1 |
| 正则化惩罚包含 bias | 截距被无意义压缩,模型预测偏差增大 | 手动实现时只惩罚权重维度 |
| 树模型上照搬 L1/L2 | 没提升还增加参数数量 | 树模型靠深度、叶子数和样本权重控容量 |
其中“用全量数据选 α 再交叉验证”这个错是最隐蔽的。很多人先跑一次LassoCV选出 α,然后用这个固定 α 去做常规交叉验证,结果验证分数特别漂亮,上线后却崩了。原因很简单:α 的信息已经从全量数据流进了模型,交叉验证形同虚设。正确的做法是把 α 搜索放进 CV 的每一折里面,也就是直接使用LassoCV(cv=5)这种内置流程,它会自动保证每一折都只用自己的训练部分选 α。
6.2 正则化无效时的下一步:排查顺序
有时候加了正则化,验证集误差纹丝不动,这时候不要死磕。先排查标准化是否做好,再确认 α 搜索范围是否覆盖了有效区间;如果模型本身容量很小,比如只剩一个线性层的欠拟合状态,加惩罚只会雪上加霜。此时最该做的是提升原始表达能力:增加特征、降低噪声、上更灵活的非线性模型,等你把模型容量涨上去了,正则化才重新有发挥空间。
一批没有正则化的异常数据也可能让惩罚失效,比如特征里有大量缺失值、离群点、稀疏假特征。处理这些脏数据,效果往往比调 λ 来得更直接。所以我的排查顺序永远是数据先行、损失曲线次之、α 搜索最后。
最后分享一个调参时的小体会
这几年下来,我逐渐养成一个习惯:无论哪个项目,先把不带正则化的模型跑一遍,拿到训练集与验证集的误差差,再决定要不要加正则化、加多大。因为正则化不是万能药,它只治“方差过大”这一种病。如果训练误差本来就高,加再大的 λ 也只是把模型推得更笨而已。正确定位问题,比满屏堆技巧重要得多。