ADMM求解Lasso:从数学原理到大规模分布式实现
2026/9/8 6:56:28 网站建设 项目流程

简介:这是一套面向统计学习与优化算法学习者的R语言实现资源,聚焦用ADMM算法求解Lasso回归,兼顾变量选择与大规模数据计算效率。项目包含1个R脚本和1个RData结果文件,压缩包共2个文件、大小8.39MB。其中R脚本完整实现了ADMM迭代流程,支持并行计算与分布式扩展,可用于处理高维特征数据;RData文件保存了模型参数、拟合结果与特征选择输出,方便复现与评估。资源目前已有1788人学习下载,适合希望掌握L1正则化、ADMM原理及R并行编程的科研人员、数据科学爱好者。通过解读脚本和结果,读者可理解ADMM如何将Lasso问题分解为子问题交替求解,并借助foreach、parallel等包提升计算性能,为工业级统计建模提供可直接借鉴的工程化模板。

1. Lasso 的求解难点在哪:为什么默认方法不总够用

Lasso(Least Absolute Shrinkage and Selection Operator)是稀疏回归里绕不开的模型,形式上就是一个平方损失加 L1 惩罚:

minimize 1/2 ||Ax - b||_2^2 + λ ||x||_1

这里的 λ 控制稀疏程度,x 的零元素数量决定了哪些特征被筛掉。很多人第一反应是“这不就是个带正则的最小二乘吗”,但真正动手求解之后会发现,L1 惩罚在零点不可微,这意味着梯度下降类的算法不能直接套用,所有依赖于求导的优化工具都得绕路。

1.1 一个不可微的惩罚项

L2 正则(岭回归)有显式解,直接解线性方程就好。L1 正则麻烦在 |x_i| 这个绝对值函数在 x_i = 0 处有一个尖角。梯度在零点左右来回震荡,如果强行用次梯度法去迭代,收敛速度慢得让人怀疑人生。

不可微带来的直接后果是:你没法用一个简单的梯度公式一次性把解写出来。但换个角度想,L1 也有个非常好的性质——它让解产生精确的零元素,而不是像 L2 那样把所有系数都压小。这个性质在特征筛选场景下极其重要,所以即便它不好求,我们也要硬着头皮找高效的算法。

1.2 坐标下降为什么在小规模场景是主角

在中小规模数据上,坐标下降(Coordinate Descent)是求解 Lasso 的事实标准。sklearn 的 Lasso 默认用的就是坐标下降,glmnet 在 R 语言里也靠它打天下。它的思路很朴素:每次只优化一个坐标方向,其他坐标固定,对单个变量来说 Lasso 的子问题有闭式解(用软阈值算子),所以一轮一轮扫坐标就能收敛。

坐标下降在小规模场景非常好用,迭代速度快,实现也简单。但它的瓶颈在于:当特征维度 p 非常大(比如几十万、上百万),或者数据以分布式方式存储在多台机器上时,坐标下降的串行特性就成了硬伤——因为每次更新一个坐标依赖其他坐标的最新值,天然难以并行。

1.3 ADMM 登场:适合大规模和分布式

ADMM 的思路和坐标下降完全不同。它把原问题拆成两个子问题,每个子问题都能有效求解,然后通过一个对偶变量把两个子问题的解“拉拢”到一起。由于子问题之间只需要交换一次变量信息,它天然适合并行和分布式计算。

用 ADMM 解 Lasso 还有一个额外好处:所有子问题都有闭式解或者近似闭式解,不需要在每次迭代里再跑一层迭代优化。这一点在工程实现上非常重要——你不需要为每一步迭代去调一个内层 solver 的收敛精度,代码结构会清爽很多。

2. ADMM 求解 Lasso 的完整数学拆解

2.1 变量分裂:把 Lasso 改造成线性约束问题

先讲 ADMM 的核心思想:变量分裂。我们把 Lasso 中的 x 拆成两个变量 x 和 z,让二次损失只依赖于 x,L1 惩罚只依赖于 z,然后强制两者相等:

minimize 1/2 ||Ax - b||_2^2 + λ ||z||_1 subject to x - z = 0

