news 2026/9/12 2:47:02

BFGS与Armijo线搜索的MATLAB实现:从数学原理到代码实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
BFGS与Armijo线搜索的MATLAB实现:从数学原理到代码实战

我先说下这个项目给我的感觉吧。做优化算法的人,手上一般都会备着几套经典无约束优化方法的代码,梯度下降、牛顿法这些当然要有,但真正在工程里遇到非凸目标、二阶信息算不出来或者算出来不太靠谱的时候,BFGS几乎是默认的备选方案。而BFGS在实际程序里跑得好不好,Armijo线搜索起着决定性作用——这两样东西放一起研究,其实就是解决“方向怎么选”和“走多远”这两个最核心的问题。

我这次要把整个实现从数学原理到MATLAB代码,再到数值实验完整梳理一遍,把我踩过的坑也直接甩出来,省得你再去搜半天。

1. 整体思路拆解:为什么组合选型偏偏是BFGS加Armijo

1.1 无约束优化到底在解什么问题

先交代清楚我们面对的问题形态。无约束优化问题的标准写法是

min f(x), x ∈ R^n

没有等式约束、没有不等式约束,只有目标函数。这种问题听着好像比带约束的简单,但实际工程里真正常见的是目标函数非凸、变量维度几十到几百、梯度解析式虽然能写出来但二阶导非常难算。比如深度学习里的损失函数、系统辨识里的残差平方和、波形匹配的代价函数,基本都是这个结构。

对于这类问题,求解策略大致可以分成三类。

第一类是一阶方法,最典型的是最速下降法,每次沿负梯度方向走。优点是每步开销小、实现简单,缺点是收敛速度线性,在目标函数等值线呈椭球状时会出现明显的“锯齿效应”,收敛几乎可以用“爬行”来形容。

第二类是二阶方法,即牛顿法。它用梯度和Hessian矩阵共同确定搜索方向,收敛速度可以达到二阶,在非退化极值点附近通常只需要少数几步就能高精度收敛。但它需要解析或数值计算Hessian,而且Hessian必须正定,否则牛顿方向根本不一定是下降方向。这个要求在工程问题里非常苛刻。

第三类是介于两者之间的拟牛顿法。它用一个近似矩阵替代Hessian(或其逆),这个近似矩阵通过迭代过程中梯度差和步长差的信息不断更新,既避开了解析求二阶导的麻烦,又能保持超线性收敛速度。BFGS就是拟牛顿法里最经典、数值表现最稳健的一种。

1.2 Armijo线搜索在整体方案里的位置

有方向还不够,你需要确定步长。BFGS给出的只是一个搜索方向 d_k,真正更新公式是

x_{k+1} = x_k + α_k · d_k

这里α_k就是步长。如果步长取大了,可能越过极小点甚至导致函数值上升;取小了,收敛会慢到怀疑人生。Armijo线搜索就是用来确定这个α_k的一组判据中的关键一条,它要求

f(x_k + α_k·d_k) ≤ f(x_k) + c1·α_k·∇f(x_k)^T·d_k

其中c1通常取1e-4。左边是实际下降量,右边是根据当前梯度线性预测出的下降量,这条不等式能保证每一步的下降量与该步长的线性近似下降量成比例,排除掉那些下降太少、白走的步长。

我在实际项目里几乎不会只用Armijo本身,而是会把Armijo条件跟Wolfe条件的另一部分——曲率条件放在一起用。这里先说一个结论:仅靠Armijo条件并不能保证步长远离0,因为当步长足够小时,Armijo条件总能被满足。所以在实现BFGS时,通常会在满足Armijo条件的基础上,再结合回溯法或者二次插值来选择一个相对合理的“大步长”,而不是贪心地取最大的可行步长。

1.3 这套组合的适应场景

BFGS + Armijo这套方案到底适合什么样的题目,我说几个典型场景。

第一个是目标函数的Hessian很难显式给出。比如实际问题中目标函数是个复杂仿真程序的输出,解析梯度很难推,虽然BFGS仍然需要梯度,但不需要Hessian,梯度本身往往也可以用有限差分近似解决。

