Skip to content

Optimization Topic 1: Optimizer

计算机的数值计算

数值解

深度学习的优化过程,本质上就是在寻找一个能让损失函数最小化的神经网络参数集合,这个过程几乎完全依赖于数值计算方法,尤其是基于梯度的迭代优化算法。具体来说,一个经典的神经网络学习优化过程如下:

  1. 前向传播:输入数据,通过网络计算得到一个预测值。这个过程是大量的矩阵乘法和激活函数计算,是纯粹的数值计算。
  2. 计算损失:用损失函数来衡量预测值和真实值之间的差距,这同样是数值计算。
  3. 反向传播:为了调整参数来减少损失,我们需要计算损失函数对每一个参数的梯度(偏导数),常用的比如反向传播算法,这也是一种数值方法。
  4. 参数更新:使用一种优化算法,比如Adam,根据计算出来的梯度来微调网络中的所有参数。这是一个反复迭代的数值逼近过程。

上面所说的具体流程在后面的章节中一一学习,这里提出的原因是想说明:数值计算是机器学习以及深度学习的核心,必须要学习掌握,否则无法展开后面内容的学习。

绝大多数机器学习问题(比如训练一个复杂的神经网络)都极其复杂,根本不可能存在一个直接算出最优参数的完美公式。换句话说,机器学习算法要通过迭代过程(Iterative Process)来不断更新解的估计值,而不是通过解析过程(Analytical Solution)直接推导出一个公式。

常见的数值计算的应用场景:

  • 优化:在深度学习中,寻找一组能让损失函数最小化的参数
  • 线性方程组的求解:比如经典的线性回归模型中会遇到

上溢和下溢

我们已经知道机器学习和深度学习的核心就是一系列的数值计算。在理想的情况下,我们将通过成千上万次的迭代,让参数平稳地、逐步地向着损失函数的最低点收敛。然而,这个完美的数学流程并不能完美地在计算机上实现。首当其冲的问题就是上溢(Overflow)和下溢(Underflow):

  • Overflow 上溢:当一个数字太大,超过了计算机能表示的最大范围时,他就会变成一个无穷大(inf)或者一个无意义(NaN, Not a Number)的数字。
  • Underflow 下溢:当一个数字变得太接近于零,超出了计算机能表示的最小精度时,它就会被“四舍五入”成零。

举例来说:(先不要管具体方法是怎么实现的,只是帮助理解一下上溢和下溢)

  • 在反向传播中,梯度的计算是多个导数的连乘。如果网络很深,且激活函数的导数持续小于1,那么梯度在从后向前传播时会越乘越小,最终变得无限接近于零,从而被计算机当做零来处理。
  • 反过来讲,如果在反向传播计算中梯度持续大于1,那么连乘之后就会变得异常巨大,超出计算机能表示的最大值,最终变成inf或NaN。一旦某次计算更新中让算出来一个NaN,那么整个模型瞬间就被摧毁了,训练过程直接失败。

那这里肯定有人会问:python已经支持大整数运算,那么假设我们支持小数点后10万位的小数,计算时把他转换为整数不就好了吗?理论上是可以的,但是计算效率会非常之低。对于一个相当规模的深度学习训练,计算次数是上万亿次的。因此,数值计算速度非常重要。

现在一般的方法是直接把支持32位浮点数和64位浮点数计算的电路单元直接焊死在电路单元或者说电路板上。这个小东西被称为FPU(Floating-Point Unit)。这些电路在一个时钟周期内就能完成一次浮点数加法或乘法,速度快到极致。我们可以把FPU理解为厨师,他只做一件事:进行浮点数计算(加减乘除)。他做的飞快,是物理上真实存在的实体。在一个GPU中,分布着成千上万个这样的厨师,并通过CUDA架构来实现高效的协同工作。

FPU的物理限制是显而易见的,Overflow和Underflow的问题非常突出。举例来说,如果某一次计算中的输入向量是:\(x=[1000,990,980]\)

我们的FPU使用的标准是32位浮点数,一个float32能表示的最大数值是\(3.4 \times 10^{38}\),任何超过这个数的计算,FPU都会报告上溢(Overflow),并返回一个特殊值 inf

在这里,我们先不讨论为什么要用下面这个公式,这里只是展示overflow的情况下我们该怎么办:

\[ \mathrm{softmax}(\mathbf{x})_i = \frac{\exp(x_i)}{\sum_{j=1}^n \exp(x_j)}. \]

好了,现在第一步就超出FPU的限制了,第一个计算就没能得到结果。程序继续计算e的990次方、980次方,结果都是 inf,整个计算过程全面崩坏,我们不可能得到想要的结果。

为了解决这个问题,我们必然需要把输入向量x进行某种调整。只要我们能把数值调整为计算结果不会溢出就可以了,这种调整一般被称为“数值稳定”。

还是以上面的向量x为示例,举一个数值稳定的方法:

  1. 首先找到向量x中的最大值,max(x)=1000
  2. 对x中的每一个元素,减去这个最大值,得到新的向量\(z=[0,-10,-20]\)
  3. 计算新的softmax函数,上述变换不会改变softmax的最终概率值(因为贡献比例没有改变)
  4. 由于max(x)的巧妙设置,分母中永远都至少有一个exp(0)=1,而其他下溢的内容被记为0,这样就可以总能得到softmax的结果值而不会出错。换句话说,经过上述变换,上溢问题被解决了,而下溢在softmax计算中又是无害的,因此不会造成计算的崩坏。

