做机器学习模型调参、做工程参数整定、或者单纯想把某个黑盒函数的最小值找出来,你迟早会遇到同一类问题:目标函数是非线性的,而且没有任何约束条件。这类“无约束非线性函数”的最优化问题,说实话是数值优化里最基础、也最容易被忽视的一块。但它恰恰是很多高级算法的地基——你训练神经网络用的梯度下降,本质上就是在解一个无约束非线性优化问题;你做参数拟合,本质也是在解一个无约束非线性最小二乘问题。
这篇内容我打算按做项目的思路来拆:先讲清楚无约束非线性优化到底在求什么,再逐个过一遍最核心的几类迭代算法(最速下降、牛顿法、拟牛顿法、共轭梯度法),然后把步长选择和线搜索的细节摊开讲透,最后用 Python 手写一段可复现的实验代码,把收敛行为对比给出来。适合刚接触数值优化的人照着走一遍,也适合写过梯度下降但总感觉“差点意思”的人回头补补底层逻辑。
1. 无约束非线性优化在解决什么问题
1.1 数学形式与几何直觉
无约束非线性优化问题的标准写法是:
min f(x),x ∈ Rⁿ
这里的 f 是定义在 n 维实空间上的实值函数,通常要求至少一阶连续可微。如果二阶光滑,那就有机会用更快的算法。所谓“无约束”,就是 x 没有任何上下界、没有等式约束、没有不等式约束,可以在整个空间自由取值。
用几何的话来说,f(x) 画出来是一个 n+1 维空间里的曲面,你要做的事情就是站在这个曲面上的某个位置,通过不断移动,最终走到最低的那个“洼地”。这里的“最低”分两种:一种是全局最小,即在整个空间里 f(x) 是最小的;另一种是局部最小,即在某个邻域内 f(x) 比其他点都小。无约束优化的几乎所有算法都只能保证找到局部最小点,能拿到全局最小的场景非常有限,除非 f 是凸函数。
判断一个点是不是局部最小,靠的是微积分给我们的最优性条件。这个条件分梯队:
一阶必要条件:如果 x* 是局部极小点且 f 在 x* 处可微,那么梯度 ∇f(x*) = 0。梯度为零的点叫驻点。直观理解就是:站在山谷底部,往任何方向走,地势都不再明显下降,坡度为零。注意这是必要条件,不是充分条件——山脊上的鞍点、山顶上的局部极大点,梯度同样为零。
二阶必要条件:如果 f 是二次连续可微的,那么在局部极小点处 Hessian 矩阵(二阶偏导数组成的矩阵)必须半正定。二阶充分条件:梯度为零且 Hessian 正定,那么这个点一定是严格局部极小点。正定这个要求相当于要求“往任何方向走,曲率都是向上的”。
这三条看起来简单,但实际做优化时非常有用。算法迭代到某个点之后,怎么判断“我差不多到了”?最常用的就是看梯度范数是否足够小。这也是后面所有收敛性分析的核心。
1.2 极值点的判断标准与收敛性度量
判断一个算法好坏,通常看三点:收敛性(会不会发散)、收敛速度(几步能到)、每步的代价(算一次需要多少计算量)。后两点往往此消彼长,工程上的大多数选择都是在这两者之间找平衡。
收敛速度的严格定义分几档。线性收敛指误差序列 eₖ 满足 ||eₖ₊₁|| ≤ c ||eₖ||,其中 c ∈ (0,1)。c 越小越快。超线性收敛指 lim ||eₖ₊₁|| / ||eₖ|| = 0,也就是后一步的误差相对前一步的误差是无穷小。二阶收敛则指 ||eₖ₊₁|| ≤ C ||eₖ||²,意思是每步误差大约平方衰减,一旦进入二阶收敛区间,几步就到位。
这里的误差一般指 xₖ 到最优解 x* 的距离,但没有 x* 的时候我们用什么衡量?实践中常用梯度范数 ||∇f(xₖ)|| 作为停机指标,因为梯度为零是最优性条件的基础。我个人在调试代码时喜欢同时记录“函数值下降量”和“迭代点移动距离”两个指标,后面会讲为什么单一指标容易误判。
2. 四个核心迭代算法的选型逻辑
所有无约束优化迭代法都在反复执行同一个套路:在当前点 xₖ,选一个搜索方向 pₖ,再决定沿这个方向走多远(步长 αₖ),得到下一个点 xₖ₊₁ = xₖ + αₖ pₖ。不同算法的本质区别就在 pₖ 怎么选,以及用多大代价去选。
2.1 最速下降法:起步法宝,也有明显短板
最速下降法选择负梯度方向 pₖ = -∇f(xₖ)。理由很朴素:在某个点的所有方向里,函数值下降最快的方向就是负梯度方向。用微分近似展开就能说明:f(xₖ + αp) 关于 α 的导数是 ∇f(xₖ)ᵀ p,要让导数最小(下降最快),p 应该取负梯度方向。
这个方法实现极其简单,每步只需要算一次梯度,内存占用为零。但也正因为只用了一阶信息,它有个致命短板:当目标函数的等值面是扁长的椭圆形状时,迭代路径会呈之字形震荡,收敛可能非常慢。数学上有个经典结论:如果目标函数是二次函数且 Hessian 的特征值在 [m, M] 之间,那么最速下降法配合精确线搜索时,收敛速度是线性的,且收敛因子约为 (M-m)/(M+m) = (κ-1)/(κ+1),其中 κ = M/m 是条件数。条件数越大,这个因子越接近 1,意味着每一步只能削减极小比例误差。
举个例子直观感受一下:κ = 10 时,收敛因子约 0.82,每一步误差乘 0.82,想要让误差降到初始的千分之一,大约需要 35 步。κ = 100 时因子约 0.98,达到同样精度需要约 340 步。这就是为什么在机器学习里,数据预处理要做标准化操作——本质就是把特征空间的“条件数”降下来,让梯度下降不至于龟速爬行。
所以最速下降法适合用在起步阶段、规模大但只需要粗略解的场合,或者用来给更快的算法提供初始点。真要追求高效收敛,必须换方向。
2.2 牛顿法:二次收敛的代价与风险
牛顿法的出发点是利用二阶信息。把 f 在 xₖ 附近做二次泰勒展开:
f(xₖ + p) ≈ f(xₖ) + ∇f(xₖ)ᵀ p + (1/2) pᵀ ∇²f(xₖ) p
对 p 求导并令其为零,就得到搜索方向满足的线性方程:
∇²f(xₖ) pₖ = -∇f(xₖ)
也就是说,每一步需要解一个 n 阶线性方程组。如果 Hessian 正定,这个搜索方向必然是下降方向。牛顿法最大的优点是一旦接近最优解,收敛速度是二阶的,误差按平方级别衰减,通常几步就能从“差不多的区域”冲入“精度极高的邻域”。
但代价也很明确。第一,你需要计算完整的 Hessian 矩阵,对许多实际工程问题,二阶导要么根本给不出解析式,要么计算代价大得吓人。第二,每步要解一个线性方程组,当 n 很大时,这一步的 O(n³) 计算量是压倒性的。第三,Hessian 可能不正定,那么 pₖ 就不一定是下降方向,直接迭代可能发散。
所以纯牛顿法常用于中小规模、目标函数二阶信息容易获得的场景。当你面对的优化问题变量只有几十个、目标函数是二次连续可微的解析表达式时,牛顿法是非常值得优先试的。
2.3 拟牛顿法:BFGS 是工程上的均衡选择
既然牛顿法好用但 Hessian 不好算,自然就想找一个替代品:能不能只靠梯度信息,慢慢“拼凑”出一个近似 Hessian?这正是拟牛顿法的思路。它维护一个矩阵 Bₖ 来近似真实 Hessian(或者 Hₖ 来近似其逆),每步更新一次,更新时只用相邻两个迭代点的梯度差。
最经典的是 BFGS 更新公式。设 sₖ = xₖ₊₁ - xₖ,yₖ = ∇f(xₖ₊₁) - ∇f(xₖ),那么对近似逆 Hessian 矩阵 Hₖ 的更新为:
Hₖ₊₁ = (I - ρₖ sₖ yₖᵀ) Hₖ (I - ρₖ yₖ sₖᵀ) + ρₖ sₖ sₖᵀ,其中 ρₖ = 1 / (yₖᵀ sₖ)
这个公式的推导可以从“割线方程”出发:我们希望下一个近似矩阵满足 yₖ = Hₖ₊₁ sₖ(即近似 Hessian 在 sₖ 方向上的行为与真实梯度差一致),并且在某种范数意义上最小地偏离当前矩阵。BFGS 是工程上验证最充分、且对不顺滑问题也相当稳的拟牛顿方法,收敛速度是超线性的,每步计算量只要 O(n²)。
另一个变体是 DFP,它与 BFGS 的差别在于更新的是 Hessian 而不是其逆,实际使用中 BFGS 通常更稳定。L-BFGS 则是 BFGS 的低内存版本,不显式存储 Hₖ,只保留最近的 m 组 (s, y) 对,用它们隐式地重建搜索方向。对 n 非常大的问题(比如数万维参数的模型训练),L-BFGS 几乎是拟牛顿法里唯一现实的选择。
我自己的经验是:如果问题规模在一千维以内且不知道 Hessian 能否算,直接上 BFGS,通常能在二三十步内得到一个相当好的解。用不着纠结什么高级花活。
2.4 共轭梯度法:大规模问题的存储友好方案
共轭梯度法最开始的动机是求解大型稀疏对称正定线性方程组,后来被引入无约束优化。它的独特之处在于:方向 pₖ 不再是梯度的简单比例,而是从当前负梯度方向出发,和之前所有搜索方向构造为“共轭”的关系。在二次函数上,共轭方向有一个漂亮的性质:每一步在一个方向上的极小化不会破坏之前方向已经做好的极小化,因此理论上最多 n 步就能精确收敛。
FR(Fletcher-Reeves)是经典公式,PRP(Polak-Ribière)变体在非精确线搜索下表现更好。实现时只需要存几个向量,内存开销 O(n),每步计算量 O(n),适合大规模问题。缺点是它对线搜索质量比较敏感,如果步长算不准,理论上的收敛保证会打折扣。
我在选型时习惯这样判断:一步的梯度计算不贵但 n 很大(超过十万)时,用 L-BFGS 或共轭梯度;n 中等但二阶信息珍贵时,用 BFGS;n 很小且能写 Hessian 解析式时,直接用牛顿法。
3. 线搜索策略与步长选择的实用细节
搜索方向确定之后,下一步要解决“走多远”的问题。步长 α 选得好不好,直接决定迭代稳定性。这一步在几乎所有教材里都被单独拿出来讲,但工程上很多实现(包括一些开源库的默认行为)并没有多少人真正去理解背后的机制。
3.1 精确搜索为什么不划算
理论上最完美的做法是沿当前方向做精确一维极小化:
αₖ = argmin f(xₖ + α pₖ),α > 0
也就是说,在这个方向上一口气走到谷底。问题在于:对绝大多数非线性函数,这个一维极小化本身没有解析解,需要反复调用函数值和导数值,每次消耗可能比主迭代还大。更糟的是,精确线搜索可能会把迭代方向带“歪”——尤其在最速下降法里,精确线搜索反而会加剧之字形路径。所以现代实现几乎不会做精确线搜索,而是用一种“足够好就行”的近似判定:保证函数值充分下降、步长不要太短也不要太长。
这对应的是 Wolfe 条件。先看 Armijo 条件:
f(xₖ + α pₖ) ≤ f(xₖ) + c₁ α ∇f(xₖ)ᵀ pₖ,c₁ ∈ (0,1)
左边是走完 α 步之后的新函数值,右边是“斜率足够下降”的参考线。这个条件保证了步长不会大到把函数值抬上去。但光有它还不够,因为很小的 α 总能满足不等式(在光滑函数下),步长会退化成蜗牛爬。所以要再加曲率条件:
∇f(xₖ + α pₖ)ᵀ pₖ ≥ c₂ ∇f(xₖ)ᵀ pₖ,c₂ ∈ (c₁, 1)
意思是说走完 α 步之后,目标函数沿着搜索方向的斜率不能比起点更负太狠,从而排除了过小的步长。
3.2 Armijo回溯与 Wolfe 条件的实现细节
工程上最简单可靠的是回溯线搜索配合 Armijo 条件。做法如下:先给 α 设初始值 1(对牛顿类方法通常很合适),检查 Armijo 条件是否成立;不成立就把 α 乘以一个收缩因子 ρ ∈ (0.5, 0.8),再检查;直到条件成立或 α 小于某个极小值为止。
写出来就是:
def backtracing_line_search(f, g, x, p, c1=1e-4, rho=0.5, alpha0=1.0, max_iter=50): alpha = alpha0 f_x = f(x) gxp = g(x) @ p # 下降方向保证了这个值<0 for _ in range(max_iter): f_new = f(x + alpha * p) if f_new <= f_x + c1 * alpha * gxp: return alpha alpha *= rho return alphac₁ 通常取 1e-4,这个值几乎是所有教材和开源实现的通用默认。为什么不是 0.1 或 0.5?因为 Armijo 条件只是一个“必要不充分”的验收门槛,c₁ 太小容易放过过大的步长,c₁ 太大则会拒绝很多原本可以继续走的步长。1e-4 这个数值是几十年的工程经验沉淀,你不需要再发明一个。
回溯法的好处是不需要额外调线搜索内部机制,每轮只多算几次函数值就行。缺点是无法区分“太小”的步长——只要 Armijo 成立它就返回,哪怕 α = 1e-12。所以还有一个常见的补充检查:如果 α 已经收缩到小于某个阈值(比如 1e-10),说明当前方向可能就是数值噪声,直接终止或换方向。
真正实现 Wolfe 条件需要同时找满足两个不等式的 α,常见办法是先做回溯,等 Armijo 成立后再检查曲率条件,不满足就启动二分或插值。很多商业级非线性规划器用的是更复杂的强 Wolfe 条件和 More-Thuente 线搜索,但对我们自主实现来说,回溯就够了。
3.3 步长参数的经验取值
不同算法对线搜索的“胃口”不一样,我踩过不少坑之后总结出这样几条:
牛顿类方向自带尺度,初始 α = 1 通常就能让序列直接进入收敛区间,所以回溯时初始值设为 1 最合理。拟牛顿法也建议从 1 开始,因为由 BFGS 构造的 Hₖ 已经在模仿 Hessian 的逆,方向上的“长度”信息也有一部分被编码进去了。最速下降法和共轭梯度法则没有这个性质,初始 α 可以先取一个较大的保守值,比如 1,然后让回溯慢慢收缩;但如果你发现每次回溯都要缩好几轮才接受,说明目标函数尺度差异大,先把变量标准化处理一下比硬调参数更有效。
rho 的取值我习惯放在 0.5 到 0.8 之间。rho 越小收缩越激进,函数评估次数少但可能浪费好的大步长;rho 越接近 1 收缩越细腻,但容易多算很多次函数值,得不偿失。
还有一个细节很多人容易忽略:下降方向的判定。数值上算出来的 p 可能因为舍入误差,与梯度内积只是“略小于零”,结果走了一步反而函数值上升。所以每次生成方向后,我习惯先检查 dot(g, p) 是否严格为负;如果不是,就回退到最速下降方向,这个保护逻辑成本几乎为零,但对病态目标函数能救很多次。
4. Python 实操:手写梯度下降与数值实验对比
讲完理论,我用一个具体实验把各算法跑一遍。测试函数选经典的香蕉函数(也叫 Rosenbrock 函数):
f(x, y) = 100(y - x²)² + (1 - x)²
这个函数全局最小值在 (1,1) 处,函数值为 0,但它沿着一条弯曲的窄谷延伸,条件数很大,非常适合暴露“算法差异”。它的梯度、Hessian 都有解析式,方便测试。
4.1 测试函数与梯度检查
先写出目标函数和梯度:
import numpy as np def banana_fun(x): return 100 * (x[1] - x[0]**2)**2 + (1 - x[0])**2 def banana_grad(x): g = np.empty(2) g[0] = -400 * x[0] * (x[1] - x[0]**2) - 2 * (1 - x[0]) g[1] = 200 * (x[1] - x[0]**2) return g写任何数值优化代码,我第一个动作永远是梯度检查。用有限差分近似梯度:
(f(x + h eᵢ) - f(x - h eᵢ)) / (2h) 与解析梯度对比。如果两者相对误差超过 1e-5,那别急着优化目标函数,先把梯度公式或代码批查一遍。这一步能省掉后面排查问题的大量时间。
4.2 梯度下降与 BFGS 核心实现
我实现一个标准梯度下降加回溯线搜索:
def gradient_descent(f, g, x0, max_iter=10000, tol=1e-6): x = x0.copy() fx = f(x) history_grad = [] for _ in range(max_iter): grad = g(x) grad_norm = np.linalg.norm(grad) history_grad.append(grad_norm) if grad_norm < tol: break p = -grad alpha = backtracing_line_search(f, g, x, p) x = x + alpha * p fx = f(x) return x, fx, history_grad简易 BFGS 的核心迭代也可以手写,关键在于 Hₖ 的更新:
def bfgs(f, g, x0, max_iter=500, tol=1e-9): n = len(x0) H = np.eye(n) x = x0.copy() grad = g(x) hist = [] for _ in range(max_iter): grad_norm = np.linalg.norm(grad) hist.append(grad_norm) if grad_norm < tol: break p = -H @ grad alpha = backtracing_line_search(f, g, x, p) x_new = x + alpha * p grad_new = g(x_new) s = x_new - x y = grad_new - grad if s @ y > 1e-12: rho = 1.0 / (s @ y) I = np.eye(n) H = (I - rho * np.outer(s, y)) @ H @ (I - rho * np.outer(y, s)) + rho * np.outer(s, s) x = x_new grad = grad_new return x, f(x), hist注意更新前要检查 sᵀy 的正负。sᵀy > 0 是拟牛顿法保持正定的关键,它相当于二阶充分条件在离散层面的体现。如果不大于 0,说明当前步的曲率信息是异常的,保留旧矩阵更安全。
4.3 实验对比与收敛性观察
初始点取常见的 (-1.2, 1.0),这个点距离最优解 (1,1) 较远,且处于香蕉函数的艰难区域。我用两个算法跑完并记录梯度范数的变化:
- 最速下降法:跑完 10000 步梯度范数大约降到 1e-3 的量级,函数值徘徊在 1e-5 附近,离真正的极小点还很远。迭代路径呈现典型的之字形,每一步都在窄谷两侧来回大幅横跳。原因是该区域 Hessian 的条件数达到数百量级,收敛因子接近 1,每一步的下降被急剧稀释。
- BFGS:跑 50 步左右梯度范数就降到 1e-10 以下,函数值稳定在 1e-20 量级。即使是没有显式 Hessian 的二阶近似,超线性收敛的特征非常明显:前面十步“找方向”,后面几轮梯度范数以几乎平方的速度掉下去。
这个对比不是想说明“梯度下降是垃圾”——在大规模机器学习里,每次迭代只要 O(n) 的代价、不做矩阵运算,恰恰是它的不可替代性。它想说明的是:在能负担每步 O(n²) 计算量的中小规模问题上,多花一点代码复杂度换二阶或超二阶收敛,收益是数量级的。
5. 常见问题与避坑实录
5.1 Hessian 不正定怎么处理
牛顿法最怕遇到 Hessian 不正定的点。处理办法有几种:最简单的是“阻尼牛顿法”,给 Hessian 加上一个正定对角阵:∇²f(xₖ) + λI,λ 是正数。这等价于在原始二阶模型里增加一个信赖域惩罚项,也不难理解成强制对方向做正则化。λ 大到一定程度,方向就退化成了最速下降步。实际中可以在每次迭代前计算 Hessian 的最小特征值或尝试 Cholesky 分解,如果 Cholesky 失败就加大 λ。工业级实现里,利文贝格-马夸特(Levenberg-Marquardt)方法就是这个思路,专为最小二乘问题而设计,本质就是梯度下降与牛顿法的自适应混合。
5.2 收敛到鞍点与局部极小怎么判断
梯度为零的点可能是鞍点。对高维非凸问题,鞍点从概率上讲远比局部极小点多,因为鞍点只需要一个特征值为负,极小点需要所有特征值为正。如果在迭代终点发现梯度范数已经小于容差,但函数值似乎不稳定,可以检查 Hessian 的特征值分布:只要出现显著负特征值,基本可以断定是鞍点。
避免停在鞍点或不良极小点的常用手段是多重起点策略:从多个随机初始点出发,各自独立迭代,比较结果。如果函数评估不贵,这个策略非常有效。此外可以在梯度范数很小时加一点随机扰动,跳出当前盆地重新迭代,类似模拟退火的直觉。
5.3 停止准则与容忍度选择
只盯梯度范数容易犯“早停”的错。某些函数在接近最优点时梯度范数下降很快,但迭代点本身还在缓慢漂移;反过来,目标函数本身很平缓的区域,梯度可能已经很小,但距离最优解还远得很。我常用的停机条件是把三个指标一起组合:
| 指标 | 常见容差 | 说明 |
|---|---|---|
| 梯度无穷范数 | 1e-6 ~ 1e-8 | 最优性条件的一阶近似 |
| 相邻迭代点相对变化 | 1e-8 | 防止点还在漂移时误停 |
| 函数值相对下降量 | 1e-10 | 防止函数值停滞但方向仍在大幅变动 |
同时要提防过小的容差。机器精度只有大约 2.2e-16,当你要求梯度的无穷范数小于 1e-12 时,实际可能已经在数值噪声区域了。如果此时用的是解析梯度还好,数值微分梯度基本不可能收敛到这个精度。所以容差设置要匹配你的梯度计算精度。
5.4 内存与计算量的现实权衡
我再补充一张算法选型速查表,覆盖常见工程场景:
| 算法 | 每步计算量 | 内存 | 收敛速度 | 适用场景 |
|---|---|---|---|---|
| 最速下降法 | O(n) | O(1) | 线性(条件数敏感) | 起步、粗解、大规模且精度要求低 |
| 牛顿法 | O(n³) | O(n²) | 二阶 | 中小规模、Hessian 易得 |
| BFGS | O(n²) | O(n²) | 超线性 | 中等规模、只给梯度 |
| L-BFGS | O(n) | O(mn) | 超线性(略弱于 BFGS) | 大规模、只给梯度 |
| 共轭梯度 | O(n) | O(n) | 线性至超线性 | 大规模、需要精确线搜索配合 |
这里的 O(n³) 是解线性方程组的典型代价,O(n²) 是矩阵向量乘法和 BFGS 更新矩阵的代价。n 在几百量级时 O(n³) 可能毫秒级完成,n 到一万时 O(n³) 基本就别想了。
另外一个工程细节:无论用什么算法,都应该先对变量做归一化或按量纲调整尺度。比例失调的变量会让 Hessian 条件数极大,前面说过,这会直接拖垮一阶方法。很多“算法不收敛”的问题,把变量缩放到大致同一量级就自然解决了。
最后分享一下我这些年做优化实践的个人体会。第一,别把算法选型当成“选最贵的”,一块钱的函数评估和一块钱的 Hessian 计算,背后是迥然不同的时间成本分配,先搞清楚你每步计算到底花在哪儿。第二,没有万能算法,但 L-BFGS 在绝大多数中小规模工程问题里都是最省心的第一选择——它不需要二阶信息,内存可控,收敛速度也足够好。第三,调试优化代码时,最先要排查的不是步长不是方向,而是梯度的正确性;梯度算错,后面的所有花里胡哨都是空中楼阁。
如果这篇内容能让你在解决下一个实际优化问题时少踩一两个坑,那花在这上面的时间就值了。后面如果不懒,我打算接着写带约束的优化问题怎么处理,到时候再把拉格朗日函数、罚函数法这些内容摊开来讲。