从零实现梯度下降优化器家族:SGD、Momentum、Adam等算法原理与NumPy实战

发布时间:2026/5/31 5:09:30

从零实现梯度下降优化器家族:SGD、Momentum、Adam等算法原理与NumPy实战 1. 项目概述从零构建梯度下降优化器家族在机器学习和深度学习的实战中无论你构建的是简单的线性回归模型还是复杂的深度神经网络模型训练的核心引擎几乎都是优化算法。而梯度下降Gradient Descent及其变体无疑是这个引擎中最经典、最基础也最值得深入理解的部件。很多朋友在调用model.fit()时可能对背后optimizeradam这个参数习以为常但你是否曾好奇过SGD、Momentum、RMSprop、Adam这些名字背后究竟是如何一步步计算并更新模型参数的它们的数学直觉是什么为什么Adam通常表现更好而朴素的SGD有时仍有其不可替代的价值这个项目就是一次彻底的“造轮子”之旅。我们将仅依赖Python和NumPy从最基础的批量梯度下降开始手动实现包括SGD、Momentum、Adagrad、RMSprop、Adam以及Nadam在内的多种主流优化算法变体。这不仅仅是一次编程练习更是一次深入理解优化算法内在机理的绝佳机会。通过亲手实现你会对学习率衰减、动量积累、自适应学习率等概念有肌肉记忆般的理解未来在调试模型、选择优化器、甚至设计自定义优化器时都能做到心中有数游刃有余。2. 优化算法核心思想与数学基础拆解在开始敲代码之前我们必须夯实理论基础。优化算法的终极目标是找到一组模型参数 $\theta$使得损失函数 $J(\theta)$ 的值最小化。梯度下降的基本思想非常直观既然梯度 $\nabla_{\theta} J(\theta)$ 指明了函数值上升最快的方向那么沿着其反方向即负梯度方向更新参数就能逐步走向山谷最小值点。最朴素的更新规则是 $$\theta_{t1} \theta_t - \eta \cdot \nabla_{\theta} J(\theta_t)$$ 其中$\eta$ 是学习率它决定了每一步迈出的距离。然而这个“朴素”版本在实际中面临诸多挑战学习率选择困难太小则收敛慢太大则可能震荡甚至发散。陷入局部极小值或鞍点梯度为零或接近零时更新停滞。不同参数应有不同学习率对于稀疏特征或尺度差异大的参数固定学习率不高效。为了解决这些问题研究者们提出了各种变体其演进脉络主要围绕两个核心思路引入动量Momentum和自适应学习率Adaptive Learning Rate。动量的灵感来源于物理学它不仅仅考虑当前的梯度还会累积过去梯度的指数加权平均形成一个“速度”变量。这有助于在相关方向加速并抑制震荡帮助冲出平坦的局部极小区域或鞍点。其更新公式通常包含一个衰减因子 $\beta$通常取0.9 $$v_t \beta v_{t-1} (1 - \beta) \nabla_{\theta} J(\theta_t)$$ $$\theta_{t1} \theta_t - \eta \cdot v_t$$自适应学习率则为每个参数维护一个独立的学习率该学习率会根据该参数历史梯度的大小进行调整。对于频繁更新、梯度大的参数给予较小的学习率使其步伐稳健对于不常更新、梯度小的参数给予较大的学习率使其加快学习。Adagrad是这一思想的先驱它累积所有历史梯度的平方和但会导致学习率过早衰减。RMSprop和Adam对此进行了改进引入了指数衰减平均只关注最近一段时间的梯度规模。理解这些核心思想是我们接下来实现每一个变体的基石。3. 算法实现详解与NumPy实战我们将构建一个面向对象的框架定义一个基类Optimizer然后让每种具体的优化算法继承它。这保证了接口的统一便于测试和对比。3.1 基础架构与批量梯度下降实现首先我们搭建基类并实现最基础的批量梯度下降Batch Gradient Descent。BGD在每次更新时使用全部训练数据计算梯度计算稳定但内存消耗大、速度慢。import numpy as np class Optimizer: 优化器基类 def __init__(self, learning_rate0.01): self.learning_rate learning_rate self.t 0 # 时间步用于某些算法 def update(self, params, grads): 更新参数 :param params: 字典存储需要更新的参数如 {W1: W1, b1: b1, ...} :param grads: 字典存储对应参数的梯度如 {W1: dW1, b1: db1, ...} :return: 更新后的params raise NotImplementedError class BGD(Optimizer): 批量梯度下降 def __init__(self, learning_rate0.01): super().__init__(learning_rate) def update(self, params, grads): for key in params.keys(): params[key] - self.learning_rate * grads[key] return params注意这里的params和grads都是字典这是为了模拟神经网络中多层参数的情况。在实际的简单线性/逻辑回归中可能只有一组W和b。3.2 随机梯度下降与小批量梯度下降随机梯度下降SGD每次只用一个样本计算梯度并更新速度快、可在线学习但梯度噪声大收敛路径震荡剧烈。小批量梯度下降Mini-batch GD是两者的折衷也是深度学习中最常用的方式它每次使用一个小批量batch的数据。我们的SGD类实际上实现的就是Mini-batch GD因为通常我们说的SGD在深度学习中就是指Mini-batch。class SGD(Optimizer): 小批量随机梯度下降 def __init__(self, learning_rate0.01): super().__init__(learning_rate) def update(self, params, grads): for key in params.keys(): params[key] - self.learning_rate * grads[key] return params你会发现SGD的update方法和BGD一模一样。是的从算法公式上看它们没有区别。区别在于调用update时传入的grads是如何计算出来的BGD的grads基于全部数据SGDMini-batch的grads基于一个batch的数据。因此优化器本身不关心数据量它只负责按规则更新传入的梯度。这是理解优化器独立性的关键。3.3 带动量的随机梯度下降Momentum SGD通过引入速度变量积累了之前的梯度方向使其在稳定方向加速在震荡方向抵消。class MomentumSGD(Optimizer): 带动量的SGD def __init__(self, learning_rate0.01, momentum0.9): super().__init__(learning_rate) self.momentum momentum self.v None # 速度字典初始化为None def update(self, params, grads): if self.v is None: # 首次调用时初始化速度字典结构与params相同 self.v {} for key, val in params.items(): self.v[key] np.zeros_like(val) for key in params.keys(): # 更新速度v β*v (1-β)*grad # 注意有些实现直接使用 v β*v grad区别在于对学习率的缩放这里采用更常见的带(1-β)的形式 self.v[key] self.momentum * self.v[key] (1 - self.momentum) * grads[key] # 更新参数θ θ - η * v params[key] - self.learning_rate * self.v[key] return params实操心得momentum参数$\beta$通常设置为0.9或0.99。这里的(1 - self.momentum)项对梯度进行了缩放使得初始速度v是梯度的指数加权平均。另一种常见写法是self.v[key] self.momentum * self.v[key] grads[key]此时等效于学习率被隐式缩放。两种方式都可以只要理解其物理意义并保持一致即可。我们这里采用第一种与Adam等算法中的一阶矩估计形式保持一致。3.4 自适应学习率算法Adagrad与RMSpropAdagrad为每个参数自适应地调整学习率累积历史梯度平方和。class Adagrad(Optimizer): Adagrad优化器 def __init__(self, learning_rate0.01, epsilon1e-8): super().__init__(learning_rate) self.epsilon epsilon # 防止除零的小常数 self.h None # 累积梯度平方和字典 def update(self, params, grads): if self.h is None: self.h {} for key, val in params.items(): self.h[key] np.zeros_like(val) for key in params.keys(): # 累积平方梯度h h grad^2 self.h[key] grads[key] ** 2 # 更新参数θ θ - η / (sqrt(h) ε) * grad params[key] - self.learning_rate * grads[key] / (np.sqrt(self.h[key]) self.epsilon) return paramsAdagrad的主要问题是h会随时间单调递增导致学习率过早且过度衰减可能在训练后期几乎停止更新。RMSprop解决了这个问题它引入一个衰减率$\rho$只累积最近一段时间的梯度平方的指数加权平均。class RMSprop(Optimizer): RMSprop优化器 def __init__(self, learning_rate0.001, rho0.9, epsilon1e-8): super().__init__(learning_rate) self.rho rho # 衰减率 self.epsilon epsilon self.h None # 梯度平方的指数加权移动平均 def update(self, params, grads): if self.h is None: self.h {} for key, val in params.items(): self.h[key] np.zeros_like(val) for key in params.keys(): # 更新二阶矩估计h ρ*h (1-ρ)*grad^2 self.h[key] self.rho * self.h[key] (1 - self.rho) * grads[key] ** 2 # 更新参数θ θ - η / (sqrt(h) ε) * grad params[key] - self.learning_rate * grads[key] / (np.sqrt(self.h[key]) self.epsilon) return params注意事项RMSprop的学习率通常设置得比SGD小例如0.001是一个常见的起点。参数rho控制着历史信息衰减的速度通常取0.9或0.99。3.5 自适应矩估计Adam与NadamAdam可以看作是Momentum和RMSprop的结合体它同时计算梯度的一阶矩估计动量和二阶矩估计自适应学习率并进行偏差校正使其在训练初期更准确。class Adam(Optimizer): Adam优化器 def __init__(self, learning_rate0.001, beta10.9, beta20.999, epsilon1e-8): super().__init__(learning_rate) self.beta1 beta1 # 一阶矩衰减率 self.beta2 beta2 # 二阶矩衰减率 self.epsilon epsilon self.m None # 一阶矩动量 self.v None # 二阶矩自适应项 self.t 0 # 时间步从0开始 def update(self, params, grads): if self.m is None: self.m {} self.v {} for key, val in params.items(): self.m[key] np.zeros_like(val) self.v[key] np.zeros_like(val) self.t 1 # 时间步递增 lr_t self.learning_rate * np.sqrt(1 - self.beta2 ** self.t) / (1 - self.beta1 ** self.t) # 偏差校正后的学习率 for key in params.keys(): # 更新一阶矩和二阶矩 self.m[key] self.beta1 * self.m[key] (1 - self.beta1) * grads[key] self.v[key] self.beta2 * self.v[key] (1 - self.beta2) * (grads[key] ** 2) # 计算偏差校正后的估计 m_hat self.m[key] / (1 - self.beta1 ** self.t) v_hat self.v[key] / (1 - self.beta2 ** self.t) # 更新参数 params[key] - lr_t * m_hat / (np.sqrt(v_hat) self.epsilon) return paramsNadam是Adam结合了Nesterov动量的变体。Nesterov动量是先根据累积速度“展望”一步在那个“展望点”计算梯度再进行更新被认为对凸函数有更好的理论性质。Nadam将这种“展望”思想融入了Adam。class Nadam(Optimizer): Nadam (Nesterov-accelerated Adaptive Moment Estimation) 优化器 def __init__(self, learning_rate0.001, beta10.9, beta20.999, epsilon1e-8): super().__init__(learning_rate) self.beta1 beta1 self.beta2 beta2 self.epsilon epsilon self.m None self.v None self.t 0 def update(self, params, grads): if self.m is None: self.m {} self.v {} for key, val in params.items(): self.m[key] np.zeros_like(val) self.v[key] np.zeros_like(val) self.t 1 # Nadam的偏差校正和更新公式略有不同它融入了Nesterov动量 for key in params.keys(): # 更新一阶矩和二阶矩 self.m[key] self.beta1 * self.m[key] (1 - self.beta1) * grads[key] self.v[key] self.beta2 * self.v[key] (1 - self.beta2) * (grads[key] ** 2) # 偏差校正 m_hat self.m[key] / (1 - self.beta1 ** self.t) v_hat self.v[key] / (1 - self.beta2 ** self.t) # Nadam的核心使用“展望”后的动量进行更新 # 公式可简化为θ θ - η / (sqrt(v_hat)ε) * (β1*m_hat (1-β1)*grad/(1-β1^t)) momentum_term self.beta1 * m_hat (1 - self.beta1) * grads[key] / (1 - self.beta1 ** self.t) params[key] - self.learning_rate * momentum_term / (np.sqrt(v_hat) self.epsilon) return params核心细节解析Adam和Nadam中的偏差校正Bias Correction至关重要。因为在训练初期t很小m和v被初始化为0即使经过指数加权平均它们也会偏向于0。通过除以(1 - beta^t)可以将估计值拉回到无偏的状态尤其是在训练开始的几十到几百个step内效果显著。这也是为什么Adam通常能更快稳定收敛的原因之一。4. 可视化对比实验与性能分析实现完算法我们最关心的是它们在实际优化中表现如何我们设计一个简单的实验来可视化它们的优化路径。选择一个非凸的测试函数例如Beale函数它有多个局部极小点和一个全局最小点能很好地测试优化器的性能。import numpy as np import matplotlib.pyplot as plt from mpl_toolkits.mplot3d import Axes3D # 1. 定义测试函数 (Beale Function) 及其梯度 def beale(x, y): return (1.5 - x x*y)**2 (2.25 - x x*y**2)**2 (2.625 - x x*y**3)**2 def grad_beale(x, y): df_dx 2*(1.5 - x x*y)*(-1 y) 2*(2.25 - x x*y**2)*(-1 y**2) 2*(2.625 - x x*y**3)*(-1 y**3) df_dy 2*(1.5 - x x*y)*x 2*(2.25 - x x*y**2)*(2*x*y) 2*(2.625 - x x*y**3)*(3*x*y**2) return np.array([df_dx, df_dy]) # 2. 统一测试框架 def optimize_path(optimizer, start_point, n_iters200): 使用指定的优化器记录优化路径 :param optimizer: 优化器实例 :param start_point: 起始点 [x, y] :param n_iters: 迭代次数 :return: 路径列表每个元素是[x, y] path [start_point.copy()] params {xy: start_point.copy()} # 将坐标视为参数 for i in range(n_iters): x, y params[xy] grad grad_beale(x, y) grads {xy: grad} params optimizer.update(params, grads) path.append(params[xy].copy()) return np.array(path) # 3. 设置起始点和优化器 start_point np.array([-1.0, 1.5]) # 一个具有挑战性的起点 optimizers { SGD: SGD(learning_rate0.01), Momentum: MomentumSGD(learning_rate0.01, momentum0.9), Adagrad: Adagrad(learning_rate1.0), # Adagrad初始学习率可设大些 RMSprop: RMSprop(learning_rate0.01), Adam: Adam(learning_rate0.1), # Adam学习率也可以稍大 Nadam: Nadam(learning_rate0.1) } # 4. 运行优化并绘图 fig, axes plt.subplots(2, 3, figsize(15, 10)) axes axes.flatten() # 生成函数等高线背景 x np.linspace(-4.5, 4.5, 400) y np.linspace(-4.5, 4.5, 400) X, Y np.meshgrid(x, y) Z beale(X, Y) for idx, (name, opt) in enumerate(optimizers.items()): ax axes[idx] # 绘制等高线 ax.contour(X, Y, Z, levels50, cmapviridis, alpha0.6) # 运行优化获取路径 path optimize_path(opt, start_point, n_iters150) # 绘制路径 ax.plot(path[:, 0], path[:, 1], ro-, markersize3, linewidth1.5, labelf{name} path) ax.plot(path[0, 0], path[0, 1], gs, markersize8, labelStart) # 起点 ax.plot(path[-1, 0], path[-1, 1], b*, markersize12, labelEnd) # 终点 ax.set_title(f{name} Optimization Path) ax.set_xlabel(x) ax.set_ylabel(y) ax.legend() ax.grid(True, alpha0.3) ax.set_xlim([-4.5, 4.5]) ax.set_ylim([-4.5, 4.5]) plt.tight_layout() plt.show()通过运行上述代码我们可以得到六张子图直观对比不同优化器从同一起点出发寻找Beale函数最小值的路径。典型结果分析SGD路径震荡明显收敛缓慢且容易在峡谷状地形中来回弹跳。Momentum路径平滑许多在峡谷中能更直接地向下游走因为它积累了速度抵消了垂直方向的震荡。Adagrad初期步幅较大但后期由于学习率急剧衰减在离最优点较远处就几乎停止了更新路径点密集堆积。这验证了其学习率过早衰减的问题。RMSprop表现良好能自适应调整不同方向的学习率路径相对直接且稳定地趋近最优点。Adam和Nadam通常表现出最快和最稳定的收敛路径平滑且能快速找到最小值区域。两者差异在此简单2D问题上可能不明显但在更复杂的高维非凸问题上Nadam有时能展现出微弱的优势。这个可视化实验清晰地展示了不同优化器在应对非凸曲面、鞍点、不同尺度梯度等挑战时的行为差异。5. 实战调参指南与避坑技巧理解了原理看到了表现接下来就是在真实项目中应用和调参了。这里分享一些从实践中总结的干货。5.1 学习率设置最重要的超参数学习率是优化器最敏感的超参数没有之一。SGD/Momentum通常需要仔细调校。可以从0.1, 0.01, 0.001等数量级尝试。一个经验法则是如果训练损失在几个epoch内不下降甚至上升说明学习率可能太大如果下降极其缓慢则可能太小。Adagrad/RMSprop/Adam由于有自适应机制它们对初始学习率的鲁棒性更强。Adam的默认学习率0.001是一个非常好的起点在大多数网络和任务上都能工作。对于RMSprop0.01或0.001也是常见起点。学习率衰减无论使用哪种优化器在训练后期引入学习率衰减如每N个epoch乘以0.1或使用余弦退火几乎总是有益的这有助于模型收敛到更尖锐的极小点。5.2 优化器选择策略没有“永远最好”的优化器选择取决于具体任务和数据。新手首选Adam如果你刚入门或者面对一个新问题不知道选什么用Adamlr0.001或0.0001大概率不会错。它收敛快调参简单。追求极致性能在计算机视觉、NLP的许多SOTA模型中带动量的SGD配合精心设计的学习率衰减策略最终收敛的模型泛化性能有时会略优于Adam。但这需要更多的调参经验。稀疏数据与嵌入层对于自然语言处理中常见的稀疏特征如词嵌入Adagrad或它的改进版如Adadelta, Adam通常是不错的选择因为它们为不频繁更新的特征分配了更大的更新步长。不稳定或非常深的网络如果训练过程中出现损失NaN爆炸可以尝试将Adam的beta1从0.9降低到0.5或0.3这能减少对当前梯度噪声的敏感度。也可以尝试梯度裁剪Gradient Clipping这是一个独立于优化器的稳定化技巧。5.3 常见陷阱与排查清单损失变成NaN检查数据是否有缺失值或异常值对输入数据进行归一化/标准化。降低学习率这是最常见的原因。使用梯度裁剪在调用optimizer.update之前对grads中的每个梯度矩阵进行范数裁剪。max_norm 1.0 for key in grads: norm np.linalg.norm(grads[key]) if norm max_norm: grads[key] grads[key] * max_norm / norm尝试不同的权重初始化方法如He初始化配合ReLU或Xavier初始化。训练损失不下降检查梯度是否正常在训练开始时打印出各层梯度的范数或均值/方差。如果梯度全部接近0可能是网络结构、激活函数如梯度消失或数据预处理出了问题。增大学习率以10倍为单位尝试。检查模型容量模型是否过于简单无法拟合数据尝试增加层数或神经元数量。检查标签是否正确确认数据加载和标签对应无误。验证集性能震荡或过早过拟合使用更强的正则化如Dropout, L2正则化权重衰减。实施学习率衰减。早停当验证集损失连续多个epoch不下降时停止训练。对于Adam尝试启用权重衰减注意Adam的原始论文没有包含L2正则化但后来的研究表明将权重衰减与Adam结合称为AdamW能获得更好的泛化。在我们的实现中可以在计算梯度时手动加入权重衰减项grad original_grad weight_decay * param。5.4 在真实神经网络中集成我们的优化器为了验证我们实现的优化器在真实任务上的有效性我们可以将其集成到一个简单的多层感知机MLP中用于MNIST手写数字分类。# 假设我们有一个简单的两层神经网络类 class SimpleMLP: def __init__(self, input_size, hidden_size, output_size, weight_init_std0.01): self.params {} # 初始化参数 self.params[W1] weight_init_std * np.random.randn(input_size, hidden_size) self.params[b1] np.zeros(hidden_size) self.params[W2] weight_init_std * np.random.randn(hidden_size, output_size) self.params[b2] np.zeros(output_size) def predict(self, x): # 前向传播 a1 np.dot(x, self.params[W1]) self.params[b1] z1 np.maximum(0, a1) # ReLU a2 np.dot(z1, self.params[W2]) self.params[b2] # 简易softmax (实际训练中需用交叉熵损失) exp_a np.exp(a2 - np.max(a2, axis1, keepdimsTrue)) # 防溢出 y exp_a / np.sum(exp_a, axis1, keepdimsTrue) return y, (x, a1, z1, a2) # 返回预测值和中间结果用于反向传播 def loss(self, y_pred, y_true): # 交叉熵损失 batch_size y_true.shape[0] return -np.sum(y_true * np.log(y_pred 1e-7)) / batch_size def gradient(self, x, y_true, cache): # 反向传播计算梯度 (简化版假设使用交叉熵损失和softmax输出) x_val, a1, z1, a2 cache batch_size x_val.shape[0] # 输出层梯度 y_pred, _ self.predict(x_val) # 重新计算预测值或从cache传递 da2 (y_pred - y_true) / batch_size # Softmax CrossEntropy 的梯度 # 第二层梯度 grads {} grads[W2] np.dot(z1.T, da2) grads[b2] np.sum(da2, axis0) # 隐藏层梯度 dz1 np.dot(da2, self.params[W2].T) dz1[z1 0] 0 # ReLU的梯度 grads[W1] np.dot(x_val.T, dz1) grads[b1] np.sum(dz1, axis0) return grads # 训练循环示例 def train_mlp(optimizer, epochs10, batch_size64, lr0.01): # 假设已有数据加载器 (X_train, y_train_onehot) model SimpleMLP(input_size784, hidden_size50, output_size10) opt optimizer(learning_ratelr) # 实例化优化器 n_batches len(X_train) // batch_size for epoch in range(epochs): epoch_loss 0 # 这里应有一个打乱数据和分批的循环 for i in range(n_batches): # 获取一个batch的数据 X_batch, y_batch # ... # 前向传播 y_pred, cache model.predict(X_batch) loss model.loss(y_pred, y_batch) epoch_loss loss # 反向传播 grads model.gradient(X_batch, y_batch, cache) # 优化器更新参数 model.params opt.update(model.params, grads) print(fEpoch {epoch1}, Avg Loss: {epoch_loss/n_batches:.4f})通过这样的集成测试你可以直观地比较不同优化器在真实任务上的收敛速度、最终准确率和训练稳定性。你会发现我们手动实现的优化器与主流深度学习框架如PyTorch的torch.optim、TensorFlow的tf.keras.optimizers中的对应版本在核心思想和效果上是一致的。

相关新闻