当然,现在的深度学习框架如PyTorch和TensorFlow都已经把上述数值计算过程进行了封装,用户在调用计算时不会出现上下溢出问题。例如,调用 torch.nn.functional.softmax时,上面的数值稳定方法(框架中甚至使用了更加优异的数值稳定方法!)已经内嵌在函数中,我们直接调用就可以了。

病态条件

病态条件(Ill-conditioned)的意思是:对于一个函数,其输入的微小变化,将会显著地影响到其输出结果。病态条件在计算机的数值计算中是一个大麻烦,因为计算机在存储数字的时候总会有微小的舍入误差,比如把1/3存储为0.3333333,当这个微小的、不可避免的误差发生在一个病态条件的输入上时,会导致输出产生巨大的偏差,导致计算结果和真实值有天壤之别。

由于深度学习的核心人任务之一就是优化,即寻找一个能让损失函数最小化的参数集。我们可以把损失函数想象成一个非常复杂的、高低不平的地形表面,我们的目标是走到这个地形的最低点。在深度学习任务中,这个地形往往是病态的,他并不光滑,而是更像一个峡谷,在一些地方极其陡峭,而在另外一些地方又极其平缓,如下图所示:

1759274402466

这个病态的损失函数表面有如下特征:

  • 充满陡峭的悬崖,在这里损失函数在某个方向上会变化极快
  • 平缓的谷底,在这里损失函数在某个方向上的变化又会极慢

这种特点会导致我们的优化算法(比如梯度下降)在进入到这样的峡谷时,梯度在陡峭方向会非常大,在平缓方向又会非常小,从而导致在优化过程在峡谷两侧来回震荡,难以向真正的最低点前进。为了防止震荡,我们必须使用很小的学习率,但是这又会导致在平缓的方向上的前进速度变得极其缓慢,从而导致训练卡住或者不收敛。

这里要注意,上面的3D图只是一种比喻。真实的神经网络的损失函数,其参数量是一个维度极高的向量,远远不止三维。一个中等大小的模型都可能有数百万(百万维)甚至数十亿(十亿维)的参数。这里以及大多数教材中画3D峡谷图,是为了方便我们理解,是一种降维的可视化类比,帮助我们建立直观的理解。换句话说,3D可视图无论如何构建,都是极度简化的切片,有着严重的失真,但足够我们用来理解病态条件(峡谷)、局部最小值(小坑)等重要概念。

既然知道了病态的损失函数表面的这些特点,那么我们就可以针对性地来采用这样一种策略:

  • 当我们在平缓的谷底时,用较快的速度前进
  • 当我们在陡峭的悬崖时,用较慢的速度移动

上述策略其实就是如今人们常用的优化器(Optimizer) 的核心思想。这里要注意,并非每种优化器都有这种策略,传统的GD(梯度下降)、SGD(随机梯度下降)中没有这种动态变化能力,而Adam、带有Polyak动量的SGD则具备了这种能力。这种能力一般由动量(Momentum)自适应学习率(Adaptive Learning Rate) 组成,在后面我们会展开来详细讲解。在学习具备动态功能的高级优化器之前,我们还是要从最为传统的优化器开始,了解梯度下降等基本概念。

基于梯度的优化方法

Ian经典教材第四章中除了计算机的数值计算、梯度下降、Hessian外,还介绍了一些其他概念,比如最优步长、牛顿法、Lipschitz连续、凸优化等,如果后续遇到,我再回来补充。概念太多,没办法一下子全都记住,因此我们暂时先记住最重要的概念

  • 梯度是一个向量,永远指向函数值上升最快的方向
  • 梯度下降就是朝着梯度的反方向前进
  • Hessian矩阵描述了地形的曲率(弯曲程度),通过分析Hessian,我们可以知道当前位置是U型山谷(曲率小)、V型峡谷(曲率大),还是更复杂的鞍点
  • 当Hessian矩阵的最大曲率和最小曲率相差悬殊时,我们就遇到了病态地形。这种地形就好比一个两侧陡峭、谷底平缓的峡谷,会导致梯度下降收敛缓慢,让算法在峡谷两侧来回震荡等,时所有优化算法都要面对的头号敌人。
  • 泰勒一阶展开证明了为什么负梯度方向是有效的,二阶展开则揭示了曲率是如何影响优化效果的,并揭示了线性预测和真实情况之间的差距
  • 在深度学习这种高维空间中,我们遇到的梯度为零的点,绝大多数都是鞍点,而不是局部最小值。简单的梯度下降会在这里卡柱,而我们之后要学习的现代优化器则有逃离鞍点的机制。
  • 既然Hessian描述了地形,那么最理想的优化方法就是在每一步都同时利用梯度和Hessian来计算出完美的下一步。牛顿法虽然完美,但是计算成本过高,不适合深度学习,但这也是所有高级优化器试图模仿和近似的目标。

在Ian书中的第四章还有最后一部分“约束优化”,这一部分暂时先跳过。现在正在学的内容主要都是无约束优化,其目标是在一个没有任何限制的广阔空间里,找到函数的最低点;这是绝大多数深度神经网络如CNN,RNN,Transformer训练的标准场景。