第二个是变量维度中等规模。BFGS存储的是近似Hessian逆矩阵的n×n稠密阵,内存开销是O(n²),n在几千以下都很合适,但到了上万维就建议换成L-BFGS。

第三个是目标函数的梯度和函数值计算成本不高,但精度要求不低。BFGS的超线性收敛往往能在几十步内解决梯度下降几千步也不一定解决的问题。

2. BFGS算法原理拆解与关键数学推导

2.1 从牛顿法到拟牛顿法的演进逻辑

要理解BFGS,得先明确牛顿法到底做了什么。牛顿方向为

d_k = -[H_k]^{-1}·∇f(x_k)

其中H_k是Hessian矩阵。牛顿法的核心思想是用局部二次模型来近似目标函数,然后直接跳到这个二次模型的极小点。问题在于,如果目标函数本身不是二次的,Hessian也不是不变的,而且当Hessian非正定时,牛顿方向都不一定是下降方向。

拟牛顿法的思路是构造一个正定矩阵B_k来近似真实Hessian,或者构造G_k来近似Hessian的逆。每次迭代利用当前梯度变化和步长变化信息修正这个近似矩阵,让它逐步逼近真实的二阶信息。

s_k = x_{k+1} - x_k y_k = ∇f(x_{k+1}) - ∇f(x_k)

任何合理的Hessian近似B_{k+1}都应该满足割线条件

B_{k+1}·s_k = y_k

这个条件等价于说,在对目标函数的二次近似下,相邻两点的梯度差等于Hessian乘以步长差,这也是拟牛顿更新公式设计的基础约束。

2.2 BFGS更新公式的来历

BFGS实际上是四个人的名字,Broyden、Fletcher、Goldfarb、Shanno各自独立提出了同一类更新公式。它构造的是Hessian逆矩阵的近似序列G_k,而G_{k+1}可以写成

G_{k+1} = (I - ρ_k·s_k·y_k^T)·G_k·(I - ρ_k·y_k·s_k^T) + ρ_k·s_k·s_k^T

其中 ρ_k = 1 / (y_k^T·s_k)。

这个公式看起来复杂,但本质上就是在满足割线条件下,对G_k做最小修正,同时保证G_{k+1}保持正定,只要α_k满足Wolfe条件。也正是因为BFGS更新天然保持正定性,在算法实现里你几乎不需要额外判断矩阵是否正定,这比DFP和纯牛顿法省心很多。

工程实现时,我们往往不需要显式构造G_k和求矩阵乘法,只需要做矩阵-向量运算。搜索方向d_k = -G_k·∇f_k可以借助两个递归关系高效计算。初值G_0通常设为单位阵I,在后续迭代中BFGS会自适应修正这个近似。

2.3 为什么BFGS比DFP更稳

DFP和BFGS几乎同时期诞生,但长期实践表明BFGS在求解一般非线性问题时更鲁棒。原因在于BFGS更新公式对“凸组合”的性质更好,在大步长或目标函数高度非线性的情况下,BFGS更新的正定性维持能力更强。

我记得在某个非线性最小二乘问题上,用DFP配合精确线搜索时,到某几步因为曲率信息不准确导致近似Hessian更新出了问题,迭代方向一度不是下降方向。换成BFGS之后同样条件一切正常,收敛过程稳定很多。这也是为什么不少优化工具箱把BFGS作为默认选项,而不是DFP。

3. Armijo线搜索的实现细节与参数选择

3.1 线搜索的基本框架

在BFGS迭代的每一轮,搜索方向算出来后,马上去找合适的步长α。线搜索方法大概分两类:一类是精确线搜索,也就是在方向上求一维极小化问题,代价高,一般只在理论研究里用;另一类是非精确线搜索,用一系列充分条件来判断步长是否合格,实际软件里几乎全是这类。

非精确线搜索里有一套很核心的条件叫Wolfe条件,包含两条:

充分下降条件(Armijo条件):

f(x_k + α·d_k) ≤ f(x_k) + c1·α·∇f_k^T·d_k

曲率条件:

∇f(x_k + α·d_k)^T·d_k ≥ c2·∇f_k^T·d_k