为什么这么干?因为原来的问题里,二次项和 L1 项缠在一起,谁都不好处理。拆开之后,x 子问题变成一个纯最小二乘(带 L2 正则),z 子问题变成一个纯 L1 最小化,两者都有极简单高效的解法。

2.2 增广拉格朗日与 x 子问题

引入拉格朗日乘子 y,把约束条件加入目标函数,再加上一个二次惩罚项保证收敛稳定性:

L_ρ(x, z, y) = 1/2 ||Ax - b||_2^2 + λ ||z||_1 + y^T(x - z) + (ρ/2)||x - z||_2^2

其中 ρ 是惩罚参数。实际操作中更常用缩放形式,令 w = y/ρ(w 叫缩放对偶变量),表达式会更简洁,迭代时少一次除法。缩放形式的迭代步骤为:

x^{k+1} = argmin_x 1/2 ||Ax - b||_2^2 + (ρ/2)||x - z^k + w^k||_2^2

z^{k+1} = argmin_z λ ||z||_1 + (ρ/2)||x^{k+1} - z + w^k||_2^2

w^{k+1} = w^k + x^{k+1} - z^{k+1}

先看 x 子问题。对 x 求导并令导数为零,得到线性方程:

(A^T A + ρI) x = A^T b + ρ(z^k - w^k)

这个形式和岭回归一模一样——多出来的 ρI 项实际上是给 A^T A 加了对角扰动。由于 ρ > 0,即使 A 是病态矩阵或者列之间存在严重共线性,A^T A + ρI 也一定是正定矩阵,理论上保证可解。这就是 ADMM 对病态问题稳健的一个重要原因。

2.3 z 子问题:软阈值算子的闭式解

z 子问题的核心是近端算子。对于函数 g(z) = λ||z||_1,它的近端算子就是软阈值算子:

prox_{λ||·||_1}(v) = sign(v) · max(|v| - λ, 0)

具体的更新是:

z^{k+1} = S_{λ/ρ}(x^{k+1} + w^k)

其中 S_κ(·) 是逐元素操作的软阈值算子:对每个分量 v,如果 v > κ,输出 v - κ;如果 v < -κ,输出 v + κ;如果 |v| ≤ κ,输出 0。直观理解就是:把每个分量往零的方向压缩 κ,压缩到零的就不动了。

为什么软阈值算子天然产生稀疏解?因为在 [-κ, κ] 区间内的值被直接“抹平”到零。κ = λ/ρ,所以 λ 越大,抹平的范围越大,解越稀疏;ρ 越大,κ 越小,x 和 z 的耦合越紧。

2.4 三步迭代的几何直觉

把三步迭代放在一起看,其实很有画面感:

  • x 更新:在当前“猜测的稀疏解”z 的引导下,求解一个带 L2 正则的拟合问题,得到一个相对稠密的解;
  • z 更新:把 x 的解“砍一刀”,把绝对值小于阈值的分量置零,得到一个新的稀疏猜测;
  • w 更新:记录 x 和 z 之间的差距,作为下一轮的修正信号。

整个迭代过程就像两个人在拔河。x 代表“拟合”的力量,想把数据拟合得足够好;z 代表“稀疏”的力量,想把特征筛得足够干净。w 就是那根绳子上记录着双方拉扯的张力。当 x 和 z 趋于一致时,w 基本不再变化,问题就收敛了。

3. 收敛判据和 rho 的调参策略

3.1 原始残差和对偶残差如何决定停止

很多人跑 ADMM 时只看 x 的变化量,这其实不够。ADMM 的收敛要同时看两个量:

原始残差 r^k = x^k - z^k,衡量约束 x = z 的违背程度; 对偶残差 s^k = -ρ(x^k - x^{k-1}),衡量对偶变量(也就是目标函数最优性条件)的违背程度。

停止条件通常是:

||r^k||_2 ≤ ε_pri,且 ||s^k||_2 ≤ ε_dual

ε_pri 和 ε_dual 可以用绝对误差加相对误差的方式设定。一个常用的做法(Boyd 等人在论文中的建议)是:

ε_pri = √p · ε_abs + ε_rel · max(||x||_2, ||z||_2) ε_dual = √n · ε_abs + ε_rel · ||y||_2

其中 ε_abs 通常取 1e-6 到 1e-4,ε_rel 取 1e-4 到 1e-3。如果只盯着原始残差看,对偶残差偏大时你可能会得到“看起来满足约束、但远非最优解”的结果。

