1. 项目概述从“黑箱”到“白盒”的优化算法实践在机器学习和深度学习的实践中我们常常把各种优化器当作一个“黑箱”来使用。无论是TensorFlow的Adam还是PyTorch的SGD我们只需要导入、设置学习率然后调用.step()方法模型参数就会自动更新。这固然方便但久而久之我们可能会对梯度下降这个最核心的迭代优化过程感到陌生甚至产生一种“魔法”般的错觉。当模型训练出现震荡、不收敛或者陷入局部最优时如果对优化器内部的工作原理一知半解调试起来就会非常被动。这个项目的核心就是亲手用Python和Numpy从零开始实现梯度下降优化算法的几个经典变种。这绝不是一个简单的“抄写公式”练习。其真正的价值在于通过一行行代码的构建你将彻底理解每个算法是如何“看到”梯度、如何“记忆”过去、如何“预测”未来并最终做出参数更新决策的。你会明白为什么Momentum能让优化过程冲过狭窄的山谷为什么RMSProp能自适应地调整每个参数的学习率以及Adam是如何巧妙地融合了前两者的思想。当你下次再面对复杂的训练曲线时你看到的将不再是一堆令人困惑的折线而是算法内部动量、梯度二阶矩等状态量相互博弈的动态图景。我们将从最基础的批量梯度下降Batch Gradient Descent开始逐步实现小批量梯度下降Mini-batch GD、带动量的随机梯度下降SGD with Momentum、AdaGrad、RMSProp最终实现目前应用最广泛的Adam算法。整个过程我们将使用一个简单的二次函数作为优化目标进行可视化让你直观地看到不同算法在损失曲面上的“行走路径”有何不同。无论你是刚入门机器学习的新手希望夯实基础还是已有经验的研究者或工程师想要深入理解优化器以进行更高效的模型调优这个“造轮子”的过程都将让你受益匪浅。2. 核心概念与数学基础梯度下降的“灵魂”三问在动手写代码之前我们必须统一思想回答关于梯度下降的三个根本问题为什么能下降、沿着什么方向下降以及每一步下降多大。对这些问题的理解深度直接决定了我们实现算法时的代码清晰度和调试能力。2.1 损失函数与梯度优化问题的地图与指南针想象一下你被蒙上眼睛放置在一个连绵起伏的山丘上你的任务是找到海拔最低的谷底。你唯一能获取的信息就是通过脚底感受当前所在点的坡度。这里的“山丘地形”就是我们的损失函数 J(θ)它描述了模型参数 θ 取不同值时模型预测误差的大小。我们的目标就是找到使 J(θ) 最小的那个 θ。“脚底感受的坡度”就是数学上的梯度 ∇J(θ)。梯度是一个向量其方向指向函数值增加最快的方向。那么一个很自然的想法是要最快地降低高度损失我们就应该朝着坡度上升最快的反方向走。这就是梯度下降法的核心思想θ_new θ_old - η * ∇J(θ_old)。其中η 就是学习率Learning Rate它决定了我们沿着反梯度方向迈出的“步长”。注意梯度是损失函数对每个参数的偏导数组成的向量。对于有上百万参数的深度神经网络计算一次完整的梯度即在全量数据集上开销巨大这引出了随机性的概念。2.2 学习率步伐大小的艺术学习率 η 是梯度下降中最重要的超参数之一没有“之一”也不为过。它控制着参数更新的幅度。η 太大步伐过大可能会直接“跨过”山谷导致损失值震荡甚至发散越来越大。就像在下山时步子迈得太大直接从山坡的一边跳到了另一边。η 太小步伐过小收敛速度极慢可能需要非常多的迭代步数才能到达最低点并且容易陷入局部极小点而非全局最优点而无法跳出。在实际的算法变种中一个核心的改进方向就是让学习率不再是一个固定的标量而是能够自适应调整。有的算法为每个参数维护不同的学习率如AdaGrad, RMSProp有的算法则通过引入“动量”来影响有效的更新方向与大小如Momentum, Adam。2.3 批量Batch的概念计算效率与更新稳定性的权衡根据计算梯度时使用的数据量不同梯度下降主要有三种模式批量梯度下降Batch Gradient Descent, BGD使用整个训练集的数据计算损失函数的梯度。优点是梯度方向准确更新稳定必然朝着整体损失降低的方向前进缺点是每次更新计算开销大速度慢且无法处理超出内存容量的大型数据集。随机梯度下降Stochastic Gradient Descent, SGD每次随机使用一个训练样本计算梯度并立即更新参数。优点是更新频率极高计算快对于大规模数据很友好并且固有的随机噪声有助于跳出局部最优缺点是梯度估计非常嘈杂更新方向波动大损失函数下降过程会剧烈震荡。小批量梯度下降Mini-batch Gradient Descent这是前两者的折中也是深度学习中的实际标准。每次随机抽取一小批例如32, 64, 128个数据计算梯度。它兼具了BGD的相对稳定性和SGD的高效性并且可以利用现代计算库如Numpy, PyTorch的并行计算优势。在我们的实现中将重点放在Mini-batch GD及其改进变种上因为这是最具实用价值的基线。3. 基础实现从标准BGD到Mini-batch GD让我们先搭建一个简单的测试环境并实现最基础的版本作为后续复杂算法的对照基准。3.1 构建一个可视化测试环境为了直观对比算法性能我们优化一个简单的二维二次函数J(x, y) x^2 10 * y^2。这个函数在(0,0)处有全局最小值0。它的等高线是椭圆y轴方向比x轴方向“更陡峭”这可以用来测试算法处理不同尺度ill-conditioned问题的能力。import numpy as np import matplotlib.pyplot as plt from matplotlib import cm from mpl_toolkits.m3d import Axes3D # 定义目标函数和它的梯度 def loss_function(w): 一个简单的二次损失函数用于演示。w是一个二维向量[x, y] x, y w[0], w[1] return x**2 10 * y**2 def gradient(w): 损失函数的梯度 x, y w[0], w[1] return np.array([2*x, 20*y]) # 生成网格数据用于绘图 x np.linspace(-5, 5, 100) y np.linspace(-5, 5, 100) X, Y np.meshgrid(x, y) Z X**2 10 * Y**2 # 绘制3D曲面和等高线 fig plt.figure(figsize(16, 6)) ax1 fig.add_subplot(121, projection3d) surf ax1.plot_surface(X, Y, Z, cmapcm.coolwarm, alpha0.8, linewidth0) ax1.set_xlabel(X) ax1.set_ylabel(Y) ax1.set_zlabel(Loss) ax1.set_title(3D Surface of Loss Function) ax2 fig.add_subplot(122) contour ax2.contour(X, Y, Z, levels50, cmapcm.coolwarm) ax2.set_xlabel(X) ax2.set_ylabel(Y) ax2.set_title(Contour of Loss Function) plt.colorbar(contour, axax2) plt.tight_layout() plt.show()3.2 实现批量梯度下降BGDBGD的逻辑最为直接计算整个“数据集”在我们的例子中就是损失函数本身的梯度然后更新。def batch_gradient_descent(initial_w, learning_rate, n_iterations): 批量梯度下降实现 Args: initial_w: 初始参数向量如 np.array([-4.0, 3.0]) learning_rate: 固定学习率 n_iterations: 迭代次数 Returns: w_history: 参数更新历史用于绘图 loss_history: 损失值历史 w initial_w.copy() w_history [w.copy()] loss_history [loss_function(w)] for i in range(n_iterations): # 计算当前点的梯度这里就是整个函数的梯度 grad gradient(w) # 参数更新核心公式 w w - learning_rate * grad w_history.append(w.copy()) loss_history.append(loss_function(w)) return np.array(w_history), np.array(loss_history) # 测试BGD initial_w np.array([-4.0, 3.0]) lr 0.1 iters 50 w_history_bgd, loss_history_bgd batch_gradient_descent(initial_w, lr, iters) print(fBGD 最终参数: {w_history_bgd[-1]}, 最终损失: {loss_history_bgd[-1]:.6f})运行这段代码你会看到参数沿着最陡峭的下降方向前进。但由于y方向的梯度20y远大于x方向2x如果学习率设置不当在y方向很容易发生震荡。你可以尝试将learning_rate改为0.12观察损失值是如何发散的。3.3 实现小批量梯度下降Mini-batch GD在真实机器学习场景中我们的“数据”是样本集合。为了模拟Mini-batch我们创建一个虚拟的线性回归数据集。# 生成模拟数据 np.random.seed(42) n_samples 1000 X_data 2 * np.random.randn(n_samples, 1) # 1000个样本1个特征 # 生成带噪声的标签 y 4 3*X noise true_theta np.array([[4], [3]]) y_data true_theta[0] true_theta[1] * X_data np.random.randn(n_samples, 1) * 0.5 # 为数据添加偏置项 (x01) X_b np.c_[np.ones((n_samples, 1)), X_data] def linear_loss(theta, X, y): 线性回归的均方误差损失 m len(y) predictions X.dot(theta) return (1/(2*m)) * np.sum((predictions - y)**2) def linear_gradient(theta, X, y): 线性回归损失的梯度 m len(y) predictions X.dot(theta) grad (1/m) * X.T.dot(predictions - y) return grad def mini_batch_gradient_descent(X, y, initial_theta, learning_rate, n_epochs, batch_size): 小批量梯度下降实现 Args: X: 特征矩阵含偏置 y: 标签向量 initial_theta: 初始参数 learning_rate: 学习率 n_epochs: 遍历整个数据集的轮数 batch_size: 批大小 Returns: theta_history: 参数历史 loss_history: 损失历史 theta initial_theta.copy() m len(y) theta_history [theta.copy()] loss_history [linear_loss(theta, X, y)] for epoch in range(n_epochs): # 每个epoch开始时打乱数据 shuffled_indices np.random.permutation(m) X_shuffled X[shuffled_indices] y_shuffled y[shuffled_indices] # 按batch_size遍历数据 for i in range(0, m, batch_size): X_batch X_shuffled[i:ibatch_size] y_batch y_shuffled[i:ibatch_size] grad linear_gradient(theta, X_batch, y_batch) theta theta - learning_rate * grad # 可选每N个batch记录一次避免历史数据过大 theta_history.append(theta.copy()) loss_history.append(linear_loss(theta, X, y)) return np.array(theta_history), np.array(loss_history) # 测试Mini-batch GD initial_theta np.random.randn(2, 1) lr 0.01 epochs 50 batch_size 32 theta_history_mb, loss_history_mb mini_batch_gradient_descent(X_b, y_data, initial_theta, lr, epochs, batch_size) print(fMini-batch GD 最终参数: \n{theta_history_mb[-1].flatten()}) print(f真实参数: {true_theta.flatten()})实操心得在实现Mini-batch GD时每个epoch开始前打乱数据Shuffling至关重要。如果不打乱假设数据本身有特定顺序如按标签排序那么每个batch的梯度分布会有严重偏差导致优化过程不稳定甚至无法收敛。np.random.permutation(m)是一种高效的做法。4. 进阶优化器实现Momentum, AdaGrad, RMSProp基础GD算法的主要问题在于固定学习率和梯度噪声。下面的算法从不同角度试图解决这些问题。4.1 带动量的随机梯度下降SGD with Momentum动量法的灵感来源于物理学。想象一下在损失函数的曲面上推一个球它不仅受到当前坡度梯度的影响还会积累之前的动量。这带来两个好处1) 在梯度方向一致的维度上加速2) 在梯度方向频繁改变的维度上抑制震荡帮助穿过狭窄的山谷。其更新公式为v_t β * v_{t-1} (1 - β) * g_t θ_t θ_{t-1} - η * v_t其中g_t是当前梯度v_t是动量项β是动量系数通常取0.9η是学习率。def sgd_momentum(initial_w, learning_rate, n_iterations, beta0.9): 带动量的SGD实现在简单损失函数上演示 w initial_w.copy() v np.zeros_like(w) # 初始化动量为0 w_history [w.copy()] loss_history [loss_function(w)] for i in range(n_iterations): grad gradient(w) # 动量更新当前梯度与历史动量的加权和 v beta * v (1 - beta) * grad # 参数更新使用动量项v而非原始梯度g w w - learning_rate * v w_history.append(w.copy()) loss_history.append(loss_function(w)) return np.array(w_history), np.array(loss_history) # 对比BGD和Momentum initial_w np.array([-4.0, 3.0]) lr 0.1 iters 30 w_history_bgd, loss_bgd batch_gradient_descent(initial_w, lr, iters) w_history_mom, loss_mom sgd_momentum(initial_w, lr, iters, beta0.9) # 绘图对比路径 plt.figure(figsize(10, 8)) plt.contour(X, Y, Z, levels50, cmapcm.coolwarm, alpha0.5) plt.plot(w_history_bgd[:, 0], w_history_bgd[:, 1], o-, labelfBGD (lr{lr}), markersize4) plt.plot(w_history_mom[:, 0], w_history_mom[:, 1], s-, labelfMomentum (β0.9, lr{lr}), markersize4) plt.scatter(0, 0, cred, s100, marker*, labelGlobal Minimum) plt.xlabel(X) plt.ylabel(Y) plt.title(Optimization Paths: BGD vs Momentum) plt.legend() plt.grid(True) plt.show()运行对比图你会明显看到Momentum的路径更加平滑且由于初始累积的动量在初期“冲”得更快。在“峡谷”地形y方向陡x方向缓中Momentum能更快地沿着谷底方向x轴前进而普通BGD则在y方向反复震荡。4.2 AdaGrad自适应地为每个参数调整学习率AdaGrad的核心思想是对于频繁更新的参数梯度大我们已经学到了很多应该降低学习率迈小步对于不频繁更新的参数梯度小我们知之甚少应该保持较大的学习率迈大步。它通过累加参数历史梯度的平方来实现这一点。更新公式G_t G_{t-1} g_t ⊙ g_t # ⊙ 表示逐元素乘法 θ_t θ_{t-1} - (η / (√G_t ε)) ⊙ g_t其中G_t是梯度平方的累积ε是一个极小值如1e-8防止除零。def adagrad(initial_w, learning_rate, n_iterations, epsilon1e-8): AdaGrad 优化器实现 w initial_w.copy() G np.zeros_like(w) # 梯度平方累积变量 w_history [w.copy()] loss_history [loss_function(w)] for i in range(n_iterations): grad gradient(w) # 累积梯度平方 G grad ** 2 # 计算自适应学习率。注意学习率η在分母所以实际更新步长会越来越小。 adapted_lr learning_rate / (np.sqrt(G) epsilon) # 参数更新 w w - adapted_lr * grad w_history.append(w.copy()) loss_history.append(loss_function(w)) return np.array(w_history), np.array(loss_history) # 测试AdaGrad w_history_ada, loss_ada adagrad(initial_w, lr0.5, n_iterations50) # AdaGrad可以使用更大的初始学习率 print(fAdaGrad 最终损失: {loss_ada[-1]:.6f})注意事项AdaGrad有一个致命缺点——随着训练进行G_t会单调递增导致自适应学习率η/√G_t不断减小最终趋于零使得参数在训练后期几乎无法更新。这在训练深度网络时是个严重问题。因此AdaGrad更适用于稀疏数据场景如自然语言处理其中很多参数不常更新。4.3 RMSProp解决AdaGrad学习率衰减过快的问题RMSProp是对AdaGrad的改进由Geoffrey Hinton提出。它引入了一个衰减系数ρ通常为0.9将历史梯度平方的累积改为指数移动平均从而让“记忆”是有限的避免了学习率无限变小的问题。更新公式E[g^2]_t ρ * E[g^2]_{t-1} (1 - ρ) * g_t ⊙ g_t θ_t θ_{t-1} - (η / (√(E[g^2]_t) ε)) ⊙ g_tdef rmsprop(initial_w, learning_rate, n_iterations, rho0.9, epsilon1e-8): RMSProp 优化器实现 w initial_w.copy() Eg2 np.zeros_like(w) # 梯度平方的指数移动平均 w_history [w.copy()] loss_history [loss_function(w)] for i in range(n_iterations): grad gradient(w) # 更新梯度平方的指数移动平均 Eg2 rho * Eg2 (1 - rho) * (grad ** 2) # 计算自适应学习率 adapted_lr learning_rate / (np.sqrt(Eg2) epsilon) # 参数更新 w w - adapted_lr * grad w_history.append(w.copy()) loss_history.append(loss_function(w)) return np.array(w_history), np.array(loss_history) # 测试RMSProp w_history_rms, loss_rms rmsprop(initial_w, lr0.1, n_iterations50) print(fRMSProp 最终损失: {loss_rms[-1]:.6f})RMSProp很好地解决了AdaGrad的学习率消失问题成为许多场景下的可靠选择。它特别适合处理非平稳目标如RNN的训练和梯度值变化范围大的问题。5. Adam优化器动量与自适应学习率的集大成者AdamAdaptive Moment Estimation可以说是目前深度学习领域最流行、默认首选的优化器。它结合了Momentum和RMSProp的思想同时计算梯度的一阶矩均值提供动量和二阶矩未中心化的方差提供自适应学习率并进行偏差校正。5.1 Adam算法原理分步拆解Adam的更新过程可以分为四步理解每一步是正确实现的关键计算梯度的一阶矩动量的指数移动平均m_tm_t β1 * m_{t-1} (1 - β1) * g_t这类似于Momentumβ1通常取0.9。计算梯度的二阶矩平方的指数移动平均v_tv_t β2 * v_{t-1} (1 - β2) * g_t^2这类似于RMSPropβ2通常取0.999。偏差校正Bias Correction 由于m_t和v_t初始化为0向量在训练初期它们会偏向于0。偏差校正通过除以(1 - β^t)来抵消这种偏差其中t是时间步。m_hat_t m_t / (1 - β1^t)v_hat_t v_t / (1 - β2^t)参数更新θ_t θ_{t-1} - η * m_hat_t / (√(v_hat_t) ε)这里m_hat_t提供了带有动量的更新方向而η / (√(v_hat_t) ε)为每个参数提供了自适应学习率。5.2 完整的Numpy实现def adam(initial_w, learning_rate, n_iterations, beta10.9, beta20.999, epsilon1e-8): Adam 优化器完整实现 w initial_w.copy() m np.zeros_like(w) # 一阶矩估计动量 v np.zeros_like(w) # 二阶矩估计自适应项 w_history [w.copy()] loss_history [loss_function(w)] for t in range(1, n_iterations 1): # t从1开始便于偏差校正 grad gradient(w) # 更新一阶和二阶矩估计 m beta1 * m (1 - beta1) * grad v beta2 * v (1 - beta2) * (grad ** 2) # 计算偏差校正后的估计 m_hat m / (1 - beta1**t) v_hat v / (1 - beta2**t) # 参数更新 w w - learning_rate * m_hat / (np.sqrt(v_hat) epsilon) w_history.append(w.copy()) loss_history.append(loss_function(w)) return np.array(w_history), np.array(loss_history) # 测试Adam w_history_adam, loss_adam adam(initial_w, lr0.3, n_iterations30) # Adam对学习率不敏感可以设大一些 print(fAdam 最终损失: {loss_adam[-1]:.6f})5.3 综合对比与可视化让我们在一个图上对比所有算法的优化路径和损失下降曲线。# 统一测试条件 initial_w np.array([-4.0, 3.0]) max_iters 40 algorithms { BGD: batch_gradient_descent(initial_w, 0.1, max_iters), Momentum: sgd_momentum(initial_w, 0.1, max_iters, 0.9), AdaGrad: adagrad(initial_w, 0.5, max_iters), RMSProp: rmsprop(initial_w, 0.1, max_iters, 0.9), Adam: adam(initial_w, 0.3, max_iters, 0.9, 0.999) } # 绘制优化路径对比图 plt.figure(figsize(15, 5)) # 子图1等高线路径图 plt.subplot(1, 2, 1) plt.contour(X, Y, Z, levels30, cmapcm.coolwarm, alpha0.5) markers [o, s, ^, D, P] colors [blue, green, red, purple, orange] for idx, (name, (w_hist, _)) in enumerate(algorithms.items()): plt.plot(w_hist[:, 0], w_hist[:, 1], markermarkers[idx], markersize4, labelname, colorcolors[idx], linewidth1.5, markevery3) plt.scatter(0, 0, cblack, s150, marker*, labelMinimum) plt.xlabel(X) plt.ylabel(Y) plt.title(Optimization Paths on Contour) plt.legend() plt.grid(True) # 子图2损失下降曲线图 plt.subplot(1, 2, 2) for idx, (name, (_, loss_hist)) in enumerate(algorithms.items()): plt.plot(loss_hist, labelname, colorcolors[idx], linewidth2) plt.yscale(log) # 使用对数坐标更清晰地显示下降趋势 plt.xlabel(Iteration) plt.ylabel(Loss (log scale)) plt.title(Loss Convergence Comparison) plt.legend() plt.grid(True) plt.tight_layout() plt.show()通过对比图你可以清晰地看到BGD路径直接但可能在陡峭方向震荡收敛速度一般。Momentum路径更平滑初期有“冲量”能更快穿过平坦区域。AdaGrad初期步长较大后期步长迅速减小收敛早但可能停在离最优点较远的地方。RMSProp自适应调整步长在崎岖地形表现稳定收敛曲线平滑。Adam通常结合了Momentum的快速和RMSProp的稳定在大多数情况下收敛最快、最稳健。实操心得Adam的默认参数Adam的作者论文中推荐的默认参数β10.9, β20.999, ε1e-8在绝大多数情况下工作得很好通常不需要调整。你需要调节的主要是学习率η。一个常见的经验是从3e-4或1e-3开始尝试。对于我们的简单二次函数由于问题很“干净”可以使用更大的学习率如0.3。但在复杂的神经网络训练中过大的学习率会导致不稳定。6. 在神经网络上的实战测试与对比为了更贴近真实场景我们构建一个简单的全连接神经网络在经典的Fashion-MNIST数据集上测试这些优化器。6.1 构建一个简单的多层感知机MLPimport numpy as np from sklearn.datasets import fetch_openml from sklearn.model_selection import train_test_split from sklearn.preprocessing import OneHotEncoder # 加载Fashion-MNIST数据集如果网络慢可以事先下载好 print(Loading Fashion-MNIST dataset...) X, y fetch_openml(Fashion-MNIST, version1, return_X_yTrue, parserauto) X X / 255.0 # 归一化到[0,1] y y.astype(int).reshape(-1, 1) # 将标签转为one-hot编码 encoder OneHotEncoder(sparse_outputFalse) y_onehot encoder.fit_transform(y) # 划分训练集和测试集 X_train, X_test, y_train, y_test train_test_split(X, y_onehot, test_size0.2, random_state42) print(fTraining set: {X_train.shape}, Test set: {X_test.shape}) # 定义网络层 def sigmoid(x): return 1 / (1 np.exp(-x)) def sigmoid_derivative(x): s sigmoid(x) return s * (1 - s) def softmax(x): exp_x np.exp(x - np.max(x, axis1, keepdimsTrue)) # 防溢出 return exp_x / np.sum(exp_x, axis1, keepdimsTrue) def cross_entropy_loss(y_pred, y_true): m y_true.shape[0] # 加一个小常数防止log(0) log_likelihood -np.log(y_pred[np.arange(m), np.argmax(y_true, axis1)] 1e-8) loss np.sum(log_likelihood) / m return loss def accuracy(y_pred, y_true): pred_labels np.argmax(y_pred, axis1) true_labels np.argmax(y_true, axis1) return np.mean(pred_labels true_labels) class SimpleMLP: def __init__(self, input_size, hidden_size, output_size): # He初始化适合ReLU族这里用sigmoid也可以用Xavier self.W1 np.random.randn(input_size, hidden_size) * np.sqrt(2. / input_size) self.b1 np.zeros((1, hidden_size)) self.W2 np.random.randn(hidden_size, output_size) * np.sqrt(2. / hidden_size) self.b2 np.zeros((1, output_size)) def forward(self, X): self.z1 X.dot(self.W1) self.b1 self.a1 sigmoid(self.z1) self.z2 self.a1.dot(self.W2) self.b2 self.a2 softmax(self.z2) return self.a2 def backward(self, X, y, output): m X.shape[0] # 输出层误差 dz2 output - y # 对于交叉熵损失和softmax梯度形式特别简单 # 隐藏层误差 da1 dz2.dot(self.W2.T) dz1 da1 * sigmoid_derivative(self.z1) # 计算梯度 dW2 (1/m) * self.a1.T.dot(dz2) db2 (1/m) * np.sum(dz2, axis0, keepdimsTrue) dW1 (1/m) * X.T.dot(dz1) db1 (1/m) * np.sum(dz1, axis0, keepdimsTrue) return dW1, db1, dW2, db26.2 实现一个通用的优化器训练框架我们将实现一个训练循环可以传入不同的优化器函数。def train_with_optimizer(X_train, y_train, model, optimizer_fn, epochs, batch_size, lr, **optimizer_kwargs): 通用训练函数 optimizer_fn: 优化器函数需接受参数(grad, state)并返回更新后的参数和状态 这里为了简化我们采用一种更直接的实现方式。 n_samples X_train.shape[0] loss_history [] acc_history [] # 初始化优化器状态对于不同优化器状态字典内容不同 # 我们将在优化器函数内部处理状态初始化 # 这里用一个列表存储每层的参数方便优化器处理 params [model.W1, model.b1, model.W2, model.b2] # 为每个参数初始化对应的优化器状态 states [] for param in params: # 初始化一个空字典优化器函数会填充它 states.append({}) for epoch in range(epochs): # 打乱数据 indices np.random.permutation(n_samples) X_shuffled X_train[indices] y_shuffled y_train[indices] epoch_loss 0 n_batches 0 for i in range(0, n_samples, batch_size): X_batch X_shuffled[i:ibatch_size] y_batch y_shuffled[i:ibatch_size] # 前向传播 output model.forward(X_batch) loss cross_entropy_loss(output, y_batch) epoch_loss loss # 反向传播 grads model.backward(X_batch, y_batch, output) # grads 是一个元组 (dW1, db1, dW2, db2) # 使用优化器更新参数 for idx, (param, grad) in enumerate(zip(params, grads)): # 调用优化器函数更新参数和状态 param[:], states[idx] optimizer_fn(param, grad, states[idx], lr, epoch, **optimizer_kwargs) n_batches 1 avg_loss epoch_loss / n_batches loss_history.append(avg_loss) # 每个epoch结束后在训练集上计算准确率可选耗时 train_output model.forward(X_train[:1000]) # 用部分数据评估加快速度 train_acc accuracy(train_output, y_train[:1000]) acc_history.append(train_acc) if (epoch 1) % 10 0: print(fEpoch {epoch1}/{epochs}, Loss: {avg_loss:.4f}, Acc: {train_acc:.4f}) return loss_history, acc_history # 定义各个优化器的更新函数 def sgd_update(param, grad, state, lr, epoch, **kwargs): 普通SGD更新无状态 param param - lr * grad return param, state def momentum_update(param, grad, state, lr, epoch, beta0.9, **kwargs): 带动量的SGD更新 if v not in state: state[v] np.zeros_like(param) v state[v] v beta * v (1 - beta) * grad param param - lr * v state[v] v return param, state def rmsprop_update(param, grad, state, lr, epoch, beta0.9, epsilon1e-8, **kwargs): RMSProp更新 if Eg2 not in state: state[Eg2] np.zeros_like(param) Eg2 state[Eg2] Eg2 beta * Eg2 (1 - beta) * (grad ** 2) param param - lr * grad / (np.sqrt(Eg2) epsilon) state[Eg2] Eg2 return param, state def adam_update(param, grad, state, lr, epoch, beta10.9, beta20.999, epsilon1e-8, **kwargs): Adam更新 if m not in state: state[m] np.zeros_like(param) state[v] np.zeros_like(param) state[t] 0 m, v, t state[m], state[v], state[t] t 1 m beta1 * m (1 - beta1) * grad v beta2 * v (1 - beta2) * (grad ** 2) m_hat m / (1 - beta1**t) v_hat v / (1 - beta2**t) param param - lr * m_hat / (np.sqrt(v_hat) epsilon) state[m], state[v], state[t] m, v, t return param, state6.3 运行对比实验由于完整训练较耗时我们使用一个小的子集和较少的轮次进行演示。# 为了快速演示使用数据子集 subset_size 5000 X_train_small X_train[:subset_size] y_train_small y_train[:subset_size] # 训练配置 input_size 784 hidden_size 128 output_size 10 epochs 30 batch_size 64 learning_rate 0.001 optimizers { SGD: sgd_update, Momentum: lambda p, g, s, lr, e: momentum_update(p, g, s, lr, e, beta0.9), RMSProp: lambda p, g, s, lr, e: rmsprop_update(p, g, s, lr, e, beta0.9), Adam: lambda p, g, s, lr, e: adam_update(p, g, s, lr, e, beta10.9, beta20.999) } results {} for opt_name, opt_fn in optimizers.items(): print(f\n Training with {opt_name} ) np.random.seed(42) # 确保每次初始化相同 model SimpleMLP(input_size, hidden_size, output_size) loss_hist, acc_hist train_with_optimizer( X_train_small, y_train_small, model, opt_fn, epochsepochs, batch_sizebatch_size, lrlearning_rate ) results[opt_name] {loss: loss_hist, acc: acc_hist, model: model}6.4 结果分析与可视化# 绘制训练损失和准确率曲线 plt.figure(figsize(14, 5)) plt.subplot(1, 2, 1) for opt_name, res in results.items(): plt.plot(res[loss], labelopt_name, linewidth2) plt.xlabel(Epoch) plt.ylabel(Training Loss) plt.title(Training Loss Comparison) plt.legend() plt.grid(True) plt.subplot(1, 2, 2) for opt_name, res in results.items(): plt.plot(res[acc], labelopt_name, linewidth2) plt.xlabel(Epoch) plt.ylabel(Training Accuracy) plt.title(Training Accuracy Comparison) plt.legend() plt.grid(True) plt.tight_layout() plt.show() # 在测试集上评估最终模型 print(\n Final Test Accuracy ) for opt_name, res in results.items(): model res[model] test_output model.forward(X_test) test_acc accuracy(test_output, y_test) print(f{opt_name}: {test_acc:.4f})在这个小规模实验中你可能会观察到SGD损失下降最慢曲线可能有较多震荡。Momentum损失下降速度明显快于SGD曲线更平滑。RMSProp通常能快速下降并稳定。Adam在大多数情况下无论是损失下降速度还是最终准确率都表现最佳或接近最佳。注意事项这个对比是在较小数据集和简单模型上进行的。在更复杂的问题如ImageNet图像分类和更深层的网络如ResNet上不同优化器的表现差异可能会更加明显有时经过精细调参的带动量的SGD可能达到与Adam相当甚至更好的最终性能。但Adam因其“开箱即用”的良好表现和更少的超参数调节成为了绝大多数情况下的默认选择。7. 优化器选择与调参经验谈经过从原理到实现的完整拆解你应该对每个优化器的“性格”有了直观感受。在实际项目中如何选择呢7.1 如何根据场景选择优化器首选Adam对于绝大多数标准的深度学习任务图像分类、自然语言处理等尤其是当你不想花太多时间调参时Adam是默认的、安全的选择。它自适应学习率和动量的组合对初始学习率不敏感通常在1e-3到3e-4之间尝试收敛速度快且稳定。考虑带动量的SGD如果你追求模型的最终极致性能并且有充足的算力和时间进行超参数搜索特别是学习率调度那么带动量的SGD通常称为SGDM可能是一个更好的选择。许多计算机视觉领域的顶尖论文如ResNet原文仍使用SGDM。它虽然需要手动设计学习率衰减策略如Step Decay, Cosine Annealing但调好后可能找到更尖锐的最小值从而获得更好的泛化性能。AdaGrad用于稀疏数据如果你的数据特征非常稀疏例如NLP中的词袋模型AdaGrad的表现可能很好因为它会给不常出现的特征分配更大的更新步长。RMSProp用于RNN在处理递归神经网络RNN或长序列数据时由于梯度可能存在爆炸或消失问题RMSProp的自适应学习率机制能提供更稳定的训练。7.2 关键超参数调优指南学习率η这是最重要的超参数。一个实用的策略是进行学习率扫描在一段范围如[1e-5, 1e-1]内以对数尺度取几个值进行快速训练如1-5个epoch观察初始损失下降情况。选择那个能使损失快速、稳定下降的最大学习率。批量大小Batch Size更大的批量通常意味着更稳定的梯度估计允许使用更大的学习率但会减少参数更新频率。一般选择2的幂次32, 64, 128, 256以利用硬件并行性。在小数据集上过大的批量可能导致泛化能力下降。Adam的β1, β2几乎永远不需要调整。保持β10.9, β20.999, ε1e-8。动量系数β for Momentum通常设为0.9。如果想更平滑可以尝试0.99。学习率调度Learning Rate Schedule对于SGD/Momentum学习率衰减至关重要。常见策略有Step Decay每N个epoch将学习率乘以一个衰减因子γ如0.1。Cosine Annealing学习率随epoch变化遵循余弦函数从初始值衰减到0。这通常能取得更好的效果。Warmup在训练初期如前5个epoch将学习率从0线性增加到预设值有助于训练稳定。7.3 训练过程监控与问题排查当训练出现问题时优化器往往是首要怀疑对象。以下是一些常见症状和排查思路损失值NaN或爆炸可能原因学习率过大。排查立即将学习率降低一个数量级如从1e-3降到1e-4重试。检查数据预处理如归一化、网络初始化使用He或Xavier初始化。对于Adam可以尝试将ε调大如从1e-8调到1e-7或1e-6虽然不常见但极端情况下二阶矩估计v_t可能太小导致除零问题。损失值震荡剧烈不收敛可能原因学习率仍然偏高或批量大小太小导致梯度噪声太大。排查继续降低学习率。尝试增大批量大小。检查数据是否被打乱。损失值下降一段时间后停滞可能原因学习率可能已经变得太小特别是对于AdaGrad或SGD没有衰减。排查实施学习率衰减策略。对于Adam可以尝试使用AdamW解耦权重衰减的Adam它有时能带来更好的收敛性。训练损失下降但验证损失上升过拟合这不是优化器的主要问题但优化器选择有影响。Adam因其快速收敛有时可能更快地过拟合。可以尝试增加正则化Dropout, L2正则化。使用SGDM并配合更强的数据增强。使用早停Early Stopping。实操心得学习率与批量大小的关系有一个经验法则当批量大小乘以k时学习率也可以近似乘以k来保持更新的“方差”相似。例如当你将批量大小从64增加到256乘以4时可以尝试将学习率从0.001增加到0.004。但这只是一个起点仍需根据实际训练情况调整。亲手实现一遍这些优化算法最大的收获不是记住了公式而是建立了一种直觉。当你看到训练曲线震荡时你能想到可能是动量不够大或者学习率太高当模型在某个损失值卡住时你会怀疑是AdaGrad的学习率衰减完了或是需要调整Adam的epsilon。这种从“黑箱”到“白盒”的理解是解决复杂模型训练问题的根本能力。下次当你import torch.optim.Adam时你看到的将不再是一个简单的API而是一套精妙的、融合了历史梯度信息与自适应步长的动态系统。这才是这个项目带给你的、远超代码本身的价值。
从零实现梯度下降算法:BGD到Adam的优化器原理与Python实践
发布时间:2026/5/31 4:22:30
1. 项目概述从“黑箱”到“白盒”的优化算法实践在机器学习和深度学习的实践中我们常常把各种优化器当作一个“黑箱”来使用。无论是TensorFlow的Adam还是PyTorch的SGD我们只需要导入、设置学习率然后调用.step()方法模型参数就会自动更新。这固然方便但久而久之我们可能会对梯度下降这个最核心的迭代优化过程感到陌生甚至产生一种“魔法”般的错觉。当模型训练出现震荡、不收敛或者陷入局部最优时如果对优化器内部的工作原理一知半解调试起来就会非常被动。这个项目的核心就是亲手用Python和Numpy从零开始实现梯度下降优化算法的几个经典变种。这绝不是一个简单的“抄写公式”练习。其真正的价值在于通过一行行代码的构建你将彻底理解每个算法是如何“看到”梯度、如何“记忆”过去、如何“预测”未来并最终做出参数更新决策的。你会明白为什么Momentum能让优化过程冲过狭窄的山谷为什么RMSProp能自适应地调整每个参数的学习率以及Adam是如何巧妙地融合了前两者的思想。当你下次再面对复杂的训练曲线时你看到的将不再是一堆令人困惑的折线而是算法内部动量、梯度二阶矩等状态量相互博弈的动态图景。我们将从最基础的批量梯度下降Batch Gradient Descent开始逐步实现小批量梯度下降Mini-batch GD、带动量的随机梯度下降SGD with Momentum、AdaGrad、RMSProp最终实现目前应用最广泛的Adam算法。整个过程我们将使用一个简单的二次函数作为优化目标进行可视化让你直观地看到不同算法在损失曲面上的“行走路径”有何不同。无论你是刚入门机器学习的新手希望夯实基础还是已有经验的研究者或工程师想要深入理解优化器以进行更高效的模型调优这个“造轮子”的过程都将让你受益匪浅。2. 核心概念与数学基础梯度下降的“灵魂”三问在动手写代码之前我们必须统一思想回答关于梯度下降的三个根本问题为什么能下降、沿着什么方向下降以及每一步下降多大。对这些问题的理解深度直接决定了我们实现算法时的代码清晰度和调试能力。2.1 损失函数与梯度优化问题的地图与指南针想象一下你被蒙上眼睛放置在一个连绵起伏的山丘上你的任务是找到海拔最低的谷底。你唯一能获取的信息就是通过脚底感受当前所在点的坡度。这里的“山丘地形”就是我们的损失函数 J(θ)它描述了模型参数 θ 取不同值时模型预测误差的大小。我们的目标就是找到使 J(θ) 最小的那个 θ。“脚底感受的坡度”就是数学上的梯度 ∇J(θ)。梯度是一个向量其方向指向函数值增加最快的方向。那么一个很自然的想法是要最快地降低高度损失我们就应该朝着坡度上升最快的反方向走。这就是梯度下降法的核心思想θ_new θ_old - η * ∇J(θ_old)。其中η 就是学习率Learning Rate它决定了我们沿着反梯度方向迈出的“步长”。注意梯度是损失函数对每个参数的偏导数组成的向量。对于有上百万参数的深度神经网络计算一次完整的梯度即在全量数据集上开销巨大这引出了随机性的概念。2.2 学习率步伐大小的艺术学习率 η 是梯度下降中最重要的超参数之一没有“之一”也不为过。它控制着参数更新的幅度。η 太大步伐过大可能会直接“跨过”山谷导致损失值震荡甚至发散越来越大。就像在下山时步子迈得太大直接从山坡的一边跳到了另一边。η 太小步伐过小收敛速度极慢可能需要非常多的迭代步数才能到达最低点并且容易陷入局部极小点而非全局最优点而无法跳出。在实际的算法变种中一个核心的改进方向就是让学习率不再是一个固定的标量而是能够自适应调整。有的算法为每个参数维护不同的学习率如AdaGrad, RMSProp有的算法则通过引入“动量”来影响有效的更新方向与大小如Momentum, Adam。2.3 批量Batch的概念计算效率与更新稳定性的权衡根据计算梯度时使用的数据量不同梯度下降主要有三种模式批量梯度下降Batch Gradient Descent, BGD使用整个训练集的数据计算损失函数的梯度。优点是梯度方向准确更新稳定必然朝着整体损失降低的方向前进缺点是每次更新计算开销大速度慢且无法处理超出内存容量的大型数据集。随机梯度下降Stochastic Gradient Descent, SGD每次随机使用一个训练样本计算梯度并立即更新参数。优点是更新频率极高计算快对于大规模数据很友好并且固有的随机噪声有助于跳出局部最优缺点是梯度估计非常嘈杂更新方向波动大损失函数下降过程会剧烈震荡。小批量梯度下降Mini-batch Gradient Descent这是前两者的折中也是深度学习中的实际标准。每次随机抽取一小批例如32, 64, 128个数据计算梯度。它兼具了BGD的相对稳定性和SGD的高效性并且可以利用现代计算库如Numpy, PyTorch的并行计算优势。在我们的实现中将重点放在Mini-batch GD及其改进变种上因为这是最具实用价值的基线。3. 基础实现从标准BGD到Mini-batch GD让我们先搭建一个简单的测试环境并实现最基础的版本作为后续复杂算法的对照基准。3.1 构建一个可视化测试环境为了直观对比算法性能我们优化一个简单的二维二次函数J(x, y) x^2 10 * y^2。这个函数在(0,0)处有全局最小值0。它的等高线是椭圆y轴方向比x轴方向“更陡峭”这可以用来测试算法处理不同尺度ill-conditioned问题的能力。import numpy as np import matplotlib.pyplot as plt from matplotlib import cm from mpl_toolkits.m3d import Axes3D # 定义目标函数和它的梯度 def loss_function(w): 一个简单的二次损失函数用于演示。w是一个二维向量[x, y] x, y w[0], w[1] return x**2 10 * y**2 def gradient(w): 损失函数的梯度 x, y w[0], w[1] return np.array([2*x, 20*y]) # 生成网格数据用于绘图 x np.linspace(-5, 5, 100) y np.linspace(-5, 5, 100) X, Y np.meshgrid(x, y) Z X**2 10 * Y**2 # 绘制3D曲面和等高线 fig plt.figure(figsize(16, 6)) ax1 fig.add_subplot(121, projection3d) surf ax1.plot_surface(X, Y, Z, cmapcm.coolwarm, alpha0.8, linewidth0) ax1.set_xlabel(X) ax1.set_ylabel(Y) ax1.set_zlabel(Loss) ax1.set_title(3D Surface of Loss Function) ax2 fig.add_subplot(122) contour ax2.contour(X, Y, Z, levels50, cmapcm.coolwarm) ax2.set_xlabel(X) ax2.set_ylabel(Y) ax2.set_title(Contour of Loss Function) plt.colorbar(contour, axax2) plt.tight_layout() plt.show()3.2 实现批量梯度下降BGDBGD的逻辑最为直接计算整个“数据集”在我们的例子中就是损失函数本身的梯度然后更新。def batch_gradient_descent(initial_w, learning_rate, n_iterations): 批量梯度下降实现 Args: initial_w: 初始参数向量如 np.array([-4.0, 3.0]) learning_rate: 固定学习率 n_iterations: 迭代次数 Returns: w_history: 参数更新历史用于绘图 loss_history: 损失值历史 w initial_w.copy() w_history [w.copy()] loss_history [loss_function(w)] for i in range(n_iterations): # 计算当前点的梯度这里就是整个函数的梯度 grad gradient(w) # 参数更新核心公式 w w - learning_rate * grad w_history.append(w.copy()) loss_history.append(loss_function(w)) return np.array(w_history), np.array(loss_history) # 测试BGD initial_w np.array([-4.0, 3.0]) lr 0.1 iters 50 w_history_bgd, loss_history_bgd batch_gradient_descent(initial_w, lr, iters) print(fBGD 最终参数: {w_history_bgd[-1]}, 最终损失: {loss_history_bgd[-1]:.6f})运行这段代码你会看到参数沿着最陡峭的下降方向前进。但由于y方向的梯度20y远大于x方向2x如果学习率设置不当在y方向很容易发生震荡。你可以尝试将learning_rate改为0.12观察损失值是如何发散的。3.3 实现小批量梯度下降Mini-batch GD在真实机器学习场景中我们的“数据”是样本集合。为了模拟Mini-batch我们创建一个虚拟的线性回归数据集。# 生成模拟数据 np.random.seed(42) n_samples 1000 X_data 2 * np.random.randn(n_samples, 1) # 1000个样本1个特征 # 生成带噪声的标签 y 4 3*X noise true_theta np.array([[4], [3]]) y_data true_theta[0] true_theta[1] * X_data np.random.randn(n_samples, 1) * 0.5 # 为数据添加偏置项 (x01) X_b np.c_[np.ones((n_samples, 1)), X_data] def linear_loss(theta, X, y): 线性回归的均方误差损失 m len(y) predictions X.dot(theta) return (1/(2*m)) * np.sum((predictions - y)**2) def linear_gradient(theta, X, y): 线性回归损失的梯度 m len(y) predictions X.dot(theta) grad (1/m) * X.T.dot(predictions - y) return grad def mini_batch_gradient_descent(X, y, initial_theta, learning_rate, n_epochs, batch_size): 小批量梯度下降实现 Args: X: 特征矩阵含偏置 y: 标签向量 initial_theta: 初始参数 learning_rate: 学习率 n_epochs: 遍历整个数据集的轮数 batch_size: 批大小 Returns: theta_history: 参数历史 loss_history: 损失历史 theta initial_theta.copy() m len(y) theta_history [theta.copy()] loss_history [linear_loss(theta, X, y)] for epoch in range(n_epochs): # 每个epoch开始时打乱数据 shuffled_indices np.random.permutation(m) X_shuffled X[shuffled_indices] y_shuffled y[shuffled_indices] # 按batch_size遍历数据 for i in range(0, m, batch_size): X_batch X_shuffled[i:ibatch_size] y_batch y_shuffled[i:ibatch_size] grad linear_gradient(theta, X_batch, y_batch) theta theta - learning_rate * grad # 可选每N个batch记录一次避免历史数据过大 theta_history.append(theta.copy()) loss_history.append(linear_loss(theta, X, y)) return np.array(theta_history), np.array(loss_history) # 测试Mini-batch GD initial_theta np.random.randn(2, 1) lr 0.01 epochs 50 batch_size 32 theta_history_mb, loss_history_mb mini_batch_gradient_descent(X_b, y_data, initial_theta, lr, epochs, batch_size) print(fMini-batch GD 最终参数: \n{theta_history_mb[-1].flatten()}) print(f真实参数: {true_theta.flatten()})实操心得在实现Mini-batch GD时每个epoch开始前打乱数据Shuffling至关重要。如果不打乱假设数据本身有特定顺序如按标签排序那么每个batch的梯度分布会有严重偏差导致优化过程不稳定甚至无法收敛。np.random.permutation(m)是一种高效的做法。4. 进阶优化器实现Momentum, AdaGrad, RMSProp基础GD算法的主要问题在于固定学习率和梯度噪声。下面的算法从不同角度试图解决这些问题。4.1 带动量的随机梯度下降SGD with Momentum动量法的灵感来源于物理学。想象一下在损失函数的曲面上推一个球它不仅受到当前坡度梯度的影响还会积累之前的动量。这带来两个好处1) 在梯度方向一致的维度上加速2) 在梯度方向频繁改变的维度上抑制震荡帮助穿过狭窄的山谷。其更新公式为v_t β * v_{t-1} (1 - β) * g_t θ_t θ_{t-1} - η * v_t其中g_t是当前梯度v_t是动量项β是动量系数通常取0.9η是学习率。def sgd_momentum(initial_w, learning_rate, n_iterations, beta0.9): 带动量的SGD实现在简单损失函数上演示 w initial_w.copy() v np.zeros_like(w) # 初始化动量为0 w_history [w.copy()] loss_history [loss_function(w)] for i in range(n_iterations): grad gradient(w) # 动量更新当前梯度与历史动量的加权和 v beta * v (1 - beta) * grad # 参数更新使用动量项v而非原始梯度g w w - learning_rate * v w_history.append(w.copy()) loss_history.append(loss_function(w)) return np.array(w_history), np.array(loss_history) # 对比BGD和Momentum initial_w np.array([-4.0, 3.0]) lr 0.1 iters 30 w_history_bgd, loss_bgd batch_gradient_descent(initial_w, lr, iters) w_history_mom, loss_mom sgd_momentum(initial_w, lr, iters, beta0.9) # 绘图对比路径 plt.figure(figsize(10, 8)) plt.contour(X, Y, Z, levels50, cmapcm.coolwarm, alpha0.5) plt.plot(w_history_bgd[:, 0], w_history_bgd[:, 1], o-, labelfBGD (lr{lr}), markersize4) plt.plot(w_history_mom[:, 0], w_history_mom[:, 1], s-, labelfMomentum (β0.9, lr{lr}), markersize4) plt.scatter(0, 0, cred, s100, marker*, labelGlobal Minimum) plt.xlabel(X) plt.ylabel(Y) plt.title(Optimization Paths: BGD vs Momentum) plt.legend() plt.grid(True) plt.show()运行对比图你会明显看到Momentum的路径更加平滑且由于初始累积的动量在初期“冲”得更快。在“峡谷”地形y方向陡x方向缓中Momentum能更快地沿着谷底方向x轴前进而普通BGD则在y方向反复震荡。4.2 AdaGrad自适应地为每个参数调整学习率AdaGrad的核心思想是对于频繁更新的参数梯度大我们已经学到了很多应该降低学习率迈小步对于不频繁更新的参数梯度小我们知之甚少应该保持较大的学习率迈大步。它通过累加参数历史梯度的平方来实现这一点。更新公式G_t G_{t-1} g_t ⊙ g_t # ⊙ 表示逐元素乘法 θ_t θ_{t-1} - (η / (√G_t ε)) ⊙ g_t其中G_t是梯度平方的累积ε是一个极小值如1e-8防止除零。def adagrad(initial_w, learning_rate, n_iterations, epsilon1e-8): AdaGrad 优化器实现 w initial_w.copy() G np.zeros_like(w) # 梯度平方累积变量 w_history [w.copy()] loss_history [loss_function(w)] for i in range(n_iterations): grad gradient(w) # 累积梯度平方 G grad ** 2 # 计算自适应学习率。注意学习率η在分母所以实际更新步长会越来越小。 adapted_lr learning_rate / (np.sqrt(G) epsilon) # 参数更新 w w - adapted_lr * grad w_history.append(w.copy()) loss_history.append(loss_function(w)) return np.array(w_history), np.array(loss_history) # 测试AdaGrad w_history_ada, loss_ada adagrad(initial_w, lr0.5, n_iterations50) # AdaGrad可以使用更大的初始学习率 print(fAdaGrad 最终损失: {loss_ada[-1]:.6f})注意事项AdaGrad有一个致命缺点——随着训练进行G_t会单调递增导致自适应学习率η/√G_t不断减小最终趋于零使得参数在训练后期几乎无法更新。这在训练深度网络时是个严重问题。因此AdaGrad更适用于稀疏数据场景如自然语言处理其中很多参数不常更新。4.3 RMSProp解决AdaGrad学习率衰减过快的问题RMSProp是对AdaGrad的改进由Geoffrey Hinton提出。它引入了一个衰减系数ρ通常为0.9将历史梯度平方的累积改为指数移动平均从而让“记忆”是有限的避免了学习率无限变小的问题。更新公式E[g^2]_t ρ * E[g^2]_{t-1} (1 - ρ) * g_t ⊙ g_t θ_t θ_{t-1} - (η / (√(E[g^2]_t) ε)) ⊙ g_tdef rmsprop(initial_w, learning_rate, n_iterations, rho0.9, epsilon1e-8): RMSProp 优化器实现 w initial_w.copy() Eg2 np.zeros_like(w) # 梯度平方的指数移动平均 w_history [w.copy()] loss_history [loss_function(w)] for i in range(n_iterations): grad gradient(w) # 更新梯度平方的指数移动平均 Eg2 rho * Eg2 (1 - rho) * (grad ** 2) # 计算自适应学习率 adapted_lr learning_rate / (np.sqrt(Eg2) epsilon) # 参数更新 w w - adapted_lr * grad w_history.append(w.copy()) loss_history.append(loss_function(w)) return np.array(w_history), np.array(loss_history) # 测试RMSProp w_history_rms, loss_rms rmsprop(initial_w, lr0.1, n_iterations50) print(fRMSProp 最终损失: {loss_rms[-1]:.6f})RMSProp很好地解决了AdaGrad的学习率消失问题成为许多场景下的可靠选择。它特别适合处理非平稳目标如RNN的训练和梯度值变化范围大的问题。5. Adam优化器动量与自适应学习率的集大成者AdamAdaptive Moment Estimation可以说是目前深度学习领域最流行、默认首选的优化器。它结合了Momentum和RMSProp的思想同时计算梯度的一阶矩均值提供动量和二阶矩未中心化的方差提供自适应学习率并进行偏差校正。5.1 Adam算法原理分步拆解Adam的更新过程可以分为四步理解每一步是正确实现的关键计算梯度的一阶矩动量的指数移动平均m_tm_t β1 * m_{t-1} (1 - β1) * g_t这类似于Momentumβ1通常取0.9。计算梯度的二阶矩平方的指数移动平均v_tv_t β2 * v_{t-1} (1 - β2) * g_t^2这类似于RMSPropβ2通常取0.999。偏差校正Bias Correction 由于m_t和v_t初始化为0向量在训练初期它们会偏向于0。偏差校正通过除以(1 - β^t)来抵消这种偏差其中t是时间步。m_hat_t m_t / (1 - β1^t)v_hat_t v_t / (1 - β2^t)参数更新θ_t θ_{t-1} - η * m_hat_t / (√(v_hat_t) ε)这里m_hat_t提供了带有动量的更新方向而η / (√(v_hat_t) ε)为每个参数提供了自适应学习率。5.2 完整的Numpy实现def adam(initial_w, learning_rate, n_iterations, beta10.9, beta20.999, epsilon1e-8): Adam 优化器完整实现 w initial_w.copy() m np.zeros_like(w) # 一阶矩估计动量 v np.zeros_like(w) # 二阶矩估计自适应项 w_history [w.copy()] loss_history [loss_function(w)] for t in range(1, n_iterations 1): # t从1开始便于偏差校正 grad gradient(w) # 更新一阶和二阶矩估计 m beta1 * m (1 - beta1) * grad v beta2 * v (1 - beta2) * (grad ** 2) # 计算偏差校正后的估计 m_hat m / (1 - beta1**t) v_hat v / (1 - beta2**t) # 参数更新 w w - learning_rate * m_hat / (np.sqrt(v_hat) epsilon) w_history.append(w.copy()) loss_history.append(loss_function(w)) return np.array(w_history), np.array(loss_history) # 测试Adam w_history_adam, loss_adam adam(initial_w, lr0.3, n_iterations30) # Adam对学习率不敏感可以设大一些 print(fAdam 最终损失: {loss_adam[-1]:.6f})5.3 综合对比与可视化让我们在一个图上对比所有算法的优化路径和损失下降曲线。# 统一测试条件 initial_w np.array([-4.0, 3.0]) max_iters 40 algorithms { BGD: batch_gradient_descent(initial_w, 0.1, max_iters), Momentum: sgd_momentum(initial_w, 0.1, max_iters, 0.9), AdaGrad: adagrad(initial_w, 0.5, max_iters), RMSProp: rmsprop(initial_w, 0.1, max_iters, 0.9), Adam: adam(initial_w, 0.3, max_iters, 0.9, 0.999) } # 绘制优化路径对比图 plt.figure(figsize(15, 5)) # 子图1等高线路径图 plt.subplot(1, 2, 1) plt.contour(X, Y, Z, levels30, cmapcm.coolwarm, alpha0.5) markers [o, s, ^, D, P] colors [blue, green, red, purple, orange] for idx, (name, (w_hist, _)) in enumerate(algorithms.items()): plt.plot(w_hist[:, 0], w_hist[:, 1], markermarkers[idx], markersize4, labelname, colorcolors[idx], linewidth1.5, markevery3) plt.scatter(0, 0, cblack, s150, marker*, labelMinimum) plt.xlabel(X) plt.ylabel(Y) plt.title(Optimization Paths on Contour) plt.legend() plt.grid(True) # 子图2损失下降曲线图 plt.subplot(1, 2, 2) for idx, (name, (_, loss_hist)) in enumerate(algorithms.items()): plt.plot(loss_hist, labelname, colorcolors[idx], linewidth2) plt.yscale(log) # 使用对数坐标更清晰地显示下降趋势 plt.xlabel(Iteration) plt.ylabel(Loss (log scale)) plt.title(Loss Convergence Comparison) plt.legend() plt.grid(True) plt.tight_layout() plt.show()通过对比图你可以清晰地看到BGD路径直接但可能在陡峭方向震荡收敛速度一般。Momentum路径更平滑初期有“冲量”能更快穿过平坦区域。AdaGrad初期步长较大后期步长迅速减小收敛早但可能停在离最优点较远的地方。RMSProp自适应调整步长在崎岖地形表现稳定收敛曲线平滑。Adam通常结合了Momentum的快速和RMSProp的稳定在大多数情况下收敛最快、最稳健。实操心得Adam的默认参数Adam的作者论文中推荐的默认参数β10.9, β20.999, ε1e-8在绝大多数情况下工作得很好通常不需要调整。你需要调节的主要是学习率η。一个常见的经验是从3e-4或1e-3开始尝试。对于我们的简单二次函数由于问题很“干净”可以使用更大的学习率如0.3。但在复杂的神经网络训练中过大的学习率会导致不稳定。6. 在神经网络上的实战测试与对比为了更贴近真实场景我们构建一个简单的全连接神经网络在经典的Fashion-MNIST数据集上测试这些优化器。6.1 构建一个简单的多层感知机MLPimport numpy as np from sklearn.datasets import fetch_openml from sklearn.model_selection import train_test_split from sklearn.preprocessing import OneHotEncoder # 加载Fashion-MNIST数据集如果网络慢可以事先下载好 print(Loading Fashion-MNIST dataset...) X, y fetch_openml(Fashion-MNIST, version1, return_X_yTrue, parserauto) X X / 255.0 # 归一化到[0,1] y y.astype(int).reshape(-1, 1) # 将标签转为one-hot编码 encoder OneHotEncoder(sparse_outputFalse) y_onehot encoder.fit_transform(y) # 划分训练集和测试集 X_train, X_test, y_train, y_test train_test_split(X, y_onehot, test_size0.2, random_state42) print(fTraining set: {X_train.shape}, Test set: {X_test.shape}) # 定义网络层 def sigmoid(x): return 1 / (1 np.exp(-x)) def sigmoid_derivative(x): s sigmoid(x) return s * (1 - s) def softmax(x): exp_x np.exp(x - np.max(x, axis1, keepdimsTrue)) # 防溢出 return exp_x / np.sum(exp_x, axis1, keepdimsTrue) def cross_entropy_loss(y_pred, y_true): m y_true.shape[0] # 加一个小常数防止log(0) log_likelihood -np.log(y_pred[np.arange(m), np.argmax(y_true, axis1)] 1e-8) loss np.sum(log_likelihood) / m return loss def accuracy(y_pred, y_true): pred_labels np.argmax(y_pred, axis1) true_labels np.argmax(y_true, axis1) return np.mean(pred_labels true_labels) class SimpleMLP: def __init__(self, input_size, hidden_size, output_size): # He初始化适合ReLU族这里用sigmoid也可以用Xavier self.W1 np.random.randn(input_size, hidden_size) * np.sqrt(2. / input_size) self.b1 np.zeros((1, hidden_size)) self.W2 np.random.randn(hidden_size, output_size) * np.sqrt(2. / hidden_size) self.b2 np.zeros((1, output_size)) def forward(self, X): self.z1 X.dot(self.W1) self.b1 self.a1 sigmoid(self.z1) self.z2 self.a1.dot(self.W2) self.b2 self.a2 softmax(self.z2) return self.a2 def backward(self, X, y, output): m X.shape[0] # 输出层误差 dz2 output - y # 对于交叉熵损失和softmax梯度形式特别简单 # 隐藏层误差 da1 dz2.dot(self.W2.T) dz1 da1 * sigmoid_derivative(self.z1) # 计算梯度 dW2 (1/m) * self.a1.T.dot(dz2) db2 (1/m) * np.sum(dz2, axis0, keepdimsTrue) dW1 (1/m) * X.T.dot(dz1) db1 (1/m) * np.sum(dz1, axis0, keepdimsTrue) return dW1, db1, dW2, db26.2 实现一个通用的优化器训练框架我们将实现一个训练循环可以传入不同的优化器函数。def train_with_optimizer(X_train, y_train, model, optimizer_fn, epochs, batch_size, lr, **optimizer_kwargs): 通用训练函数 optimizer_fn: 优化器函数需接受参数(grad, state)并返回更新后的参数和状态 这里为了简化我们采用一种更直接的实现方式。 n_samples X_train.shape[0] loss_history [] acc_history [] # 初始化优化器状态对于不同优化器状态字典内容不同 # 我们将在优化器函数内部处理状态初始化 # 这里用一个列表存储每层的参数方便优化器处理 params [model.W1, model.b1, model.W2, model.b2] # 为每个参数初始化对应的优化器状态 states [] for param in params: # 初始化一个空字典优化器函数会填充它 states.append({}) for epoch in range(epochs): # 打乱数据 indices np.random.permutation(n_samples) X_shuffled X_train[indices] y_shuffled y_train[indices] epoch_loss 0 n_batches 0 for i in range(0, n_samples, batch_size): X_batch X_shuffled[i:ibatch_size] y_batch y_shuffled[i:ibatch_size] # 前向传播 output model.forward(X_batch) loss cross_entropy_loss(output, y_batch) epoch_loss loss # 反向传播 grads model.backward(X_batch, y_batch, output) # grads 是一个元组 (dW1, db1, dW2, db2) # 使用优化器更新参数 for idx, (param, grad) in enumerate(zip(params, grads)): # 调用优化器函数更新参数和状态 param[:], states[idx] optimizer_fn(param, grad, states[idx], lr, epoch, **optimizer_kwargs) n_batches 1 avg_loss epoch_loss / n_batches loss_history.append(avg_loss) # 每个epoch结束后在训练集上计算准确率可选耗时 train_output model.forward(X_train[:1000]) # 用部分数据评估加快速度 train_acc accuracy(train_output, y_train[:1000]) acc_history.append(train_acc) if (epoch 1) % 10 0: print(fEpoch {epoch1}/{epochs}, Loss: {avg_loss:.4f}, Acc: {train_acc:.4f}) return loss_history, acc_history # 定义各个优化器的更新函数 def sgd_update(param, grad, state, lr, epoch, **kwargs): 普通SGD更新无状态 param param - lr * grad return param, state def momentum_update(param, grad, state, lr, epoch, beta0.9, **kwargs): 带动量的SGD更新 if v not in state: state[v] np.zeros_like(param) v state[v] v beta * v (1 - beta) * grad param param - lr * v state[v] v return param, state def rmsprop_update(param, grad, state, lr, epoch, beta0.9, epsilon1e-8, **kwargs): RMSProp更新 if Eg2 not in state: state[Eg2] np.zeros_like(param) Eg2 state[Eg2] Eg2 beta * Eg2 (1 - beta) * (grad ** 2) param param - lr * grad / (np.sqrt(Eg2) epsilon) state[Eg2] Eg2 return param, state def adam_update(param, grad, state, lr, epoch, beta10.9, beta20.999, epsilon1e-8, **kwargs): Adam更新 if m not in state: state[m] np.zeros_like(param) state[v] np.zeros_like(param) state[t] 0 m, v, t state[m], state[v], state[t] t 1 m beta1 * m (1 - beta1) * grad v beta2 * v (1 - beta2) * (grad ** 2) m_hat m / (1 - beta1**t) v_hat v / (1 - beta2**t) param param - lr * m_hat / (np.sqrt(v_hat) epsilon) state[m], state[v], state[t] m, v, t return param, state6.3 运行对比实验由于完整训练较耗时我们使用一个小的子集和较少的轮次进行演示。# 为了快速演示使用数据子集 subset_size 5000 X_train_small X_train[:subset_size] y_train_small y_train[:subset_size] # 训练配置 input_size 784 hidden_size 128 output_size 10 epochs 30 batch_size 64 learning_rate 0.001 optimizers { SGD: sgd_update, Momentum: lambda p, g, s, lr, e: momentum_update(p, g, s, lr, e, beta0.9), RMSProp: lambda p, g, s, lr, e: rmsprop_update(p, g, s, lr, e, beta0.9), Adam: lambda p, g, s, lr, e: adam_update(p, g, s, lr, e, beta10.9, beta20.999) } results {} for opt_name, opt_fn in optimizers.items(): print(f\n Training with {opt_name} ) np.random.seed(42) # 确保每次初始化相同 model SimpleMLP(input_size, hidden_size, output_size) loss_hist, acc_hist train_with_optimizer( X_train_small, y_train_small, model, opt_fn, epochsepochs, batch_sizebatch_size, lrlearning_rate ) results[opt_name] {loss: loss_hist, acc: acc_hist, model: model}6.4 结果分析与可视化# 绘制训练损失和准确率曲线 plt.figure(figsize(14, 5)) plt.subplot(1, 2, 1) for opt_name, res in results.items(): plt.plot(res[loss], labelopt_name, linewidth2) plt.xlabel(Epoch) plt.ylabel(Training Loss) plt.title(Training Loss Comparison) plt.legend() plt.grid(True) plt.subplot(1, 2, 2) for opt_name, res in results.items(): plt.plot(res[acc], labelopt_name, linewidth2) plt.xlabel(Epoch) plt.ylabel(Training Accuracy) plt.title(Training Accuracy Comparison) plt.legend() plt.grid(True) plt.tight_layout() plt.show() # 在测试集上评估最终模型 print(\n Final Test Accuracy ) for opt_name, res in results.items(): model res[model] test_output model.forward(X_test) test_acc accuracy(test_output, y_test) print(f{opt_name}: {test_acc:.4f})在这个小规模实验中你可能会观察到SGD损失下降最慢曲线可能有较多震荡。Momentum损失下降速度明显快于SGD曲线更平滑。RMSProp通常能快速下降并稳定。Adam在大多数情况下无论是损失下降速度还是最终准确率都表现最佳或接近最佳。注意事项这个对比是在较小数据集和简单模型上进行的。在更复杂的问题如ImageNet图像分类和更深层的网络如ResNet上不同优化器的表现差异可能会更加明显有时经过精细调参的带动量的SGD可能达到与Adam相当甚至更好的最终性能。但Adam因其“开箱即用”的良好表现和更少的超参数调节成为了绝大多数情况下的默认选择。7. 优化器选择与调参经验谈经过从原理到实现的完整拆解你应该对每个优化器的“性格”有了直观感受。在实际项目中如何选择呢7.1 如何根据场景选择优化器首选Adam对于绝大多数标准的深度学习任务图像分类、自然语言处理等尤其是当你不想花太多时间调参时Adam是默认的、安全的选择。它自适应学习率和动量的组合对初始学习率不敏感通常在1e-3到3e-4之间尝试收敛速度快且稳定。考虑带动量的SGD如果你追求模型的最终极致性能并且有充足的算力和时间进行超参数搜索特别是学习率调度那么带动量的SGD通常称为SGDM可能是一个更好的选择。许多计算机视觉领域的顶尖论文如ResNet原文仍使用SGDM。它虽然需要手动设计学习率衰减策略如Step Decay, Cosine Annealing但调好后可能找到更尖锐的最小值从而获得更好的泛化性能。AdaGrad用于稀疏数据如果你的数据特征非常稀疏例如NLP中的词袋模型AdaGrad的表现可能很好因为它会给不常出现的特征分配更大的更新步长。RMSProp用于RNN在处理递归神经网络RNN或长序列数据时由于梯度可能存在爆炸或消失问题RMSProp的自适应学习率机制能提供更稳定的训练。7.2 关键超参数调优指南学习率η这是最重要的超参数。一个实用的策略是进行学习率扫描在一段范围如[1e-5, 1e-1]内以对数尺度取几个值进行快速训练如1-5个epoch观察初始损失下降情况。选择那个能使损失快速、稳定下降的最大学习率。批量大小Batch Size更大的批量通常意味着更稳定的梯度估计允许使用更大的学习率但会减少参数更新频率。一般选择2的幂次32, 64, 128, 256以利用硬件并行性。在小数据集上过大的批量可能导致泛化能力下降。Adam的β1, β2几乎永远不需要调整。保持β10.9, β20.999, ε1e-8。动量系数β for Momentum通常设为0.9。如果想更平滑可以尝试0.99。学习率调度Learning Rate Schedule对于SGD/Momentum学习率衰减至关重要。常见策略有Step Decay每N个epoch将学习率乘以一个衰减因子γ如0.1。Cosine Annealing学习率随epoch变化遵循余弦函数从初始值衰减到0。这通常能取得更好的效果。Warmup在训练初期如前5个epoch将学习率从0线性增加到预设值有助于训练稳定。7.3 训练过程监控与问题排查当训练出现问题时优化器往往是首要怀疑对象。以下是一些常见症状和排查思路损失值NaN或爆炸可能原因学习率过大。排查立即将学习率降低一个数量级如从1e-3降到1e-4重试。检查数据预处理如归一化、网络初始化使用He或Xavier初始化。对于Adam可以尝试将ε调大如从1e-8调到1e-7或1e-6虽然不常见但极端情况下二阶矩估计v_t可能太小导致除零问题。损失值震荡剧烈不收敛可能原因学习率仍然偏高或批量大小太小导致梯度噪声太大。排查继续降低学习率。尝试增大批量大小。检查数据是否被打乱。损失值下降一段时间后停滞可能原因学习率可能已经变得太小特别是对于AdaGrad或SGD没有衰减。排查实施学习率衰减策略。对于Adam可以尝试使用AdamW解耦权重衰减的Adam它有时能带来更好的收敛性。训练损失下降但验证损失上升过拟合这不是优化器的主要问题但优化器选择有影响。Adam因其快速收敛有时可能更快地过拟合。可以尝试增加正则化Dropout, L2正则化。使用SGDM并配合更强的数据增强。使用早停Early Stopping。实操心得学习率与批量大小的关系有一个经验法则当批量大小乘以k时学习率也可以近似乘以k来保持更新的“方差”相似。例如当你将批量大小从64增加到256乘以4时可以尝试将学习率从0.001增加到0.004。但这只是一个起点仍需根据实际训练情况调整。亲手实现一遍这些优化算法最大的收获不是记住了公式而是建立了一种直觉。当你看到训练曲线震荡时你能想到可能是动量不够大或者学习率太高当模型在某个损失值卡住时你会怀疑是AdaGrad的学习率衰减完了或是需要调整Adam的epsilon。这种从“黑箱”到“白盒”的理解是解决复杂模型训练问题的根本能力。下次当你import torch.optim.Adam时你看到的将不再是一个简单的API而是一套精妙的、融合了历史梯度信息与自适应步长的动态系统。这才是这个项目带给你的、远超代码本身的价值。