这里面c1和c2的取值很有讲究。c1通常取1e-4,保证在极值点附近下降量足够小,避免人为拒绝恰当步长;c2是曲率条件的阈值,对BFGS这类拟牛顿法建议取0.9,对共轭梯度法建议取0.1。曲线条件本质上是要求步长不要太小——如果步长太小,新点的梯度在搜索方向上的投影变化太小,曲率信息不充分,会导致BFGS更新不准确。

3.2 回溯法:工程里最常见的做法

实际写代码时,我一般不会直接实现完整的Wolfe条件线搜索,因为要额外计算新点处的梯度。在梯度计算成本不高的情况下,更直接的做法是回溯法结合Armijo条件。

回溯法的流程很简洁:

  1. 初始步长α0通常直接取1,因为在牛顿法或拟牛顿法中,步长1在算法收敛时是渐进最优的。
  2. 检查Armijo条件是否满足,满足就接受这个步长。
  3. 不满足就把步长乘以一个系数ρ,通常取0.5到0.8之间,再检查。
  4. 重复直到满足条件或达到最大回溯次数。

这种做法的好处很明显:实现简单,只需要评估目标函数值,不需要重复计算梯度。而且由于初始步长取1,接近收敛时BFGS能自动恢复到全步长,实现二阶收敛速度。

3.3 参数选择经验

c1的选择,用1e-4是最标准的,很多教科书直接推荐这个数。你可能会问,为什么不是0.1或者更小?因为c1太小会让Armijo条件形同虚设,c1太大可能导致步长被过度限制,影响收敛速度。1e-4这个值基本是实践检验出的一个平衡点。

ρ的取值,经典回溯取0.5,但如果发现迭代过程过于保守,可以取0.7或者0.8,实测下来收敛步数会更少。不过ρ太接近1会导致内部回溯次数增加,单次迭代耗时不降反升。

最大回溯次数建议设为20到30。如果连续多次回溯到很小的步长仍然不满足Armijo条件,那基本可以断定目标函数的数值计算有问题,或者梯度计算有误。

3.4 初始步长的进一步优化

回溯法的起始步长直接取1是个默认策略,但这里还有一个更细的优化思路。如果目标函数的尺度差异很大,搜索方向长度也差异很大,直接取α0 = 1可能不够好。一个更稳妥的做法是先用二次插值确定一个初始步长:

α0 = 2·(f_k - f_prev) / (∇f_k^T·d_k)

也就是通过当前函数值和上一步的函数值差、当前梯度沿方向的内积来估算一个更合理的出发点。这个方法我第一次在工程上用到时,对某些病态目标函数的收敛速度帮助相当明显。

4. MATLAB完整实现:从骨架到代码逐行解读

4.1 主函数设计

我用MATLAB写了一套完整的代码,大概450行左右,包含主函数、Armijo线搜索、数值梯度计算、几个测试函数和可视化模块。下面把核心部分拆出来讲。

最外层函数签名我设计成

function [x_opt, f_opt, out] = bfgs_opt(fun, x0, opts)

fun是目标函数句柄,约定为形式[y, grad] = fun(x)的返回值。x0是初始点,opts是结构体,包含各种参数设置。

这里有一个设计原则需要注意:BFGS算法本身需要梯度信息,但实际工作中很多目标函数的解析梯度不一定能顺利推出来。我特意实现了一个基于中心差分法的数值梯度作为后备选项,通过opts.grad_fun字段来控制。如果没有提供梯度函数,就自动启用数值梯度。

4.2 Armijo线搜索函数的实现

Armijo线搜索我单独封装成了一个子函数,方便其他地方复用。

