MATH575B这门研究生数值分析课程,下半学期第一部分就把梯度下降拎出来单独讲。刚学完矩阵迭代、线性方程组解法,很多同学一看这个选题会犯嘀咕:前面还在搞矩阵分解和迭代收敛,怎么突然画风一转开始做优化了?其实梯度下降恰好是连接线性代数数值方法与最优化问题的桥梁,理解了它,后面再学拟牛顿、信赖域、加速方法都会顺畅很多。这篇文章就按我学习、使用以及辅导这门课的心得,把梯度下降这件事从头到尾拆一遍,内容包括数学原理、步长选择、收敛性分析、代码实现和常见坑,尽量做到不绕弯子。
1. 数值分析视角下为什么要学梯度下降
1.1 从线性方程组到优化问题的自然延伸
数值分析课程的前半段通常围绕线性问题展开,比如LU分解、QR分解、Jacobi迭代、Gauss-Seidel迭代。这些方法处理的典型场景是:给定线性方程组,直接求解或者迭代求解。但现实中大量问题并不是“解一个方程组”,而是“找一组参数让某个目标最小化”。最典型的就是最小二乘问题:数据拟合时要最小化残差平方和,写出来就是一个优化问题。把正规方程展开后确实可以转化为线性方程组,但数据量大到一定程度,直接构造正规方程往往又贵又不稳定,这时候就应该回到“迭代地沿着某种方向改进参数”的思路,也就是梯度下降。
从课程编排的逻辑来看,梯度下降放在数值分析下半学期第一部分,作用是承前启后。承前,是因为它要用到向量范数、矩阵特征值、条件数这些前面反复强调的概念;启后,是因为后面的牛顿法、拟牛顿法、共轭梯度法都可以看成在梯度下降框架上做改进。如果只记住“沿负梯度方向走一步”这个操作,却不理解步长和条件数决定的收敛速度,那后面遇到算法变体只会觉得是一堆毫无联系的公式。
1.2 课程里常见的考察方式和学习重点
我接触这门课的过程中,发现它的题目风格非常固定。第一类考题是给一个具体的二次函数或简单多项式,要求写出梯度下降的迭代格式,然后手算前两三步,观察变量和函数值的变化。第二类是给定步长条件,比如函数的梯度满足Lipschitz条件,问步长取多少能保证收敛,这考的是对理论框架的理解。第三类是修改算法,比如加上动量项,或者改用回溯线搜索,然后比较收敛行为的变化。第四类是结合数值实验,要求画出迭代路径的等高线图,直观解释锯齿现象。
换句话说,这门课并不要求学生背下一堆定理,而是要求学生能够从“数学原理→算法设计→现象解读”这条链路去理解梯度下降。这一点上,数值分析的视角和机器学习课程差别很大:机器学习课更关注“怎么在数据集上跑得更快”,数值分析课则更关注“为什么这个迭代能收敛、收敛有多快、在什么条件下会失效”。
2. 数学原理拆解:方向、步长和收敛的底层逻辑
2.1 为什么最速下降方向是负梯度方向
梯度下降的迭代格式很简洁:给定当前点,沿负梯度方向走一步,步长为,写成
其中是学习率或者叫步长。要搞清楚这个格式为什么合理,得回到函数的一阶泰勒展开。设当前点在附近,沿单位方向移动一小段距离,那么
想让函数值下降,需要取负值;想让下降最快,就得让这个内积尽量负。根据柯西-施瓦茨不等式,对内积有下限
当下标等于时,正好取到等号。换句话说,在所有单位方向里,负梯度方向是函数值瞬时下降最快的方向。这也是为什么梯度下降又被称为最速下降法的原因。
不过这里有个容易误导人的地方:“最速”指的是无穷小步长意义下的瞬时最快,不代表沿着这个方向一直走就能最快到最优点。实际迭代中,每一步都重新计算梯度、重新选方向,路径通常不是直线,这一点后面讲锯齿现象时会详细说。
2.2 学习率的定量约束与Lipschitz条件
步长的选择直接决定算法是收敛、缓慢爬行还是直接发散。课程里通常先假设目标函数的梯度满足Lipschitz条件,意思是梯度的变化不会太剧烈,存在常数使得
这个条件的几何含义是:函数不能有太夸张的“陡峭度突变”。如果梯度不满足这个条件,用固定的步长去做迭代,很有可能从陡峭区域一步跳到完全相反的地方。考虑一个简单的一维二次函数,解析的最优步长区间可以通过泰勒展开得到。对二次函数,其中矩阵的特征值为正,梯度为,迭代后的误差为
要让误差范数单调下降,需要小于1,也就是。若最大特征值,那么步长必须小于。如果取,刚好处于临界状态,不保证单调;如果大于,某些方向上误差会放大,直接发散。
课程里常见的一个结论是:当步长取,且函数同时满足梯度Lipschitz条件和强凸条件时,函数值的误差每步至少乘上一个因子,其中是强凸参数。这里。如果条件数很大,就非常接近1,收敛会变得极慢。我用生活化一点的类比来解释:梯度下降就像一个蒙着眼下山的人,每走一步只能感觉到脚下的坡度变化,但看不到整座山的形状。坡面越“细长狭窄”,他越容易在沟谷里来回折线前进,明明在往山下走,却始终到不了最低点。
2.3 精确线搜索下相邻梯度正交的性质
还有一个容易被忽略的性质:当使用精确线搜索,也就是每一步都把步长取到使函数值最小的时候,相邻两步的梯度方向是正交的。证明很简单,设在方向走到最优,那么
这说明下一步的梯度与当前方向垂直。由于当前方向是负梯度方向,所以下一步梯度与当前梯度垂直。这个性质是锯齿路径的数学根源。二维情形下,如果等高线是圆形,一步就到圆心;但如果等高线是拉长的椭圆,沿着正交方向来回走,路径就会呈现锯齿状。我当年第一次在图上看到这种锯齿迭代路径时,才真正理解为什么课程里反复强调条件数。
3. 步长选择的四种实用策略与代码实现
3.1 固定步长的使用场景和限制
最直观的步长策略是全程用一个固定常数。这个常数怎么定?一般做法是先估计梯度的Lipschitz常数,然后取。如果目标函数是二次函数,矩阵已知,直接取即可。如果问题来自实际应用,对没有先验信息,常见做法是从很小的值开始,比如0.001,跑一次迭代看函数值变化,如果函数值持续稳定下降但速度太慢,就逐渐放大步长。
固定步长的问题在于它没有自适应性。开始阶段梯度模很大,函数下降很快;随着接近最优点,梯度变小,固定步长可能导致在精确解附近来回震荡。比如在一维二次函数中,最优步长是,如果取的固定值稍大于这个边界,迭代就会在最优解两侧不断跳跃,函数值上下波动,永远不会真正停下来。
3.2 精确线搜索:理想但昂贵的方案
精确线搜索每一步找使函数值最小的步长,即
对于一般的非线性函数,这需要求解一个一维极小化子问题,通常本身就要迭代几次,代价比较高。但对于二次函数,线搜索有解析解。考虑,梯度,方向,则
这个步长又被叫做Cauchy步长,是最速下降法在二次函数上的精确最优步长。实际计算时,每步需要额外做一次矩阵向量乘来得到,比单纯计算梯度贵一些。课程里常拿这个步长做理论分析,因为用它可以直接推导出最速下降法的线性收敛率。
3.3 回溯线搜索的Armijo条件与Python实现
实际应用中更常用的是一类非精确线搜索策略,其中回溯线搜索是最典型的。它的思路是:先从一个比较大的初始步长开始,比如1,然后不断缩小步长,直到满足Armijo充分下降条件。写出来就是
其中是一个介于0到1的小常数,通常取。这个条件的意思是:新点的函数值至少要低于当前点一个与步长和梯度模成比例的数值,避免微小的下降被误认为有效下降。
代码实现很短,一个while循环就能搞定:
def backtracking_line_search(f, x, direction, grad, alpha0=1.0, rho=0.8, c=1e-4): alpha = alpha0 while f(x + alpha * direction) > f(x) + c * alpha * grad @ direction: alpha *= rho return alpha其中direction在梯度下降里就是负梯度方向,所以。回溯线搜索要求的只是每次多计算几次函数值,成本通常可控。这里c的取值很有讲究,取太小会接受过长的步长,导致迭代不稳定;取0.5这种偏大的值又可能把合理的长步长也拒掉,导致每一步都缩得很小。实践下来,我一直沿用的标准值,虽然看起来不起眼,但在绝大多数凸问题上表现稳定。
3.4 Barzilai-Borwein步长:用历史梯度信息估计步长
如果嫌线搜索每步开销大,还有一种很有意思的步长策略叫Barzilai-Borwein方法,课程后半段有时会作为拓展内容提一下。它的核心思想是用相邻两步的位移和梯度变化来估计当前曲率,从而套用拟牛顿思想的步长公式。定义
那么BB步长有两种常用形式
其中BB1更常用。它的直觉是:我们想知道“沿着当前方向,函数曲率大概是多少”,而刻画了这个信息。二次函数中,如果恰好沿特征向量方向移动,这个比值就精确等于对应特征值的倒数,步长取它就能一步到位。
BB方法在实际使用中经常搭配非单调线搜索策略,因为BB步长不一定保证每一步函数值都下降。我自己用下来觉得它在中小规模光滑问题上提速效果非常明显,但直接拿去做强非凸的深度学习优化并不合适,这点需要说明清楚。
4. 收敛性分析:条件数决定一切
4.1 锯齿效应与相邻梯度正交的直观理解
前面提到,精确线搜索下相邻两步的梯度方向正交。现在考虑目标函数是良态二次函数,其中。它的两个方向步长差异极大:方向的特征值是2,方向的特征值是200。从点出发,沿负梯度方向走精确步长后,新点的梯度与旧梯度垂直。由于在方向下降得快、在方向下降得慢,下一步的主方向又会偏向特征值较小的方向。这样一来,迭代轨迹在等高线图中呈现出明显的“之”字形折线:先沿着陡峭方向大步下降,下一次又几乎垂直于刚才的方向,横穿谷底,如此反复。
这种锯齿路径带来的后果非常实际。对条件数为100的二维二次函数,理论收敛因子是,意思是每步误差范数缩小约2%。要降到精度需要上千步迭代。而如果我们做一个坐标变换,把问题变成圆形等高线,梯度下降一步就能收敛。同一个问题,仅仅因为坐标缩放,迭代步数就差了三个数量级,这就是条件数对收敛速度的支配性影响。
4.2 强凸光滑情形下的线性收敛率
课程里最常讲的收敛性结论,是在函数梯度满足Lipschitz条件且函数本身满足强凸条件的假设下。设强凸参数为,梯度的Lipschitz常数为,取固定步长时,函数值的误差满足
也就是说,每迭代一次,函数值差距至少缩小倍。当这个因子接近1时,迭代速度会让人绝望。举例来说,一个条件数为100的问题,理论收敛速度是每步乘0.99。要做多少次迭代才能让初始误差下降8个数量级?大概需要步左右。这还只是条件数100,如果是条件数1000的问题,光是用梯度下降跑固定的步长,不配合任何预处理,基本可以说实际不可行。
这个结论也解释了为什么要做预处理、为什么牛顿法在收敛速度上有本质优势。牛顿法利用二阶信息,等价于把每一步的局部条件数变成接近1,所以能获得局部二次收敛。但代价是每步要求Hessian并解线性方程组。梯度下降胜在简单,但要真正落地,通常得搭配缩放、加速或者预条件策略。
4.3 收敛性对比速查表
下表整理了几种方向策略对光滑强凸问题的典型表现,方便按场景选择:
| 方法 | 步长策略 | 典型收敛行为 | 每步成本 | 适用场景 |
|---|---|---|---|---|
| 梯度下降 | 固定/回溯线搜索 | 线性,因子约 | 一个梯度 | 中小规模、凸问题 |
| 动量法 | 固定或线搜索 | 线性,比GD快 | 一个梯度加动量更新 | 锯齿严重的凸问题 |
| Nesterov加速 | 固定或线搜索 | 线性,因子约 | 一个梯度加外推点 | 光滑凸问题 |
| 共轭梯度法 | 不需要显式步长 | 二次问题上n步收敛 | 一次方向更新和线搜索 | 大型对称正定系统 |
| 牛顿法 | 单位步长加线搜索 | 局部二次收敛 | Hessian和线性求解 | 中小规模、光滑 |
这张表列的是理论行为,实际工程中还要考虑问题的维度和每次迭代的计算成本。数值分析课程强调的就是这种权衡思想:不仅要看收敛步数,还要看每步的单位成本。
5. 实操全过程:从零实现一个完整梯度下降
5.1 最小演示代码:跑一次二次函数最优问题
理解算法最好的方式是自己实现一遍,哪怕目标函数只是最简单的二次函数。下面这段代码我经常让学生跑:
import numpy as np def quadratic(x): A = np.array([[2.0, 0.0], [0.0, 200.0]]) return 0.5 * x @ A @ x def quadratic_grad(x): A = np.array([[2.0, 0.0], [0.0, 200.0]]) return A @ x def gradient_descent(x0, lr, grad_func, max_iter=5000, tol=1e-8): x = x0.copy() for k in range(max_iter): g = grad_func(x) if np.linalg.norm(g) < tol: return x, k x = x - lr * g return x, max_iter x0 = np.array([10.0, 0.1]) x_star, iteration_count = gradient_descent( x0, 2.0 / 202.0, quadratic_grad, max_iter=5000, tol=1e-8 ) print("最优解:", x_star) print("迭代次数:", iteration_count)这段代码中,矩阵取对角元分别为2和200的矩阵,最优解在原点。开始向量选,迭代五千次已经足够逼近原点。把步长改成比大一点,比如0.011,你会看到迭代次数暴涨,甚至函数值上下跳动。这种视觉上的对比比任何推导都直观。
5.2 数据预处理:先把等高线变成圆
实操中最重要的经验之一,是永远不要直接拿原始稀疏量纲去做梯度下降。考虑一个现实场景:如果目标函数里两个分量的尺度差100倍,不缩放坐标,条件数就是100。而如果做对角变换,比如把第二个坐标缩放10倍,新问题变成圆形等高线,梯度下降用固定步长一下子就能收敛。
在统计模型里,这个操作就是特征标准化。每列特征减去均值、除以标准差,本质上就是在改变目标函数的条件数。我遇到过一个实际案例:变量范围在0到1之间,变量范围在10000到20000之间,直接跑梯度下降时步长只能按变量的曲率来取,小到变量方向几乎不动。标准化之后,同样一套代码,一千步内轻松收敛。这个经验值得反复强调:先调数据尺度,再调算法参数。
5.3 停机准则和运行日志的设计
停机准则不能只看函数值变化。函数值变化小可能是因为目标函数在当前点已经非常平坦,梯度很小,也可能只是步长太小导致点几乎没移动。更稳妥的准则是检查梯度范数,因为梯度范数直接度量“当前点离一阶最优性条件的距离”。实际代码里通常同时设置几个条件:梯度范数小于绝对公差、大于最大迭代次数、以及函数值连续多次相对变化小于阈值。
运行日志也很有讲究。至少每五十步打一次迭代次数、梯度范数、函数值,这样能从输出曲线判断是正常缓慢收敛还是震荡发散。如果打印出来的函数值前几步大幅下降,后面长时间纹丝不动,大概率是步长设置得太保守;如果函数值上下剧烈波动,则步长过大。
6. 常见问题与排查技巧实录
6.1 震荡发散类问题
最经典的现象是:函数值先是下降,然后开始来回跳动,甚至越来越大。原因通常是步长过大,超过了收敛允许的上界。排查方法很简单,把步长缩小十倍再跑一次,如果函数值变成单调下降,就说明原来的步长越界了。还有一类情况是目标函数本身非凸,比如典型的多峰函数,从一个不好的起点出发,哪怕步长很小,也可能跨过谷底跳到另一边。这时候换初始化点,或者改用更保守的线搜索策略,会有帮助。
6.2 收敛缓慢类问题
如果函数值确实在下降,但下降幅度小到每千步才变化一个数量级,基本可以判定是条件数问题。我把这类问题称为“细长山谷问题”。课程里常用的对策有三个:一是坐标缩放或特征标准化,把椭圆变圆;二是改用动量法或Nesterov加速;三是直接换共轭梯度法。需要注意的是,动量法虽然能平滑锯齿轨迹,但并非所有问题都适用,动量参数设置不合理时,会在越过谷底之后继续沿原方向冲出去,反而需要更多步才能回到最优解附近。
6.3 数值稳定性与工程细节
代码实现里还有几个容易踩的坑。第一,计算梯度时不要用数值微分,尤其是精度不高时,差分引入的误差会直接污染迭代方向;尽可能推导解析梯度。第二,函数值或梯度溢出时,优先检查数据是否标准化,其次检查步长是否过大。第三,停机判断不要用“函数值变化小于”,要结合梯度范数一起判断,否则可能提前停机在一个非常不精确的解上。第四,如果目标函数值域跨度极大,输出日志时尽量用科学计数法,否则一两轮迭代就把小数位占满了,看不出真实变化趋势。
下面整理一张速查表,直接对照排查:
| 现象 | 可能原因 | 处理方案 |
|---|---|---|
| 函数值上下震荡 | 步长过大 | 缩小步长,或改用回溯线搜索 |
| 收敛速度极慢 | 条件数大 | 特征缩放、预处理、换加速方法 |
| 提前停止,但结果不理想 | 停机准则只看函数值变化 | 改用梯度范数停机 |
| 出现NaN或Inf | 步长过大导致溢出 | 减小步长,检查数据尺度 |
| 迭代路径锯齿严重 | 方向正交反复横跳 | 使用动量法或共轭梯度法 |
| 非凸问题陷入局部极小 | 初始化不合理 | 多次随机初始化,或加入噪声 |
7. 现代变体与加速方法
7.1 动量法与Nesterov加速
动量法是梯度下降最简单也最有用的改进。它引入一个速度变量,每步把历史梯度的方向和当前梯度方向混合:
其中是动量系数,通常取0.9左右。这个过程可以类比成一个有质量的球滚下山坡,它不会在每个点都完全转向,而是保有一定的惯性,因此能有效减少锯齿。Nesterov加速更进一步,先沿历史速度方向外推一步,再在新位置计算梯度:
这种“先看一步再迈步”的策略,在光滑凸问题上有理论上的最优收敛率,函数值误差的收敛因子从降到约。课程里讲到这个公式时会强调:它不是简单的“多加一项”,而是改变了梯度计算的位置,理解这点很关键。
7.2 共轭梯度法:方向之间相互共轭
很多人学数值分析时,会把共轭梯度法归到线性方程组迭代求解那一章,确实如此,因为求解对称正定线性方程组等价于最小化二次函数。但从优化角度看,共轭梯度法同时也是对梯度下降的一种重要改良。它构造一组关于矩阵共轭的方向,使得沿每个方向的最优步长不会破坏之前方向已经完成的极小化,于是在精确算术下,维二次问题最多步就能收敛。
实际计算中,共轭梯度法每步只需要一个矩阵向量积,并且不需要存储完整的搜索方向历史,因此特别适合大型稀疏问题。如果在一个大规模正则化线性回归问题上比较梯度下降和共轭梯度法,体验会非常强烈:前者几百步还在缓慢爬行,后者几十步就已经收敛到机器精度附近。
7.3 自适应学习率家族
深度学习里常用的自适应学习率方法,比如AdaGrad、RMSProp、Adam,本质上是把步长策略从“全局一个常数”改成了“每个坐标一个自适应步长”。Adam特别典型的更新方式是维护梯度的一阶矩估计和二阶矩估计,然后按
更新变量。其中默认取0.9、0.999、极小常数1e-8,方根逐元素计算。Adam在很多问题上能找到不错的解,但它其实偏离了原始梯度下降的单调下降性质,不能保证每一步函数值都下降。如果在数值分析课程的项目里要求严格证明收敛性、或者需要精确的极小值点,我通常建议还是用带线搜索的梯度下降或者共轭梯度法,Adam更适合快速找到可用的模型参数,而不是精确的数值最优解。
个人使用体会和一点补充技巧
这门课的内容看似抽象,但梯度下降几乎是所有数值计算工作中最实用的算法之一。我自己在实际操作中最深的一个感受是:遇到任何疑似“梯度下降不收敛”的问题,先别急着调步长,先看看目标函数的尺度是否均匀。很多表面上非常难调的问题,在做好特征标准化或者对角缩放之后,固定步长的普通梯度下降就已经足够好用了。还有一个小技巧,调试代码时先在一个二维二次函数上做单元测试,画出等高线图和迭代轨迹。如果算法实现正确,迭代轨迹应该呈现出干净的锯齿或者平滑的指向,这个视觉检查比盯着一堆打印出来的函数值更直观。等二维情况验证通过后,再推广到高维实际问题上,会少走很多弯路。