3.2 rho 取值的量级感觉

ρ 是 ADMM 里最敏感的超参数。它本质上控制 x 和 z 之间“拉拢”的力度:

  • ρ 太小,x 和 z 可以长时间偏离彼此,原始残差下降慢,需要很多次迭代才收敛;
  • ρ 太大,x 子问题被 ρI 主导,x 的更新非常保守,对偶残差下降慢,每一步都小心翼翼,同样不划算。

实际使用中,我没有一个万能公式,只有一个量级参考:ρ 应当和 A^T A 的特征值量级以及 λ 的水平相匹配。如果数据做过标准化(每列零均值、单位方差),A^T A 的对角元大约是 1,此时从 ρ = 1 或 ρ = 0.1 起步是比较稳的。如果 λ 本身很小(比如 1e-4),ρ 也相应往小了调。

3.3 自适应 rho 的工程实现思路

固定 ρ 当然简单,但想在各种数据上都稳,更省心的做法是让 ρ 随迭代自动调整。思路很直接:每当原始残差比对偶残差大太多,说明 x 和 z 拉得太远,应该增大 ρ;反之,对偶残差大说明 x 移动太快,应该减小 ρ。

伪代码如下:

if ||r||_2 > 10 * ||s||_2: rho = rho * 2 elif ||s||_2 > 10 * ||r||_2: rho = rho / 2

每迭代若干次检查一次,调整幅度用倍增/倍减而不是微调。工程上还要给 ρ 设置上下限(比如 1e-8 到 1e8),防止震荡或溢出。自适应 ρ 会让收敛曲线平稳不少,代价是要重新做 Cholesky 分解(因为 ρ 变了),但这个代价通常值得付。

4. 一段可复现的实现:从矩阵分解到主循环

4.1 Python 代码骨架

先给一个最小实现,用 numpy 在小型稠密数据上跑通逻辑。核心只有三个更新步骤:

import numpy as np def soft_threshold(v, kappa): return np.sign(v) * np.maximum(np.abs(v) - kappa, 0.0) def admm_lasso(A, b, lam, rho=1.0, max_iter=1000, tol_abs=1e-5, tol_rel=1e-3): n, p = A.shape AtA = A.T @ A Atb = A.T @ b # 对 x 子问题的系数矩阵做 Cholesky 分解 L = np.linalg.cholesky(AtA + rho * np.eye(p)) x = np.zeros(p) z = np.zeros(p) w = np.zeros(p) for k in range(max_iter): # x 更新:解 (AtA + rho*I) x = Atb + rho*(z - w) rhs = Atb + rho * (z - w) x = np.linalg.solve(L.T, np.linalg.solve(L, rhs)) # z 更新:软阈值 z_old = z.copy() z = soft_threshold(x + w, lam / rho) # w 更新 w = w + x - z # 收敛判断 r_norm = np.linalg.norm(x - z) s_norm = np.linalg.norm(-rho * (z - z_old)) eps_pri = np.sqrt(p) * tol_abs + tol_rel * max(np.linalg.norm(x), np.linalg.norm(z)) eps_dual = np.sqrt(n) * tol_abs + tol_rel * np.linalg.norm(rho * w) if r_norm <= eps_pri and s_norm <= eps_dual: break return x, z, w, k + 1

这个骨架能跑通,但只适合 A 是稠密小矩阵的场景。真实数据里 A 几乎都是稀疏的,直接 np.eye(p) 和 A.T @ A 会造成巨大的内存浪费。后面我会讲怎么改。

4.2 线性系统的预分解与稀疏表示

x 子问题的核心是解一个线性方程组。每次迭代都重新组装 A^T A + ρI 再求解非常浪费。正确的做法是:在进入迭代之前,对系数矩阵做一次 Cholesky 分解,之后每次迭代只做两次三角回代(forward/back substitution),复杂度从 O(p^3) 降到 O(p^2),这是巨大的提升。

from scipy.sparse import csc_matrix, eye from scipy.sparse.linalg import splu, spsolve # 稀疏版本:用稀疏 Cholesky 或 LU 预分解 Mt = csc_matrix(AtA + rho * eye(p)) lu = splu(Mt) # 或者用 sksparse.cholmod 的 cholesky # 每次迭代只需要调用 lu.solve(rhs) x = lu.solve(rhs)

