简介:这份资源是增广拉格朗日乘子法(ALM)的MATLAB实现代码包,面向机器学习与数值优化方向的学习者和研究者,用于求解带约束的优化问题,尤其适合低秩矩阵恢复、稀疏表示、推荐系统等场景的入门与复现。压缩包共106个文件,约749KB,以46个m文件为核心算法与辅助函数,另含c源码及mexsol、mexsg64、mexglx、mexw32等跨平台编译文件,配合txt、doc说明文档与mat数据文件,覆盖初始化、变量更新、乘子更新、惩罚参数调整等完整迭代框架。已有7196人学习下载,热度较高。读者可借此理解惩罚参数ρ逐步增大逼近最优解的机制,掌握收敛条件判断与参数调节思路,并直接运行调试,将ALM迁移到自身约束优化任务中。
1. 从一次调参翻车说起:ALM 算法到底解决什么问题
很多做机器学习的朋友第一次遇到带约束的优化问题,第一反应是上罚函数法,结果调参调到怀疑人生——罚因子给小了约束根本压不住,给大了 Hessian 矩阵条件数爆炸,迭代直接卡死。我当年做一个小型投资组合优化 Demo 时就踩过这个坑,后来换成增广拉格朗日乘子法(Augmented Lagrangian Method,ALM),同样的约束精度下迭代次数直接砍半。这份 MATLAB 代码包就是把 ALM 从公式落成可运行脚本的完整实现,覆盖等式约束、不等式约束以及带边界约束的二次规划场景。它适合正在学凸优化、做控制或信号处理、需要自己手写求解器而不是调fmincon黑匣子的开发者。代码不依赖任何工具箱,纯 MATLAB 基础语法,拿到就能跑,改目标函数和约束也只需要动几个函数句柄。
2. 算法骨架拆解:从拉格朗日函数到乘子更新
2.1 ALM 的核心迭代逻辑
ALM 的本质是在拉格朗日函数基础上加一个二次罚项,让子问题变得强凸、好解,同时通过外层循环不断更新乘子估计,最终收敛到原约束问题的 KKT 点。标准形式考虑等式约束问题:
min f(x) s.t. h(x) = 0增广拉格朗日函数写成:
L_ρ(x, λ) = f(x) + λᵀh(x) + (ρ/2)‖h(x)‖²外层每轮先固定 λ 和 ρ,对 x 做无约束(或简单箱约束)最小化,然后更新乘子和罚因子。这份代码里外层循环控制在alm_solver.m,子问题求解放在subproblem_solver.m,职责切得很干净。
2.2 目录结构与文件职责
拿到代码包后先别急着跑main.m,花两分钟把目录过一遍,后面改起来会顺很多。典型结构如下:
ALM_Matlab/ ├── main.m % 入口脚本,定义问题并调用求解器 ├── alm_solver.m % ALM 外层主循环 ├── subproblem_solver.m % 子问题无约束/箱约束求解 ├── update_multiplier.m % 乘子与罚因子更新策略 ├── test_eq.m % 等式约束测试用例 ├── test_ineq.m % 不等式约束测试用例 ├── test_qp_box.m % 带箱约束的二次规划用例 └── utils/ ├── merit_function.m % 评价函数,用于监控收敛 └── plot_convergence.m % 收敛曲线绘制main.m只负责定义f、h、g和初始点,真正的算法逻辑全在alm_solver.m里。这种分层的好处是:你想换一个问题,只改main.m;想换乘子更新策略,只改update_multiplier.m,互不干扰。
2.3 外层主循环代码逐段讲
打开alm_solver.m,核心循环大概长这样:
function [x_opt, lambda, log] = alm_solver(f, h, g, x0, opts) % ALM 外层求解器 % 输入: % f - 目标函数句柄 @(x) ... % h - 等式约束句柄 @(x) 返回列向量 % g - 不等式约束句柄 @(x) 返回列向量 (g(x) <= 0) % x0 - 初始点 % opts - 参数结构体: rho0, rho_max, eta, tol, max_iter % 初始化乘子与罚因子 lambda = zeros(size(h(x0))); % 等式约束乘子 mu = zeros(size(g(x0))); % 不等式约束乘子 rho = opts.rho0; x = x0; log = []; for k = 1:opts.max_iter % 1) 构造增广拉格朗日函数并求解子问题 aug_L = @(x) f(x) + lambda' * h(x) + (rho/2) * norm(h(x))^2 ... + sum(max(0, mu + rho * g(x)).^2 - mu.^2) / (2*rho); x_new = subproblem_solver(aug_L, x, opts); % 2) 计算约束违反度 viol_eq = norm(h(x_new)); viol_ineq = norm(max(0, g(x_new))); viol = max(viol_eq, viol_ineq); % 3) 记录日志 log = [log; k, f(x_new), viol, rho]; % 4) 收敛判断 if viol < opts.tol x_opt = x_new; return; end % 5) 乘子更新 lambda = lambda + rho * h(x_new); mu = max(0, mu + rho * g(x_new)); % 6) 罚因子更新:违反度下降不够快就加倍 if viol > opts.eta * log(max(1,end-1), 3) rho = min(rho * 2, opts.rho_max); end x = x_new; end x_opt = x; end逐段看:第 1 步把等式和不等式约束统一塞进增广拉格朗日函数,不等式部分用max(0, ·)做了一次平滑处理,这是 ALM 处理不等式约束的常见写法。第 2 步算约束违反度,等式用二范数,不等式只统计正值部分。第 5 步乘子更新是 ALM 的灵魂——lambda沿约束梯度方向走一步,步长就是罚因子rho。第 6 步的罚因子自适应策略很关键:如果这一轮违反度没有比上一轮下降超过eta倍,说明子问题解得不够好或者罚因子太小,直接把rho翻倍。
参数说明:rho0一般取 1 到 10,rho_max取 1e6 到 1e8 防止数值溢出,eta取 0.25 到 0.5,tol根据问题精度取 1e-6 到 1e-8。这几个值在main.m顶部的opts结构体里改。
2.4 子问题求解器的实现选择
subproblem_solver.m负责在给定增广拉格朗日函数下找 x。这份代码默认用的是带 Armijo 线搜索的拟牛顿法(BFGS 更新),因为大多数中小规模问题不需要精确求解子问题,近似解就够外层收敛:
function x = subproblem_solver(aug_L, x0, opts) % 用 BFGS + Armijo 线搜索求解无约束子问题 x = x0; n = length(x); B = eye(n); % Hessian 近似初始化 for it = 1:opts.inner_max_iter [fval, grad] = aug_L(x); % 假设 aug_L 返回函数值和梯度 if norm(grad) < opts.inner_tol break; end d = -B \ grad; % 拟牛顿方向 % Armijo 线搜索 alpha = 1; while aug_L(x + alpha*d) > fval + 1e-4 * alpha * grad' * d alpha = alpha * 0.5; end x_new = x + alpha * d; s = x_new - x; y = grad_new - grad; % 梯度差 % BFGS 更新 if y' * s > 1e-10 B = B - (B*s*s'*B)/(s'*B*s) + (y*y')/(y'*s); end x = x_new; end end这里有几个工程细节值得注意:inner_max_iter不要设太大,通常 20 到 50 就够,因为外层会反复调用;inner_tol可以比外层tol松一到两个数量级,比如外层要 1e-8,内层 1e-6 即可。BFGS 更新里那个y'*s > 1e-10的判断是防止除零,实际跑的时候如果目标函数尺度很小,这个阈值可以调到 1e-14。
3. 跑通第一个例子:等式约束二次规划实操
3.1 环境准备与文件放置
MATLAB 版本建议 R2018a 及以上,因为代码里用了隐式扩展和函数句柄的嵌套写法。不需要任何工具箱,Optimization Toolbox 和 Global Optimization Toolbox 都不依赖。把整个ALM_Matlab文件夹放到你习惯的工作路径下,在 MATLAB 里cd进去,然后addpath(genpath(pwd))把子目录也加进来。
3.2 定义问题并调用求解器
打开main.m,里面默认是一个等式约束二次规划:
% main.m - 等式约束 QP 示例 clear; clc; close all; % 目标函数: f(x) = 0.5 * x' * Q * x + c' * x Q = [4, 1; 1, 3]; c = [-1; -2]; f = @(x) 0.5 * x' * Q * x + c' * x; % 等式约束: A * x = b A = [1, 1]; b = 2; h = @(x) A * x - b; % 无不等式约束 g = @(x) []; % 初始点与参数 x0 = [0; 0]; opts.rho0 = 1; opts.rho_max = 1e6; opts.eta = 0.25; opts.tol = 1e-8; opts.max_iter = 100; opts.inner_max_iter = 50; opts.inner_tol = 1e-6; % 调用 ALM 求解 [x_opt, lambda, log] = alm_solver(f, h, g, x0, opts); fprintf('最优解 x = [%.6f, %.6f]\n', x_opt(1), x_opt(2)); fprintf('乘子 lambda = %.6f\n', lambda); fprintf('约束违反度 = %.2e\n', norm(h(x_opt))); % 绘制收敛曲线 plot_convergence(log);这个例子的解析解是 x = [0.5; 1.5],lambda = -2.5。跑完对照一下,如果x_opt和解析解差在 1e-6 以内,说明求解器工作正常。log矩阵每行是[迭代次数, 目标函数值, 约束违反度, 当前罚因子],plot_convergence会画出违反度随迭代下降的曲线。
3.3 不等式约束测试用例
test_ineq.m里给了一个带不等式约束的例子,目标函数是 Rosenbrock 函数的变体,约束是x1 + x2 - 1 <= 0和-x1 <= 0。跑这个用例主要观察两件事:一是乘子mu是否在约束活跃时变成正数,二是罚因子rho是否在违反度下降变慢时自动增大。如果rho一路涨到rho_max还没收敛,多半是子问题求解精度不够,把inner_tol调小一个数量级再试。
3.4 带箱约束的二次规划
test_qp_box.m演示了怎么把箱约束lb <= x <= ub融进 ALM 框架。做法是把箱约束当成不等式约束传给g,或者直接在子问题求解器里做投影。这份代码用的是前者,好处是外层逻辑不用改,坏处是子问题规模变大时收敛会慢一些。如果你的问题箱约束很多,建议改成投影梯度,在subproblem_solver.m里加一行x = min(max(x, lb), ub)就行。
4. 避坑与排查:ALM 调参常见的五个翻车现场
4.1 罚因子初始值给太大,子问题直接解不动
现象:第一轮外层迭代subproblem_solver就报最大迭代次数用完,x几乎没动。原因:rho0太大导致增广拉格朗日函数的 Hessian 条件数爆炸,BFGS 的逆矩阵近似完全失真。解决:把rho0降到 0.1 到 1 之间,让外层通过自适应策略慢慢把rho涨上去。我一般习惯rho0 = 1,除非约束违反度初始就很大。
4.2 乘子更新后目标函数值震荡
现象:log里目标函数值在相邻两轮之间上下跳,约束违反度也不单调下降。原因:子问题求解精度太松,乘子更新基于一个不准确的x_new,导致乘子估计来回摆。解决:把inner_tol调小,或者在外层加一个判断——如果连续两轮违反度没下降,就多跑几次内层迭代。常见做法是让inner_tol = max(1e-8, 0.1 * opts.tol)。
4.3 不等式约束乘子出现负值
现象:mu更新后某些分量变成负数,对应的约束明明不活跃却还在被“推”。原因:max(0, mu + rho * g(x))这行写成了mu + rho * g(x),漏了投影。解决:检查update_multiplier.m里不等式乘子更新是否带了max(0, ·)。这是 ALM 处理不等式约束的标准操作,漏掉的话 KKT 条件里的互补松弛条件就废了。
4.4 收敛判据只看约束违反度,解精度不够
现象:约束违反度早就小于tol了,但x_opt和真实最优解差很远。原因:ALM 的收敛判据应该同时看约束违反度和乘子更新量,只看前者会在目标函数平坦区域提前退出。解决:在alm_solver.m的收敛判断里加一条norm(lambda_new - lambda) < opts.tol,两个条件同时满足才退出。这份代码默认只看了违反度,你可以自己补上。
4.5 大规模问题内存溢出
现象:变量维度超过几千时,BFGS 的B矩阵是 n×n 稠密矩阵,内存直接爆掉。原因:拟牛顿法存储完整 Hessian 近似,复杂度 O(n²)。解决:换成 L-BFGS,只存最近 m 步的s和y向量,内存降到 O(mn)。subproblem_solver.m里把 BFGS 更新替换成 L-BFGS 的两循环递归即可,m 取 5 到 10 就够。
5. 二次开发改造:从示例代码到自己的求解器
5.1 替换目标函数与约束的接口约定
这份代码最值得复用的地方是接口设计:f、h、g都是函数句柄,输入列向量x,输出标量或列向量。你要解自己的问题,只需要在main.m里重新定义这三个句柄。比如做稀疏信号恢复,目标函数是f = @(x) norm(A*x - y)^2 + lambda * norm(x, 1),约束是h = @(x) sum(x) - 1。注意norm(x, 1)在零点不可导,子问题求解器会抖,常见做法是用平滑近似sqrt(x.^2 + eps)替代。
5.2 换乘子更新策略:从经典 ALM 到自适应 ALM
经典 ALM 的乘子更新步长固定为rho,收敛速度依赖罚因子调整策略。如果你想让收敛更快,可以把update_multiplier.m改成自适应版本:
function [lambda, mu, rho] = update_multiplier(lambda, mu, rho, h_val, g_val, opts) % 自适应乘子与罚因子更新 % 乘子更新 lambda = lambda + rho * h_val; mu = max(0, mu + rho * g_val); % 罚因子更新:基于约束违反度的相对下降率 viol = max(norm(h_val), norm(max(0, g_val))); if viol > opts.eta * opts.prev_viol rho = min(rho * opts.beta, opts.rho_max); end opts.prev_viol = viol; end这里beta取 2 到 10,eta取 0.25。和经典策略的区别在于:经典策略每轮都判断,自适应策略只在违反度下降不够快时才涨罚因子,避免rho过早冲到上限导致子问题病态。我实测在几个非凸问题上,自适应版本迭代次数能少 30% 左右。
5.3 加预条件与热启动
如果你的问题规模大、条件数差,可以在子问题求解前加一步预条件。简单做法是对变量做对角缩放x = D * z,D 取目标函数 Hessian 对角元的倒数平方根。热启动则是把上一轮外层的x_new直接作为下一轮内层的初始点,这份代码已经这么做了,但你可以进一步把上一轮的 BFGS 矩阵B也传下去,内层迭代次数能再降一半。
5.4 验证改造是否正确的三个检查点
改完代码别急着上大规模问题,先用小规模已知解验证。第一,跑test_eq.m,看x_opt和解析解差是否在 1e-6 以内;第二,检查log里约束违反度是否单调下降,如果有反弹说明乘子更新或罚因子策略有问题;第三,把rho_max临时调到 1e3,看算法是否在罚因子受限时仍能收敛到合理精度,这能暴露子问题求解器的鲁棒性。这三个检查点我每次改完 ALM 相关代码都会走一遍,有一次偷懒跳过第三步,结果在一个约束边界附近的问题上翻了车,迭代了 500 轮约束违反度还在 1e-3 晃悠。从那以后我每次改完乘子更新逻辑,都强制把这三个检查点跑一遍,确认没问题再上真实问题。希望这份拆解能帮你少走点调参的弯路。
本文还有配套的精品资源,点击获取