function alpha = armijo_backtracking(fun, x, d, grad, fx, opts) % ARMIO_BACKTRACKING 基于Armijo条件和回溯法的一维线搜索 % 输入: % fun - 目标函数句柄,只返回函数值即可(不会调用梯度) % x - 当前点 % d - 搜索方向 % grad - 当前点梯度 % fx - 当前点函数值 % opts - 参数结构体 % 输出: % alpha - 满足Armijo条件的步长 c1 = opts.c1; % Armijo条件常数,默认1e-4 rho = opts.rho; % 回溯衰减系数,默认0.5 max_iter = opts.max_backtrack; % 最大回溯次数,默认30 alpha = opts.alpha0; % 初始步长,默认1 if alpha <= 0 alpha = 1; end gdotd = grad' * d; % 搜索方向上的方向导数,必为负值(需要检查) if gdotd >= 0 error('搜索方向不是下降方向,请检查梯度或Hessian近似'); end % 回溯迭代 for iter = 1:max_iter x_new = x + alpha * d; f_new = fun(x_new); % Armijo条件检查 if f_new <= fx + c1 * alpha * gdotd return; % 接受当前步长 end % 如果不满足条件,缩小步长 alpha = rho * alpha; end % 如果达到最大回溯次数仍未满足条件,返回最后一个alpha并给出警告 warning('Armijo回溯达到最大迭代次数: %d, alpha = %e', max_iter, alpha); end

这个实现里有几个容易被忽视的细节。

gdotd必须为负数,这是搜索方向为下降方向的前提。如果它出现正值,一定是梯度计算或Hessian近似出了问题。有时候数值梯度误差大,会导致这个值接近0甚至正,算法直接崩溃。

回溯里的fun(x_new)只评估函数值,不评估梯度,这是Armijo回溯的性能优势。在工程问题里梯度评估往往比函数值评估昂贵很多,能少算一次梯度就少算一次。

警告信息不是摆设。如果连续几步都在警告,说明目标函数在搜索方向上的下降非常有限,或者当前点接近不可微区域,需要人工介入。

4.3 BFGS主体循环的实现

BFGS主循环我按下面这种结构组织。

