Weight Decay和L2正则化是一回事吗?用NumPy手撕一个SGD优化器来搞明白
Weight Decay与L2正则化的本质区别:用NumPy实现SGD优化器的深度解析
在深度学习的世界里,优化器扮演着至关重要的角色。而weight decay作为优化器中常见的参数,常常被初学者误认为是L2正则化的简单别名。今天,我们就抛开PyTorch和TensorFlow这些高级框架,仅用NumPy从零开始构建一个SGD优化器,通过代码层面的实现来揭示这两者之间的微妙关系。这种"造轮子"式的探索不仅能满足技术爱好者的底层求知欲,更能帮助我们在实际项目中做出更明智的参数选择。
1. 优化器基础:SGD的核心原理
随机梯度下降(Stochastic Gradient Descent, SGD)作为深度学习中最基础的优化算法,理解它的工作原理是掌握weight decay的前提。SGD的核心思想非常简单:通过计算损失函数关于参数的梯度,然后沿着梯度的反方向更新参数,逐步逼近最优解。
让我们先用NumPy实现一个最基础的SGD优化器:
import numpy as np class VanillaSGD: def __init__(self, params, lr=0.01): self.params = params # 待优化参数列表 self.lr = lr # 学习率 def step(self, grads): for param, grad in zip(self.params, grads): param -= self.lr * grad # 参数更新这个最简单的实现已经包含了SGD的所有核心要素:
params:需要优化的参数(通常是模型权重)lr:学习率,控制每次更新的步长step方法:接收梯度并执行参数更新
在实际应用中,我们通常会这样使用:
# 初始化模型参数(假设是一个简单的线性层) W = np.random.randn(10, 5) # 权重矩阵 b = np.zeros(5) # 偏置项 # 初始化优化器 optimizer = VanillaSGD([W, b], lr=0.01) # 训练循环(简化版) for epoch in range(100): # 前向传播计算损失... # 反向传播计算梯度... grads = [dW, db] # 假设已经计算出梯度 optimizer.step(grads)这种朴素的实现虽然简单,但已经能够完成基本的优化任务。不过它缺少了现代优化器的许多重要特性,其中之一就是weight decay。
2. Weight Decay的数学本质
Weight decay的概念最早可以追溯到20世纪80年代的机器学习研究。它的核心思想是对大权重值施加惩罚,防止模型过度依赖少数特征而导致过拟合。从数学上看,weight decay在原始损失函数的基础上增加了一个正则化项:
$$ L_{total} = L_{original} + \frac{\lambda}{2} \sum w^2 $$
其中:
- $L_{original}$是原始损失函数
- $\lambda$是weight decay系数
- $\sum w^2$是所有参数的平方和
这个公式看起来与L2正则化完全相同,这也是许多人将两者混为一谈的原因。但关键在于它们是如何被应用到优化过程中的。
让我们看看带有weight decay的SGD参数更新公式:
$$ w_{t+1} = w_t - \eta \nabla L_{original} - \eta \lambda w_t $$
其中$\eta$是学习率。可以看到,weight decay实际上是在每次参数更新时,额外减去当前权重值的一个比例($\eta \lambda w_t$)。
3. 实现带Weight Decay的SGD
现在,我们在之前的基础SGD实现上加入weight decay功能:
class SGDWithWeightDecay: def __init__(self, params, lr=0.01, weight_decay=0.): self.params = params self.lr = lr self.weight_decay = weight_decay def step(self, grads): for param, grad in zip(self.params, grads): # 关键变化:在更新中加入weight decay项 param -= self.lr * (grad + self.weight_decay * param)这个实现与基础SGD的唯一区别就是在梯度更新时加入了self.weight_decay * param项。让我们通过一个简单的例子来观察它的效果:
# 初始化参数 W = np.random.randn(10, 5) * 0.1 # 小随机初始化 b = np.zeros(5) # 创建两个优化器对比 vanilla_optim = VanillaSGD([W.copy(), b.copy()], lr=0.1) wd_optim = SGDWithWeightDecay([W.copy(), b.copy()], lr=0.1, weight_decay=0.1) # 模拟训练过程 for _ in range(100): # 假设梯度是随机值(仅用于演示) dW = np.random.randn(*W.shape) * 0.1 db = np.random.randn(*b.shape) * 0.1 vanilla_optim.step([dW, db]) wd_optim.step([dW, db]) # 观察参数变化 print("Vanilla SGD final weights mean:", np.mean(vanilla_optim.params[0])) print("Weight decay SGD final weights mean:", np.mean(wd_optim.params[0]))运行这个例子,你会发现weight decay版本的优化器确实使得权重值整体更接近零,这正是它的预期效果。
4. Weight Decay与L2正则化的关键区别
虽然weight decay和L2正则化在数学公式上看起来相同,但它们在实现上有一个关键区别:
- L2正则化:修改损失函数,影响梯度计算
- Weight decay:不改变损失函数,直接修改参数更新过程
在PyTorch等框架中,这个区别尤为明显。当你在优化器中设置weight_decay参数时,它并不会改变损失函数的计算,而是在优化器内部实现这个额外的衰减项。
让我们通过代码来展示这个区别:
# L2正则化的实现方式 def loss_with_l2(original_loss, params, l2_lambda): l2_penalty = 0.5 * l2_lambda * sum(np.sum(p**2) for p in params) return original_loss + l2_penalty # 使用L2正则化的训练循环 W = np.random.randn(10, 5) * 0.1 b = np.zeros(5) optimizer = VanillaSGD([W, b], lr=0.1) for epoch in range(100): # 原始损失计算 original_loss = compute_loss() # 假设已实现 # 添加L2惩罚项 total_loss = loss_with_l2(original_loss, [W, b], l2_lambda=0.1) # 计算梯度(现在会包含L2项的梯度) grads = compute_gradients(total_loss) optimizer.step(grads)相比之下,weight decay的实现更加简洁,因为它不需要修改损失函数的计算:
# 使用weight decay的训练循环 W = np.random.randn(10, 5) * 0.1 b = np.zeros(5) optimizer = SGDWithWeightDecay([W, b], lr=0.1, weight_decay=0.1) for epoch in range(100): # 只计算原始损失 loss = compute_loss() # 计算原始梯度(不含L2项) grads = compute_gradients(loss) optimizer.step(grads)在实际应用中,这两种方法在大多数情况下效果相似,但在某些特殊情况下(如使用自适应学习率优化器时)可能会有不同的表现。
5. 可视化Weight Decay的效果
为了更直观地理解weight decay的作用,我们可以创建一个简单的可视化实验。考虑一个过拟合的线性回归模型,我们将观察不同weight decay值对模型权重的影响。
import matplotlib.pyplot as plt # 生成一些带噪声的线性数据 np.random.seed(42) X = np.linspace(-3, 3, 50) y = 2 * X + np.random.randn(50) * 1.5 # 准备多项式特征(故意制造过拟合条件) X_poly = np.column_stack([X**i for i in range(1, 6)]) # 1到5次项 # 训练函数 def train_with_weight_decay(weight_decay): W = np.random.randn(5) * 0.1 optimizer = SGDWithWeightDecay([W], lr=0.01, weight_decay=weight_decay) losses = [] for epoch in range(1000): # 前向传播 pred = X_poly @ W loss = np.mean((pred - y)**2) losses.append(loss) # 反向传播 grad = X_poly.T @ (pred - y) / len(X) optimizer.step([grad]) return W, losses # 测试不同的weight decay值 w_decay_values = [0, 0.01, 0.1, 1] results = {wd: train_with_weight_decay(wd) for wd in w_decay_values} # 绘制权重值比较 plt.figure(figsize=(12, 5)) plt.subplot(1, 2, 1) for i, wd in enumerate(w_decay_values): plt.bar(np.arange(5) + i*0.2, results[wd][0], width=0.2, label=f'wd={wd}') plt.xlabel('Feature degree') plt.ylabel('Weight value') plt.title('Weight values with different decay') plt.legend() # 绘制损失曲线 plt.subplot(1, 2, 2) for wd in w_decay_values: plt.plot(results[wd][1], label=f'wd={wd}') plt.xlabel('Epoch') plt.ylabel('Loss') plt.title('Training loss') plt.legend() plt.tight_layout() plt.show()从可视化结果中,我们可以清晰地看到:
- 没有weight decay时(wd=0),模型会学习到较大的权重值,特别是高次项
- 随着weight decay增大,所有权重值都被压缩得更接近零
- 适当的weight decay(如0.01)可以在保持模型表达能力的同时防止过拟合
- 过大的weight decay(如1)会导致模型欠拟合,损失无法有效下降
6. Weight Decay在实际应用中的技巧
理解了weight decay的原理后,让我们看看在实际项目中如何有效地使用它:
学习率与weight decay的平衡
这两个超参数需要协同调整:
- 较高的学习率可能需要较小的weight decay
- 较低的weight decay可以配合较大的学习率
不同层的不同衰减
有时我们希望不同层使用不同的weight decay强度:
- 卷积层通常需要较小的weight decay
- 全连接层可能需要较强的weight decay
- 批归一化层通常不需要weight decay
# 分层设置weight decay的示例实现 class PerLayerSGD: def __init__(self, params, lr=0.01, weight_decays=None): self.params = params self.lr = lr self.weight_decays = weight_decays or [0.] * len(params) def step(self, grads): for param, grad, wd in zip(self.params, grads, self.weight_decays): param -= self.lr * (grad + wd * param) # 使用示例 conv_weights = np.random.randn(16, 3, 3, 3) # 卷积层参数 fc_weights = np.random.randn(256, 10) # 全连接层参数 optimizer = PerLayerSGD( [conv_weights, fc_weights], lr=0.01, weight_decays=[0.001, 0.01] # 全连接层使用更强的weight decay )与其他正则化方法的配合
Weight decay通常与其他正则化技术一起使用:
- Dropout
- 数据增强
- 早停法
- 批归一化
自适应优化器中的weight decay
在Adam、RMSprop等自适应优化器中使用weight decay需要特别注意:
- 这些优化器本身就有参数更新的缩放机制
- 某些实现(如AdamW)专门修正了weight decay在自适应优化器中的行为
# Adam与AdamW的对比实现(简化版) class AdamWithWD: def __init__(self, params, lr=0.001, betas=(0.9, 0.999), weight_decay=0.): self.params = params self.lr = lr self.betas = betas self.weight_decay = weight_decay self.m = [np.zeros_like(p) for p in params] # 一阶矩 self.v = [np.zeros_like(p) for p in params] # 二阶矩 self.t = 0 def step(self, grads): self.t += 1 for i, (param, grad) in enumerate(zip(self.params, grads)): # 加入weight decay grad = grad + self.weight_decay * param # Adam标准更新 self.m[i] = self.betas[0] * self.m[i] + (1 - self.betas[0]) * grad self.v[i] = self.betas[1] * self.v[i] + (1 - self.betas[1]) * grad**2 m_hat = self.m[i] / (1 - self.betas[0]**self.t) v_hat = self.v[i] / (1 - self.betas[1]**self.t) param -= self.lr * m_hat / (np.sqrt(v_hat) + 1e-8) class AdamW: def __init__(self, params, lr=0.001, betas=(0.9, 0.999), weight_decay=0.): # 初始化与Adam相同... def step(self, grads): self.t += 1 for i, (param, grad) in enumerate(zip(self.params, grads)): # Adam标准更新(不带weight decay) self.m[i] = self.betas[0] * self.m[i] + (1 - self.betas[0]) * grad self.v[i] = self.betas[1] * self.v[i] + (1 - self.betas[1]) * grad**2 m_hat = self.m[i] / (1 - self.betas[0]**self.t) v_hat = self.v[i] / (1 - self.betas[1]**self.t) # 参数更新(分开处理weight decay) param -= self.lr * (m_hat / (np.sqrt(v_hat) + 1e-8) + self.weight_decay * param)这个实现展示了Adam和AdamW的关键区别:Adam将weight decay混入梯度计算,而AdamW将其作为独立的衰减项。在实践中,AdamW通常能提供更稳定的weight decay效果。