而另一条重要分支:约束优化,也就是书中的这一节,目标是在一系列规则或限制条件下,找到函数的最低点。这个技术路线是KKT方法等,在一些特定的机器学习方法如支持向量机SVM,以及一些特定的深度学习课题中较为重要。

由于现在我们的关注点在无约束优化上,因此这一小节暂先跳过,等到需要学习的时候我们再返回来看。

接下来我将依次介绍各种梯度下降优化器,首先依据一阶或二阶,优化器可以大体分为两类:一阶优化器 First-Order Optimizers和二阶优化器。

我们先介绍一阶优化器,然后介绍二阶优化器。

一阶优化器(First-Order Optimizers)按照发展的先后顺序,主要有以下内容:

  1. 普通梯度下降:Gradient Descent (1847), SGD (1951)
  2. 结合动量法:Polyak Momentum (1964), Nesterov Momentum (1983)
  3. 结合Adaptive LR:AdaGrad (2011), RMSProp (2012), AdaDelta (2012)
  4. 同时结合动量和自适应梯度:Adam (2014), AdamW (2017)

二阶优化器(Second-Order Optimizers)按照发展的先后顺序,主要有以下内容:

  1. 纯粹二阶:牛顿法 (17世纪),准牛顿法L-BFGS (1989)
  2. 近似二阶:KFAC (2015),AdaHessian (2020)

Gradient Descent

在数学上,优化(Optimization) 指的是从所有可能的解决方案中,系统地选择并找出一个或者一组最优解的过程。放到函数上来说,一般指通过改变x,来最小化函数\(f(x)\);最大化本质就是最小化,因为可以通过\(-f(x)\)来采用同样的优化方法。

我们把要最小化的函数称为目标函数(objective function)准则(criterion)。当我们对其进行最小化时,这个函数有时也被叫做代价函数(cost function)损失函数(loss function)误差函数(error function)。在不同情境或论文中作者可能会更倾向于用某一个名称,但是他们的本质都是要最小化的目标函数。

对于使函数能取到最小值的自变量\(x\),我们记作\(x^{*} = \arg\min f(x)\)最大化的话就是\(x^{*} = \arg\max_{x} f(x)\)。在微积分中,对于函数\(y=f(x)\),它的导数(derivative)**记为\(f'(x)\)\(\frac{dx}{dy}\)。如果\(f'(x)>0\),说明在x点函数是向上倾斜的,趋势是递增的,也就是说函数在x点处继续增加,函数会变大;反之如果导数小于0,则是递减趋势。

由于我们的目标是找到函数的最小值,因此:

  • 在导数大于0时,函数是“上坡”,我们应该减少x,朝上坡的反向走
  • 在导数小于0时,函数是“下坡”,我们应该增大x,继续下坡

这个策略就叫做梯度下降(gradient descent)。在数学上,泰勒一阶展开证明了梯度下降为什么是有效的:

\[ f(x+\epsilon) \approx f(x) + \epsilon f'(x) \]

这个式子表明,在x处,如果我们行进一个微小的距离\(\epsilon\),那么行进后的函数大小近似等于原本x处的函数大小,加上原本x处的函数导数乘以这个微小距离。为了让新的函数值 f(x+ϵ) 小于旧的函数值 f(x),我们只需要让增量部分 ϵf(x) 为负数即可。而保证这一项为负数的最有效方法,就是让我们选择的‘行进方向’ ϵ 与导数 f(x)的符号相反。换句话说,泰勒一阶展开证明了梯度下降是有效策略。

\(f'(x)=0\)的时候,导数无法提供往哪个方向移动的信息,因此这个点被称为临界点(critical point)驻点(stationary point)

1759333058922

如上图所示,导数为0时可能会有三种情况:

  • Local Minimum 局部最小值
  • Local Maximum 局部最大值
  • Saddle Point 鞍点,或称为Inflection Point 拐点

在上述三种情况中,我们通过二阶导数来判断:

  • 如果二阶导数\(f''(x)>0\),那么表明函数是凹的,即山谷,局部最小点
  • 如果二阶导数\(f''(x)<0\),那么表明函数是凸的,即山峰,局部最大点
  • 如果二阶导数\(f''(x)=0\),那么表面函数是平坦的,像在马鞍的中心

在梯度下降中,局部最小点是理想的停止点,这表明我们找到了一个可能的解。而局部最大值和鞍点都是糟糕的停止点,因为在局部最大点和鞍点,我们无法判断该往哪边走是正确的。标准的梯度下降算法无法区分这三种情况,只要梯度为零他就会停下来。因此现代优化算法针对最大值和鞍点的问题,发展出了动量(Momentum)随机性(Stochasticity)二阶方法(Second-Order Methods) 等解决策略。

我们的最终目标是找到全局最小点(global minimum)。一个函数可能有一个全局最小点,也可能有多个,也可能没有全局最小点。想象一个左侧往无穷大、右侧往无穷小的函数图像\(f(x) = -x^3 + 3x\)

1759333775897

上面这个图像在右侧趋向于无穷小,在数学上称为无下界(Unbounded Below)。在梯度下降中,这种函数会导致发散(Divergence),即运行过程中未能收敛到一个稳定的解,而是趋向于一个无效的或者无限的结果。发散在函数值趋向于正无穷时也有可能产生,比如如果学习率(等一下就要讲到)设置的太高,步子迈太大,一步直接跨过了山谷的最低点而到达了对面更高的位置,下一步为了下山又跨的更远,导致越走越高。

现在我们来正式看一下梯度下降在深度学习中是如何应用的。上面我们讨论的都是一维函数,即一条线,但实际操作中我们面对的总是多变量函数。

对于一个多维输入的函数\(f: \mathbb{R}^n \to \mathbb{R}\)(这里的\(\mathbb{R}^n\)指函数的输入是一个n维向量),我们队最小值的概念有一个明确的意义:最小值必须是一维的标量。因为标量可以直接比较大小,而向量无法直接比较大小。这个标量通常也被称为损失(Loss)成本(Cost)

在最小值处,有对应着的一个n维向量,这个特定的多维向量就是我们的最优解(Optimal Solution)。换句话说,判断一个向量是否是最优解的标准,就是把他代入函数,然后看计算出来的标量是不是所有可能结果中最小的。

对于一维函数,我们已经知道,对函数求导,当导数为0时,我们就找到了一个特殊的点,这个点可能是极小值、极大值或者鞍点。而对于多维向量,我们用偏导数(partial derivative)对该函数进行求导,这个时候我们可以得到一个由原本输入向量中每个元素的偏导数组成的“包含所有偏导数的向量”,记为\(\nabla_{x} f(x)\),读作“对x的梯度(gradient)”。梯度的的第\(i\)个元素是\(f\)关于\(x_i\)的偏导数。在多维情况下,临界点是梯度中所有元素都为零的点。


举例说明一下,假设我们的多变量函数是\(f(x, y) = x^{2} + 2xy + y^{3}\),那么在这里我们就有输入向量:

\[ \mathbf{x} = \begin{bmatrix} x \\ y \end{bmatrix} \]

我们的目标是计算这个函数在任意点\((x,y)\)的梯度。根据定义,对于我们这个二维函数,他的梯度就是包含了所有偏导数的向量,即:

\[ \nabla_{X} f(x, y) = \begin{bmatrix} \text{对 }x\text{ 的偏导数} \\ \text{对 }y\text{ 的偏导数} \end{bmatrix} = \begin{bmatrix} \dfrac{\partial f}{\partial x} \\ \dfrac{\partial f}{\partial y} \end{bmatrix} \]

通过计算,我们可以分别得到对x的偏导数:

\[ \frac{\partial f}{\partial x} = 2x + 2y \]

以及对y的偏导数:

\[ \frac{\partial f}{\partial y} = 2x + 3y^{2} \]

我们把两个偏导数放回第一步的向量结构中,得到:

\[ \nabla_{X} f(x, y) = \begin{bmatrix} 2x + 2y \\ 2x + 3y^{2} \end{bmatrix} \]

这就是函数\(f(x,y)\)在任意点\((x,y)\)梯度公式。用这个公式,只要你把任何一个点的坐标代入进去,你就能很快算出那个点的梯度向量

比如说,在点\((3,2)\)处:

\[ \nabla_{X} f(x=3, y=2) = \begin{bmatrix} 10 \\ 18 \end{bmatrix} \]

这个梯度向量的含义是,函数f的值上升最快的方向,是朝着x轴走10个单位,同时朝着y轴走18个单位的那个方向。在二维平面上我们可以可视化地画出来,但是对于10000维的高维向量,我们只要用这个道理去找就行了,不需要把图像画出来找。换句话说,用这个方法,对于任意维度的多维向量,我们不需要画出图来就能判断一个点处的梯度形势。

对于上面这个方向,我们知道他是最f上升最快的方向,也就是最陡的上坡路。那么在梯度下降算法中,为了让函数值下降的最快,我们就要朝着这个梯度的反方向移动,也就是朝着\(\begin{bmatrix}-10 \\-18\end{bmatrix}\)的方向移动一小步。


对单变量函数求出来的导数,和多变量函数求出来的梯度向量,它们的每一个分量都是一“上坡指南”,指明了在各自维度上让函数值增加的方向和强度。这是导数的性质。因此,对于梯度向量,将所有维度的“上坡指南”合并成的合力方向,一定是函数整体上升最陡峭的方向。书中通过基于方向导数来寻找函数值下降最快方向的数学证明确认这了这一点(P76-77),这里不再展开。我们只需要记住:梯度永远指向最陡的上坡,它的反方向就是最陡的下坡。

既然我们知道了下坡方向,那么我们就可以沿着这个方向前进,来寻找最小值。首先我们确定一个固定步长大小的正标量,记为\(\epsilon\),这个值被称为学习率(learning rate)。

对于多维向量\(X\)

\[ X_{t+1} = X_t - \epsilon \nabla_{X} f(X_t) \]

这个公式就是最速下降法(method of steepest descent)或者说梯度下降(gradient descent) 的更新规则。

通过最速下降法迭代公式,我们可以不断寻找下坡路,直到找到一个完全平坦的地方。我们可以通过迭代的方式不断逼近该目标,或者直接通过求解方程组=0来直接跳到临界点(这种直接求解的方式叫做解析解)。在现实世界中,深度学习模型的函数往往极其复杂,梯度方程组也极其庞大,根本无法直接求解析解。因此我们才需要梯度下降这种看起来笨拙但却十分有效的迭代方法。

梯度下降算法只能用于连续空间中的优化问题。但是,对于很多参数离散的问题,比如整数规划、旅行商等,虽然无法直接计算梯度,但是可以用类似的思想,不断在当前位置找到一个最好的方向然后移动一小步来找到最优解。这个方法被称为爬山(hill climbing)算法。(之所以叫做爬山,是因为爬山算法经常用于最大化目标函数的问题,比如棋类游戏、遗传算法等;而梯度下降主要用于机器学习任务,在机器学习中我们的目标几乎总是寻找最小化损失函数。)


Jacobian

在上面的梯度下降中,我们提到用标量来衡量一个输入函数(比如包含神经网络所有参数x的多维向量)的最小值(即损失值L),记作\(f: \mathbb{R}^n \to \mathbb{R}\)

但是,即便我们的神经网络最终得到的是一个标量,在神经网络中间,我们面临的是若干个多维输入到多维输出的中间层,如下图所示:

1759368379528

这个中间过程表示为\(f: \mathbb{R}^n \to \mathbb{R}^m\),即n维输入到m维输出。那么对于这样的中间层,我们如何来了解每一个输入的变化,是如何影响到每一个输出的呢?如果用梯度向量,那么对于每一个输出变量,我们都要计算n个偏导数;那么对于m个输出变量,我们就要计算m * n个偏导数。那么如何组织这m*n个偏导数,系统性地表示和使用这海量的导数信息呢?Jacobian矩阵就是为了解决这个问题而诞生的。

Jacobian矩阵提供了一个完美的、紧凑的数学结构,即使用一个矩阵,将这 m * n 个偏导数整齐地收纳其中。通过收纳,我们就可以进一步利用线性代数中的矩阵乘法,简洁、高效地表达和计算链式法则。

具体来说,假设我们有一个函数 \(f\),它接受一个 \(n\) 维的向量输入

\[ x = [x_1, x_2, \ldots, x_n]^T \]

并产生一个 \(m\) 维的向量输出

\[ y = f(x) = [f_1(x), f_2(x), \ldots, f_m(x)]^T. \]

这里的 \(f_1, f_2, \ldots, f_m\)\(m\) 个独立的函数,它们共同组成了这个多维输出。

Jacobian 矩阵(通常记作 \(\mathbf{J}\))就是将所有输出函数对所有输入变量的一阶偏导数排列成一个矩阵。 它的标准表示如下:

\[ \mathbf{J} = \begin{bmatrix} \dfrac{\partial f_1}{\partial x_1} & \dfrac{\partial f_1}{\partial x_2} & \cdots & \dfrac{\partial f_1}{\partial x_n} \\ \dfrac{\partial f_2}{\partial x_1} & \dfrac{\partial f_2}{\partial x_2} & \cdots & \dfrac{\partial f_2}{\partial x_n} \\ \vdots & \vdots & \ddots & \vdots \\ \dfrac{\partial f_m}{\partial x_1} & \dfrac{\partial f_m}{\partial x_2} & \cdots & \dfrac{\partial f_m}{\partial x_n} \end{bmatrix}. \]

在该矩阵中:

  • 每一行代表一个输出函数对所有输入变量的偏导数;你可以把第\(i\)行看作是函数\(f_i\)的梯度(的转置)。
  • 每一列代表所有输出函数对一个输入变量的偏导数。

举一个从 \(\mathbb{R}^2\) 映射到 \(\mathbb{R}^3\) 的具体例子,假设输入向量是

\[ x = \begin{bmatrix} x_1 \\ x_2 \end{bmatrix}. \]

输出向量由以下三个函数定义:

\[ f_1(x_1, x_2) = x_1^2 + 5x_2 \]
\[ f_2(x_1, x_2) = 3x_1 \sin(x_2) \]
\[ f_3(x_1, x_2) = x_1^2 x_2 \]

根据定义,这个函数的 Jacobian 矩阵 \(\mathbf{J}\) 将是一个 \(3 \times 2\) 的矩阵。我们来逐行计算它的元素:

第一行(对 \(f_1\) 求偏导):

  • \[ \dfrac{\partial f_1}{\partial x_1} = 2x_1 \]
  • \[ \dfrac{\partial f_1}{\partial x_2} = 5 \]

第二行(对 \(f_2\) 求偏导):

  • \[ \dfrac{\partial f_2}{\partial x_1} = 3\sin(x_2) \]
  • \[ \dfrac{\partial f_2}{\partial x_2} = 3x_1\cos(x_2) \]

第三行(对 \(f_3\) 求偏导):

  • \[ \dfrac{\partial f_3}{\partial x_1} = x_2^2 \]
  • \[ \dfrac{\partial f_3}{\partial x_2} = 2x_1x_2 \]

现在,把这些结果填入矩阵中,我们就得到了这个函数的 Jacobian 矩阵的表达式:

\[ \mathbf{J} = \begin{bmatrix} 2x_1 & 5 \\ 3\sin(x_2) & 3x_1\cos(x_2) \\ x_2^2 & 2x_1x_2 \end{bmatrix}. \]

这个矩阵就完整地描述了在任意点 \((x_1, x_2)\) 处,输入向量的微小变化是如何传递并影响到输出向量的。


Hessian

在梯度下降中,我们要找到最陡峭的方向,然后反向下山。然后我们设定一个学习率,代表下山的步子有多大。那么接下来,我们面临这样一个问题:我们在下山的时候,下降的高度,和预期的是一样的吗?

想要检查梯度下降步骤是否达到预期,那么我们其实就要关注梯度下降的曲率。

1759369691308

在上图的三种情况中,绿色虚线就是我们之前提到的泰勒一阶展开\(f(x+\epsilon) \approx f(x) + \epsilon f'(x)\),也就是梯度下降算法所依赖的线性预测。根据这个预测,我们可以得到移动后的新位置\(x_{\text{new}} = x - \eta f'(x)\)

我们可以注意到,一阶泰勒展开用的是约等于,这代表它只是一个近似,而忽略了更高阶的信息。这里忽略掉的,即是梯度下降算法中忽视的曲率,也即造成绿色虚线和蓝色实现存在差距的原因。这个差距的大小和方向,由二阶导数决定。在上图中:

  • 左图代表的是\(f''(x)<0\)的情况,实际下降多于线性预期
  • 中图代表的是\(f''(x)=0\)的情况
  • 右图代表的是\(f''(x)>0\)的情况,实际下降小于线性预期

为了更加精确,我们可以进行泰勒二阶展开:

\[ f(x+\epsilon) \approx \underbrace{f(x)+\epsilon f'(x)}_{\text{梯度下降的线性预测}} \;+\; \underbrace{\tfrac{1}{2}\epsilon^2 f''(x)}_{\text{现实地形的曲率修正}} \]

这个公式清晰地揭示了所有秘密:

  • 梯度下降的预期,就是前半部分的线性预测
  • 现实与预期的差距,就是后半部分的修正项

可以看到,修正项的大小,由二阶导数来决定。我们把多维函数的二阶导数(second derivative)叫做Hessian矩阵:

\[ H(f)(x)_{i,j} = \frac{\partial^2}{\partial x_i \, \partial x_j} f(x). \]

Hessian矩阵等价于梯度的Jacobian矩阵。

到这里,我们可以对上面的内容进行一个小结:

  1. 我们有一个损失函数\(f(x)\),他的类型是多维输入对单一输出
  2. 我们队\(f(x)\)进行一阶导数求导,得到它的梯度向量\(\nabla f(x)\)
  3. 我们把梯度本身看做一个新函数\(g(x)\),它的输出是一个多维向量,因为神经网络是多层的,输入的多维向量进行一次求导后我们得到的是一个中间层;对这个中间层\(g(x)\)求导,可以得到Jacobian矩阵。
  4. Jacobian矩阵\(J_g\)的第\(i\)行、第\(j\)列的元素是\((\mathbf{J}_g)_{i,j} = \frac{\partial g_i}{\partial x_j}\),这里的\(g_i\)就是函数\(g\)输出向量的第\(i\)个分量,换句话说,也就是梯度向量的第\(i\)个分量,即\(\frac{\partial f}{\partial x_i}\),代入到原式中,得到:
\[ (\mathbf{J}_g)_{i,j} = \frac{\partial}{\partial x_j} \left( \frac{\partial f}{\partial x_i} \right) = \frac{\partial^2 f}{\partial x_j \, \partial x_i} \]

可以看到,这就是我们刚才讨论的Hessian矩阵。绝大多数情况下,我们不说“梯度的Jacobian矩阵”,而是直接说“Hessian矩阵”。


现在我们已经了解了梯度下降、Hessian矩阵,以及知道如何衡量下降时是否符合预期。上面我们提到了一维泰勒二阶展开:

\[ f(x+\epsilon) \approx f(x)+\epsilon f'(x) \;+\; \frac{1}{2}\epsilon^2 f''(x) \]

我们把上式扩展到多维的泰勒二阶展开:

\[ f(x) \approx f\bigl(x^{(0)}\bigr) + \bigl(x - x^{(0)}\bigr)^{T} g + \tfrac{1}{2}\bigl(x - x^{(0)}\bigr)^{T} \mathbf{H} \bigl(x - x^{(0)}\bigr) \]

这个式子就是在当前点\(x^{(0)}\)附近一个精确的地形地图,其中\(g\)时梯度,\(H\)时Hessian矩阵。你可以认为,这个公式用数学方法描述了局部地形的具体细节。

接着,我们把梯度下降的更新规则:

\[ x = x^{(0)} - \epsilon \mathbf{g} \]

带入到多维泰勒二阶展开中,即可得到:

\[ f\bigl(x^{(0)} - \epsilon \mathbf{g}\bigr) \approx \underbrace{f\bigl(x^{(0)}\bigr)}_{\text{当前损失}} \;-\; \underbrace{\epsilon \mathbf{g}^{T}\mathbf{g}}_{\text{一阶线性预期下降}} \;+\; \underbrace{\tfrac{1}{2}\epsilon^{2}\mathbf{g}^{T}\mathbf{H}\mathbf{g}}_{\text{二阶曲率修正项}} \]

这个预测公式可以预测出梯度下降的下一步后,损失值变成了多少。其中:

  • 当前损失:我们的起点
  • 线性预期下降:基于一阶导数的乐观预测
  • 二阶曲率修正:由Hessian主导的,决定现实与预期差距的关键项

在实际中,或者说一个常规的、标准的梯度下降训练循环中,我们不需要多此一举地计算这个泰勒预测值。一般来说,程序会用 x_new = x_old - εg 计算出新位置;然后把 x_new 扔进模型里,通过一次完整的前向传播,直接计算出真实、精确的新损失值 f(x_new)。换句话说,我们总是能得到真实值,那么为什么我们在这里要去讨论泰勒二阶展开和近似的预测公式呢?——对于优化器算法设计来说,这些都是非常关键的。

不过对于大多数人来说,并不需要自己去设计优化器,只需要了解大致的原理,知道如何选择优化器、不同的优化器之间有什么区别就够了。故而在现阶段,我们只需要对这些概念留有一个大致的印象。

一般来讲,Hessian的应用包括:

  • 判断临界点类型:通过检测临界点Hessian矩阵所有特征值的正负号来了解;如果所有特征值都为正,则为局部最小值;如果所有特征值都为负,则为局部最大值;如果特征值中又有正数又有负数,则处于鞍点。
  • 分析梯度下降的性能:利用泰勒二阶展开攻势可以计算出使得损失下降最多的最优步长;在最坏的情况下(梯度方向与最大曲率方向一致),最优步长由Hessian的特征值决定。

SGD

随机梯度下降 (Stochastic Gradient Descent, SGD)的理论基础源于一篇名为《一种随机逼近方法》(A Stochastic Approximation Method) 的论文,由Herbert Robbins和Sutton Monro于1951年提出。

SGD是一种快速的优化算法,核心思想是“随机看小部分,快速迭代”。其工作方式:不像标准方法看完所有数据再更新,SGD每次只随机抽取一小批(甚至一个)数据来计算梯度,并立即调整模型参数。在GD中,我们需要把所有的训练数据都看一遍,才能计算一次梯度,然后更新一次参数(走一步)。如果数据集有几百万张,那么走一步得等到猴年马月了。SGD是一个聪明的改进,每次随机抓取一小把数据(mini batch),比如64张图片,然后就用这一小把数据来估算一下梯度的大致方向,然后立刻走一步。

SGD的核心更新公式如下:

\[ \theta := \theta - \eta \cdot \nabla_{\theta} J(\theta; x^{(i)}, y^{(i)}) \]

其中:

  • \(\theta\):theta,表示模型的参数(包含所有权重weights和偏置biases的向量),我们的目标就是通过训练找到最优的theta
  • \(:=\) 表示赋值(Assignment)操作
  • \(\eta\):eta,表示学习率(Learning Rate),控制每次参数更新的步长。如果eta很大,参数更新的幅度就会很大。
  • $\nabla_{\theta} $ 表示梯度(Gradient)。梯度是一个向量,指向损失函数值上升最快的方向,因此梯度下降需要在前面加上负号。
  • \(J(\theta; x^{(i)}, y^{(i)})\) 表示损失函数(Loss Function)。这个函数用来衡量模型在单个训练样本\((x^{(i)}, y^{(i)})\)上的预测效果有多差。\(x^i\)是第\(i\)个训练样本的特征,\(y^i\)是第\(i\)个训练样本的真实标签。

可以看出,SGD的更新公式和传统GD相比,区别进在于批量的大小。传统的梯度下降发也叫批量梯度下降法(Batch Gradient Descent, BGD),其核心思想是每次更新参数时都要用全部的训练数据来计算梯度:\(\theta := \theta - \eta \cdot \nabla_{\theta} J(\theta)\)。而SGD的损失函数只针对一个样本,因此计算速度更快。

优点:

  • 快: 计算量小,特别适合大规模数据集。更新参数的频率极高,训练速度飞快。
  • 避免局部最优: 更新方向的随机“噪声”有助于跳出不够好的解决方案,寻找全局最优解。

缺点:

  • 不稳定: 下降路径震荡,不平滑。每一步的方向可能不太准,但总体趋势是往下走的。
  • 可能不收敛: 最终可能在最优点附近徘徊,而不是精确到达。

实际中常用的是“小批量梯度下降 (Mini-batch GD)”,它是SGD和标准梯度下降的折中,兼顾了速度和稳定性。我们可以从最纯粹的SGD定义中看到,每次更新模型参数只使用一个随机选择的训练样本,比如:

  • 一行表格数据
  • 一张图片及其标签
  • 一篇文章中的一句话,或者一系列文档中的一个文档

由于现代计算机硬件特别是GPU的并行处理数据能力较高,因此一般实践中我们每次使用一个小批量(mini-batch)的样本,比如32、64、或128个来计算梯度。除了计算效率更高外,只用1个训练样本可能会非常嘈杂和随机,导致训练过程震荡很剧烈。而使用一个批次的平均梯度,能让收敛过程更加稳定(更接近真实梯度)。在深度学习相关的议题和论文中,SGD一般指代的就是小批量梯度下降,而不是纯粹的随机梯度下降。

Momentum

Polyak动量

动量(Momentum),有时候也称为Heavy Ball Method,其核心概念在1964年由苏联数学家Boris Polyak提出。最初这种方法并非用在SGD上,而是用在标准GD上。Polyak介绍了一种“heavy ball”概念,也就是数学上的动量,可以加速收敛,特别是在沟壑或者病态场景(ill-conditioned scenarios)下。

在狭长的病态峡谷中,SGD会来回震荡,前进缓慢。动量(Momentum)就是为了解决这个问题而生的。动量在更新参数的时候,不仅考虑当前计算出的梯度,还参考上一步移动的方向和速度。其效果就是,在峡谷两侧来回震荡的时候,一左一右的梯度会因为惯性的存在而相互抵消,从而抑制了震荡。而在平缓的谷底,梯度方向很稳定,惯性会不断积累,从而加速冲刺。

一句话记住就是,Polyak动量=梯度+惯性。

Nesterov动量

Nesterov Momentum(Nesterov's Accelerated Gradient, NAG)是在1983年由另一位苏联数学家Yurii Nesterov提出的。

这是对Polyak动量的一个巧妙改进。其核心思想是,不急着在当前位置计算梯度,而是先假装按照现在的惯性往前走一步,在新的位置探探路,看看那里的梯度是什么样的,然后再结合惯性决定最终该怎么走。

一句话记住就是,Nesterov动=预判梯度+惯性,比标准动量更聪明

Adaptive Gradient

动量是从方向上解决问题,而自适应梯度(Adaptive Gradient)是从步长上解决问题。

AdaGrad

RMSProp

RMSProp也是一个自适应学习率的算法。

AdaDelta

10.14的课上讲的

Adam

Adam

Adam = Momentum + Adaptive Gradient

AdamW

....

ksjdf

二阶优化器

二阶优化器(Second-Order Optimizers) 按照发展的先后顺序,主要有以下内容:

  1. 纯粹二阶:牛顿法 (17世纪),准牛顿法L-BFGS (1989)
  2. 近似二阶:KFAC (2015),AdaHessian (2020)

牛顿法

。。。

L-BFGS

....

KFAC

....

AdaHessian

....

附:Project

不同优化器对比实验(MNIST/CIFAR-10)

在入门章节,我们在MNIST手写数据集上进行了LeNet-5实验,了解了入门深度学习训练的过程。在学习优化器后,我们今天做一个新的实验,在CIFAR-10数据集上测试不同优化器的训练效果。

1759683747292

CIFAR-10相比于MNIST,类别数量并没有增加,还是10个类别,但是识别对象的复杂度明显有了一个巨大的提升:图片采用32x32像素的RGB三通道色彩图像,背景嘈杂,图像分辨率低。

我们使用如下三个架构来在MNIST和CIFAR10上进行实验:

  • A smiple MLP on MNIST
  • A simple CNN on CIFAR-10
  • VGG-13 on CIFAR-10

简单的MLP:(针对MNIST任务)

  • 输入1张1x28x28的图片,1就是1个通道(灰度图)
  • Flatten把二维图片压平成一个1x784的一维向量(也就是把像素方阵的所有像素排成一队)
  • 接着通过第一个隐藏层Gemm:这是神经网络的第一个全连接层,它接收长度为784的输入向量,并通过一个1024x784的权重矩阵B,将其线性变换为一个长度为1024的隐藏特征向量,这是网络从原始像素中学习抽象特征的开始
  • Relu:紧跟其后的激活函数,用于增加非线性,让网络能学习更复杂的关系
  • 接着跟着第二个隐藏层
  • 最后连接到最后一个Gemm,这是输出层,它将来自第二个隐藏层的1024的高级特征,把他们映射为10个输出值

1759972513735

三层CNN:(针对CIFAR任务)

  • 输入为1张RGB彩色3通道的32x32像素的图片
  • 数据从左到右依次经过三个相同的处理单元:Conv 卷积层 -> Relu 激活层 -> MaxPool 最大池化层
  • Flatten 展平层
  • Gemm 全连接层
  • 最终输出一个1x10的向量,对应10个类别的可能性,分数最高的就是预测结果

1759972003992

VGG13:(针对CIFAR任务)

  • 下图所示是VGG16,我们在任务中使用的是同一家族谱系的VGG13,并且针对CIFAR进行了简化。VGG是牛津大学视觉几何研究组(Visual Geometry Group )发明的,并以此为名。VGG13有10个卷积层+3个全连接层;VGG16有13个卷积层+3个全连接层。
  • VGG13/VGG16在卷积核尺寸上有着严格的设定:统一使用3x3尺寸的卷积核

1759973178745

具体的训练代码可以参见REPO:

项目报告参见:

结果如下:

Optimizer MLP on MNIST CNN on CIFAR-10 VGG13 on CIFAR-10
SGD 91.82% 50.64% 52.68%
AdaGrad 97.77% 69.98% 68.11%
RMSProp 98.29% 68.65% 73.61%
Adam 98.32% 70.15% 74.61%
Polyak_0.9 97.64% 73.00% 65.75%
Nesterov_0.9 97.62% 72.91% 66.02%

其中Adam整体表现最好,Polyak和Nesterov动量法在CNN on CIFAR-10任务上优于Adam。