需要注意的是,ρ 一旦变化,系数矩阵就变了,必须重新分解。所以自适应 ρ 策略里,要判断 ρ 的实际值是否变化了,再决定是否重建分解器。

4.3 温启动、缓存与交叉验证集成

如果你在一个 λ 网格上跑 Lasso(做交叉验证选正则系数),有一个非常经典的加速技巧:温启动。把在较大 λ 下求得的解作为较小 λ 下迭代的初始值。因为 λ 的网格通常是递减的(从大到小),λ 较大时解更稀疏,把它作为初值传递给下一个 λ,通常只需要几次迭代就能收敛。

另外,A^T b 只依赖数据,可以在循环 λ 之前算好缓存。A^T A 如果数据不变,也只能算一次。真正变化的只有 λ 和 ρ。

在实际工程中,温启动配合适当减少最大迭代次数,可以把交叉验证的总时间缩短一个数量级。这个效果我在多个数据集上都验证过。

5. 常见坑与我的经验

5.1 特征尺度会让 rho 变成“伪参数”

如果你不做标准化直接跑,A 的列量级差异可能很大,A^T A 的对角元从 1e-6 到 1e6 都有可能。这时固定 ρ 就非常难受:对一个特征合适的拉力,对另一个特征可能完全失效。我的建议是数据进来先标准化,让每列有单位方差,这样 A^T A 的量级就统一了,ρ 的可调范围也稳定得多。

如果因为业务原因不能标准化(比如需要保留系数的原始解释),可以考虑换用列归一化的 A 做迭代,最后再把系数换算回去。

5.2 KKT 验证:判断是否真的收敛

停止准则满足不代表解是对的。我在实际项目中养成了一个习惯:无论用什么 solver 跑 Lasso,最后都用 KKT 条件验证一下最优性。Lasso 的最优性条件是:

0 ∈ A^T(Ax - b) + λ · ∂||x||_1

等价于:对任意 i, 如果 x_i ≠ 0,则 (A^T(Ax - b))_i = -λ · sign(x_i); 如果 x_i = 0,则 |(A^T(Ax - b))_i| ≤ λ。

换成工程语言:先算出残差向量 r = Ax - b,再算梯度 g = A^T r,然后检查所有非零位置是否满足 g_i ≈ -λ·sign(x_i),所有零位置是否满足 |g_i| ≤ λ。允许一个小的容差。这一步能快速揪出“伪收敛”的问题。

5.3 一个让人惊讶的观察:ADMM 在什么时候真正值得用

以前我刚开始用 ADMM 解 Lasso 的时候,也拿它和 sklearn 的 Lasso 比速度,在小数据集上 ADMM 常常没有优势,甚至更慢。后来想明白一件事:坐标下降在单机中小规模数据上经过了十几年的工程优化,真的很难被取代。ADMM 的价值不在“单机小数据比快”,而在三个场景里才真正兑现:

  • 数据太大放不进内存,需要把样本分片后分布式求解;
  • 特征维度超高,A^T A 都装不下,需要用矩阵分解避免显式构造 Gram 矩阵;
  • 目标函数是多个块的求和,每个块有自己的结构,ADMM 天然支持多块分解。

所以在实际项目里,我不会因为“ADMM 听起来高级”就替换掉 sklearn 的坐标下降,而是评估数据规模、分布式需求和单次迭代成本。当单机内存不足或者需要并行化的时候,ADMM 就是那个值得拿出来用的方案。

在我最近处理的一个高维稀疏特征场景里,特征维度几十万、样本量十几万,坐标下降每轮都要扫描全部特征,跑一次完整的路径要几小时。换成 ADMM 配合矩阵分解和温启动后,单条 λ 路径只花了几分钟。这个差距不是算法好坏的问题,而是问题结构决定的——ADMM 让每次迭代的计算成本变得可控,并且可以被分片到多台机器上并行跑。

如果只是中小规模回归任务,老老实实用 sklearn 的 Lasso 就够了,没必要为了用 ADMM 而用 ADMM。算法的价值取决于问题,而不是名字本身。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询