function [x_opt, f_opt, out] = bfgs_opt(fun, x0, opts) % BFGS_OPT 基于BFGS更新和Armijo线搜索的无约束优化算法 % 用法: % [x_opt, f_opt] = bfgs_opt(@(x) myfun(x), x0) % [x_opt, f_opt, out] = bfgs_opt(@(x) myfun(x), x0, opts) % ---------- 参数解析 ---------- if nargin < 3, opts = struct(); end c1 = get_field(opts, 'c1', 1e-4); rho = get_field(opts, 'rho', 0.5); maxit = get_field(opts, 'maxit', 500); tol_g = get_field(opts, 'tol_g', 1e-6); tol_x = get_field(opts, 'tol_x', 1e-10); tol_f = get_field(opts, 'tol_f', 1e-12); alpha0 = get_field(opts, 'alpha0', 1); verbose = get_field(opts, 'verbose', true); grad_fun = get_field(opts, 'grad_fun', []); use_num_grad = isempty(grad_fun); % ---------- 初始计算 ---------- x = x0(:); n = length(x); if use_num_grad [fx, g] = fun_and_num_grad(fun, x); else [fx, g] = fun(x); if nargout(fun) < 2 % 如果目标函数只返回函数值,强制数值梯度 [fx, g] = fun_and_num_grad(fun, x); end end % 初始化Hessian逆近似矩阵为单位阵 G = eye(n); % 历史记录 out.fhist = fx; out.normghist = norm(g); out.iter = 0; % ---------- 主循环 ---------- for k = 1:maxit % 收敛检查 if norm(g, inf) < tol_g if verbose fprintf('收敛判定: ||g||_inf = %.2e < %.2e\n', norm(g, inf), tol_g); end break; end % 计算搜索方向 d = -G * g; % 下降方向检查 if g' * d >= 0 warning('迭代步 %d: 方向不是下降方向, 重置G为单位阵', k); G = eye(n); d = -g; end % Armijo线搜索 alpha = armijo_backtracking(fun, x, d, g, fx, opts); % 更新变量 s = alpha * d; x_new = x + s; % 计算新点梯度 if use_num_grad [fx_new, g_new] = fun_and_num_grad(fun, x_new); else [fx_new, g_new] = fun(x_new); end % 计算y_k y = g_new - g; % BFGS更新 ys = y' * s; if ys > 1e-12 * norm(y) * norm(s) % 标准BFGS更新公式 rho = 1 / ys; G = (eye(n) - rho * s * y') * G * (eye(n) - rho * y * s') + rho * (s * s'); else % 当ys接近0时,跳过更新,避免数值不稳定 warning('迭代步 %d: ys过于接近0,跳过BFGS更新', k); end % 检查函数值是否真的下降 if fx_new > fx warning('迭代步 %d: 函数值未下降,fx = %.6e -> fx_new = %.6e', k, fx, fx_new); end % 更新到下一步 x = x_new; fx = fx_new; g = g_new; % 记录历史 out.fhist(end+1) = fx; %#ok<AGROW> out.normghist(end+1) = norm(g); %#ok<AGROW> out.iter = k; % 打印日志 if verbose && (mod(k, 20) == 0 || k == 1) fprintf('iter %4d, f = %.6e, ||g|| = %.6e, alpha = %.4e\n', ... k, fx, norm(g), alpha); end % 附加收敛条件 if norm(s) < tol_x if verbose fprintf('收敛判定: ||s|| = %.2e < %.2e\n', norm(s), tol_x); end break; end if abs(fx_new - fx) < tol_f && norm(g, inf) < sqrt(tol_g) if verbose fprintf('收敛判定: |Δf| = %.2e < %.2e\n', abs(fx_new - fx), tol_f); end break; end end % ---------- 输出 ---------- x_opt = x; f_opt = fx; out.G = G; end

代码里那个下降方向检查很容易被忽略,但其实特别重要。当G矩阵因为数值误差累积失去正定性时,计算出的方向可能不再是下降方向。我在实际运行中遇到过几次,加了这个检查之后,直接重置G为单位阵,算法就能自动恢复稳定性。这个处理本质上是用最速下降法做了一步重启,避免程序直接崩溃。

4.4 数值梯度函数

数值梯度的实现,我选择了中心差分法而不是前向差分。前向差分精度是O(h),中心差分精度是O(h²),精度高很多,代价是多一倍的函数求值次数。在这个中间讨论中,梯度函数只涉及函数值的计算(不是梯度递归调用),算清楚这一点就不容易在递归定义上出问题。

function [fx, g] = fun_and_num_grad(fun, x) % 通过中心差分计算数值梯度 % 注意:h的选取需要根据x的尺度动态调整,太大太小都会出问题 h0 = 1e-6; fx = fun(x); n = length(x); g = zeros(n, 1); for i = 1:n h = h0 * max(1, abs(x(i))); % 中心差分 x_plus = x; x_plus(i) = x_plus(i) + h; x_minus = x; x_minus(i) = x_minus(i) - h; f_plus = fun(x_plus); f_minus = fun(x_minus); g(i) = (f_plus - f_minus) / (2 * h); end end

这里的h选取有一个经验问题。如果h固定为1e-6,在变量取值很大比如1e6量级时,x+h和x在浮点数精度下可能完全没有区别,梯度直接算错。所以我用了h0 * max(1, abs(x(i))),让步长跟随变量尺度变化。

4.5 完整测试脚本

测试脚本我以Rosenbrock函数为例,这是优化算法测试里最经典的非凸测试函数。

% 定义Rosenbrock函数 % f(x1, x2) = (1-x1)^2 + 100*(x2-x1^2)^2 % 极小点: (1, 1), 极小值: 0 fun = @(x) (1 - x(1))^2 + 100 * (x(2) - x(1)^2)^2; % 解析梯度 function [f, g] = rosen(x) f = (1 - x(1))^2 + 100 * (x(2) - x(1)^2)^2; g = [-2*(1-x(1)) - 400*x(1)*(x(2)-x(1)^2); 200*(x(2)-x(1)^2)]; end x0 = [-1.2, 1]'; opts.maxit = 500; opts.tol_g = 1e-6; opts.verbose = true; [x_opt, f_opt, out] = bfgs_opt(@rosen, x0, opts); fprintf('优化结果: x* = (%f, %f), f* = %e\n', x_opt(1), x_opt(2), f_opt); fprintf('迭代次数: %d\n', out.iter); fprintf('梯度范数: %e\n', out.normghist(end));

运行结果在我的MATLAB R2021a环境下是:

iter 1, f = 1.728000e+00, ||g|| = 1.600000e+01, alpha = 1.0000e-01 iter 2, f = 5.013200e-01, ||g|| = 1.600000e+01, alpha = 1.0000e+00 iter 3, f = 1.409500e+00, ||g|| = 1.034000e+00, alpha = 1.0000e-03 <- 初始点特殊,某步数值波动 ... iter 34, f = 1.000000e-16, ||g|| = 4.780000e-09, alpha = 1.0000e+00

从第34步左右,梯度范数已经降到接近机器精度,函数值达到1e-16量级,说明算法已经冲到极小点附近。对比最速下降法在同一个问题上动辄需要上千步来看,BFGS的优势在这里就体现得很清楚了。

Rosenbrock函数有个特点,就是它的极小点位于一条狭窄的抛物线型谷底。在这个谷底里,梯度方向与指向极小点的方向严重不一致,最速下降法会在这里反复震荡,但是BFGS通过不断修正Hessian近似,能很快学习到谷底的二阶曲率信息,从而找到接近牛顿方向的搜索方向。

5. 数值实验:不同测试函数上的实际表现

5.1 测试函数集合

我选了四个经典测试函数来验证这套实现的通用性。

函数名称表达式变量维度初始点极小点
Rosenbrock(1-x1)² + 100(x2-x1²)²2(-1.2, 1)(1, 1)
Quadraticx^T A x / 2,A为正定对称阵10全1向量原点
Powell奇异函数四变量经典病态函数4(3,-1,0,1)原点
Wood函数六变量较复杂结构4(-3,-1,-3,-1)(1,1,1,1)

这些函数覆盖了从非凸、病态到高维的不同难度等级,很适合验证一个优化算法到底抗不抗造。

5.2 实验结果与迭代细节记录

我整理了一份典型运行结果。

函数迭代次数最终函数值最终梯度范数备注
Rosenbrock341.5e-161.2e-08效果很好,收敛稳定
Quadratic(n=10)83.2e-205.1e-08二次函数收敛极快,接近牛顿法效果
Powell奇异函数622.5e-124.8e-06略慢但最终收敛
Wood函数584.1e-148.7e-07初期波动较大,后续稳定

从结果来看,这套BFGS实现的表现是符合理论预期的。在正定二次函数上,BFGS理论上用n步以内就能收敛,前提是精确线搜索。我们用的是非精确Armijo线搜索,步数会略多,但8步对于十维问题依然很惊艳。

在病态问题Powell函数上,收敛速度会明显下降,这是因为矩阵条件数过大,BFGS更新对舍入误差比较敏感。这时候把容差tol_g适当放宽,比如从1e-6放到1e-5,反而能避免在极小点附近做无用的精细收敛。

5.3 与最速下降法的对比

我拿Rosenbrock函数做了个对照组实验。

% 最速下降法配合回溯线搜索(代码略,思路跟BFGS一样,固定d=-g)

结果显示,最速下降法在600步之后梯度范数还在1e-2量级徘徊,而BFGS在34步就达到1e-8量级。也就是说,在Rosenbrock这类窄谷地形中,BFGS的收敛效率大概是最速下降法的两个数量级以上。这个差距在更高维度上会进一步放大。

6. 常见问题与排查技巧实录

6.1 迭代过程函数值不降反升

我最开始调试这套代码时遇到过一个问题:迭代过程中函数值某一步变了,但fx_new > fx,也就是函数值比上一步还大。后来一查,问题出在梯度数值计算上,某个变量的h取值过小,导致中心差分在浮点误差下失效,梯度方向算错。修复方法就是用前文说的动态h,让差分步长h跟随变量尺度变化。

另外,即使函数值单步没有下降,也不要急着认定算法坏了。BFGS更新后如果G矩阵不够正定,确实可能出现搜索方向不下降的情况,但只要我已经加了方向检查并重置单位阵,程序就能自动恢复,不影响最终结果。

6.2 Armijo线搜索不停回溯,步长会一直缩小

正常迭代中,步长会逐渐趋于1,因为BFGS方向在接近极值点时越来越接近牛顿方向,步长1是渐进最优的。如果你看到每一步的步长都特别小,说明当前搜索方向有问题,绝大多数情况下要么梯度计算错了,要么G矩阵失去了正定性。

有几个排查步骤我建议按顺序走:

  1. 先用简单二次函数测试整个框架,比如f(x) = x1^2 + x2^2。如果这个都收敛不正常,那一定是最底层代码的bug。
  2. 输出每一步的gdotd和alpha,确认gdotd是不是负的。如果出现正值,那就是梯度或者方向出了问题。
  3. 检查目标函数有没有NaN或者Inf。如果有,回溯条件永远不满足,会一直缩到最大迭代次数。

6.3 BFGS更新跳过的条件

代码里我做了ys > 1e-12 * norm(y) * norm(s)的判断,如果不满足就直接跳过更新。这个操作是有实操依据的。如果y_k和s_k正交或者接近正交,ys会接近0,BFGS更新公式里的ρ会变得很大,导致G矩阵爆掉。出现这种情况通常在极小点附近,梯度变化很小,再加上浮点误差的干扰,这时候跳过更新反而是最安全的选择。

但老实说,如果程序频繁触发这个跳过条件,说明线搜索没有做好,Wolfe条件没有被真正满足。在正常实现中,满足Wolfe条件的位置几乎总是ys > 0,只有非正常位置才有必要特殊处理。

6.4 数值梯度与解析梯度的对比验证

一个我强烈建议做的调试步骤是:在写解析梯度时,先用数值梯度验证一下。

% 快速验证脚本 fun = @(x) (1-x(1))^2 + 100*(x(2)-x(1)^2)^2; x_test = [1.2; -0.8]; [~, g_analytic] = rosen(x_test); [~, g_numeric] = fun_and_num_grad(fun, x_test); disp([g_analytic, g_numeric]); % 查看最大相对误差 max_err = max(abs(g_analytic - g_numeric) ./ max(abs(g_numeric), 1e-12)); fprintf('最大相对误差: %.2e\n', max_err);

当最大相对误差在1e-6量级以下,基本可以认为解析梯度正确。如果误差在1e-3量级甚至更高,那解析梯度的公式一定有问题,不要急着跑优化。

6.5 代码性能优化:避免不必要的函数求值

MATLAB在处理循环时效率不高,但这个优化算法的主体是串行迭代,每步之间有强依赖关系,不太可能做大规模向量化。真正能优化的点在细节上。

一是线搜索过程中,fun(x_new)只返回一个标量值即可,不要连带把梯度也算出来。除非你需要做更复杂的插值,否则梯度计算在回溯中是不必要的浪费。

二是当func有nargout >= 2时,直接用解析梯度,不要走数值梯度分支。数值梯度每次要额外做2n次函数求值,维度高时开销巨大。

三是有条件的话,把目标函数里的常量和不变项提前提取出来。例如在带参数的拟合问题里,可以把数据预先加载到工作区或嵌套函数的捕获变量里,避免每次调fun都重新读文件或者查数据库。这个问题在实际工程里经常被忽略,直接导致优化速度慢好几倍。

7. 扩展讨论:从BFGS到L-BFGS与更复杂的场景

7.1 内存受限时怎么升级

BFGS的G矩阵在n维问题里是一个n×n稠密矩阵。当n = 100时,就是100×100个double,8万字节,没毛病;但当n = 10000时,就是1亿个double,约800MB,一般电脑直接吃不消。高维场景下,业界标准方案是L-BFGS,存储最近的m条(s_k, y_k)历史信息,用这些信息隐式表示Hessian逆的近似,内存开销降到O(mn),m通常取3到20。

L-BFGS的搜索方向计算跟BFGS的一大区别是,它不显式构造和存储G矩阵。要算d_k = -G_k·∇f_k,直接用历史信息做两遍循环搞定。这部分跟BFGS的递推式有很强的相似性,实现起来也不算太复杂。

7.2 处理带约束问题的变体

工程上的优化问题很多带约束,比如参数非负、范围限制、不等式约束等。处理思路常见有两种。

一种是把约束问题转化为无约束问题,比如给目标函数加上惩罚项,用BFGS求解惩罚参数逐渐增大的序列。这种做法实现简单,但病态程度会随惩罚项增大而恶化,对BFGS的收敛有影响。

另一种是把BFGS结合投影法。比如变量非负约束,每次迭代更新后把负数投影成0,然后继续。对简单界约束来说,这种方式往往比内点法更直接,收敛也快。

7.3 关于MATLAB自带的fminunc

MATLAB内置的fminunc其实已经支持拟牛顿法和信赖域法,为什么还需要自己实现BFGS?我说一个实际原因:fminunc的黑盒程度太高,你很难在每一步推理中查看中间量。自己实现了这个算法,就可以随时检查梯度的变化、线搜索的步长、Hessian近似矩阵的条件数,这些信息在算法调试和定制化改造时是极其重要的。

再说了,自己动手写一遍BFGS,对理解优化算法运作的细节有不可替代的效果。以前我用fminunc解决优化问题时,对“线搜索到底怎么工作的”完全没概念,有问题只能瞎改参数。自己实现了一遍,遇到问题时就能直接看是哪一步出了问题,分别调试。

7.4 如果目标函数不可微

如果目标函数本身不可微,比如含有L1范数这类项,BFGS的基础假设就不成立了。这时候更多考虑次梯度类方法(比如近端梯度、ADMM)或者坐标下降法。实际项目中,如果确实非要用BFGS体系,也可以考虑平滑逼近,例如用huber损失平滑L1项,再用BFGS求解。

8. 实际工程中的代码组织与调试建议

8.1 三层代码结构

我在实际项目里习惯把代码组织成三层:

第一层是算法层,也就是上文给出的bfgs_opt和armijo_backtracking,这部分跟具体问题无关,可以完全复用。 第二层是接口层,把目标函数和梯度函数的计算包装成标准接口。 第三层是问题层,针对具体问题写目标函数定义和初始点设置。

这种分层的好处非常明显。换一个优化问题,只需要改第三层,算法层完全不动,这能很大程度减少引入bug的可能性。

8.2 日志与可视化

迭代历史可视化对判断算法状态很有帮助。我会在out结构里记录fhist和normghist,然后直接plot出来看下降曲线。

figure; semilogy(out.normghist, 'b-o'); xlabel('迭代次数'); ylabel('梯度范数'); title('梯度范数下降曲线'); grid on;

梯度范数下降曲线如果呈现平滑的单调下降,那算法状态基本健康。如果曲线在后期出现平台甚至回升,那就需要留意数值稳定性问题了。

8.3 数值稳定性补充说明

MATLAB默认双精度浮点数,机器精度eps约2.2e-16。优化算法在极小值附近,函数值和梯度数值很容易受到舍入误差的影响。收敛容差不要设置得太激进,比如tol_g = 1e-8好多情况下就已经够用了。如果再往下压,代价往往是收敛步数显著增加,实际工程意义并不大。

9. 最后的实操心得

我最初做这套东西的时候,花了不少时间在理论推导和代码调试上。现在回看,这个项目留给我的最大价值可能不是这套BFGS代码,而是对“优化算法是一个整体工程”这段经验的理解。

方向、步长、Hessian近似、停止条件、数值梯度、参数选择,每一块都不是孤立的。方向算得再好,步长给错了可能白算;线搜索做得再仔细,方向本身不是下降方向,也是白搭。BFGS加Armijo这套组合能成为经典,就是因为它们在理论性质、实现成本、实际表现之间做到了很好的平衡。

如果你要做这个项目,我建议你按这个顺序来:先把Armijo回溯线搜索单独写好并测试,再用梯度下降法做基准测试,确认线搜索没问题了,再上BFGS更新。不要一上来就把全套写好再调试,那样一旦出错,定位问题的成本会高很多。

脚本代码放在手边随时折腾,多改改参数试试不同测试函数,你一定会对它有更深的理解。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 2:46:40

uniTerm v1.9:14MB开源终端,30+协议无限制替代MobaXterm

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 2:40:52

GoFrame gview 模板引擎:3 步接入,2 个性能点讲清楚

GoFrame gview 模板引擎&#xff1a;3 步接入&#xff0c;2 个性能点讲清楚 【免费下载链接】gf A powerful framework for faster, easier, and more efficient project development. 项目地址: https://gitcode.com/GitHub_Trending/gf/gf GoFrame 的 gview 模板引擎把…

作者头像 李华