Getting started
In this guide, we solve the same lasso problem by coordinate descent and proximal gradient, using a common loss and penalty for both procedures.
Installation and problem setup
Install ProximalBase on Julia 1.10 or later:
using Pkg
Pkg.add("ProximalBase")We consider the objective
\[F(\beta) = \frac12\|y-X\beta\|_2^2 + \lambda\|\beta\|_1,\]
where $X\in\mathbb{R}^{n\times p}$ is the design matrix, $y\in\mathbb{R}^n$ is the response and $\lambda>0$ controls the penalty. LeastSquaresLoss uses the sum of squared residuals divided by two. For the alternative convention $\|y-X\beta\|_2^2/(2n)+\lambda\|\beta\|_1$, use NormL1(n * λ) with this unscaled loss, or scale the loss by 1 / n.
using ProximalBase, LinearAlgebra, Random
rng = Xoshiro(1)
n, p = 100, 20
X = randn(rng, n, p)
y = X * [ones(3); zeros(p - 3)] + 0.1 * randn(rng, n)
λ = 5.0
f = LeastSquaresLoss(y, X)
g = NormL1(λ)Coordinate descent for the lasso
Let $r=y-X\beta$ and $a_j=\|X_{:,j}\|_2^2>0$. Holding the other coefficients fixed gives the update
\[\beta_j^+ = S_{\lambda/a_j}\left(\beta_j+\frac{X_{:,j}^\top r}{a_j}\right), \qquad r^+ = r-X_{:,j}(\beta_j^+-\beta_j),\]
where $S_t(v)=\operatorname{sign}(v)\max(|v|-t,0)$ is soft thresholding. The residual update costs $O(n)$ and avoids recomputing $X\beta$ after each coordinate. We compute the scalar thresholding step with prox_entry, using the curvature $a_j$ supplied by coordinate_lipschitz.
We use a SparseIterate to store the coefficients and a full gradient mapping to check convergence after each sweep. The Coordinate descent chapter derives the update and gives the coordinate optimality conditions used by active-set procedures.
function lasso_cd(f, g; tol = 1e-8, maxsweeps = 1000)
X, y = f.X, f.Y
β = SparseIterate(size(X, 2))
r = copy(y)
a = [coordinate_lipschitz(f, j) for j in axes(X, 2)]
all(>(0), a) || error("Remove zero columns before fitting")
γ = 1 / opnorm(X)^2
G = zeros(size(X, 2))
for sweep in 1:maxsweeps
for j in axes(X, 2)
old = β[j]
z = old + dot(view(X, :, j), r) / a[j]
new = prox_entry(g, z, j, 1 / a[j])
if new != old
β[j] = new
axpy!(old - new, view(X, :, j), r)
end
end
gradient_mapping!(G, f, g, β, γ)
norm(G, Inf) <= tol && return dropzeros!(β)
end
error("Coordinate descent did not converge within maxsweeps")
end
β_cd = lasso_cd(f, g)
round.(Vector(β_cd), digits = 3)20-element Vector{Float64}:
0.96
0.977
0.964
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0
0.0Note that a coordinate that stays at zero is never stored, while an entry that returns to zero remains active until dropzeros! is called. In particular, storage records the working set during a sweep, and may contain more entries than the current support.
Proximal gradient
Next, we update all coefficients together. The loss has gradient $\nabla f(\beta)=X^\top(X\beta-y)$ with Lipschitz constant $L=\|X\|_2^2$. A proximal gradient step with $\gamma=1/L$ is
\[\beta^+ = \operatorname{prox}_{\gamma g} \bigl(\beta-\gamma\nabla f(\beta)\bigr).\]
gradient! stores the gradient in a supplied array, and prox! applies the proximal operator to the full vector. Here we use the same stopping rule as for coordinate descent, and overwrite the coefficients with prox!(β, g, β, γ) after taking the gradient step.
function lasso_pg(f, g; tol = 1e-8, maxiter = 10_000)
β = zeros(size(f.X, 2))
grad, G = similar(β), similar(β)
γ = 1 / opnorm(f.X)^2
for iter in 1:maxiter
gradient!(grad, f, β)
β .-= γ .* grad
prox!(β, g, β, γ)
gradient_mapping!(G, f, g, β, γ)
norm(G, Inf) <= tol && return β
end
error("Proximal gradient did not converge within maxiter")
end
β_pg = lasso_pg(f, g)
@assert norm(Vector(β_cd) - β_pg, Inf) < 1e-7
(coordinate_descent = f(β_cd) + g(β_cd),
proximal_gradient = f(β_pg) + g(β_pg))(coordinate_descent = 15.30037015124626, proximal_gradient = 15.300370151246259)Checking the result
For convex $f$ and $g$, the gradient mapping is zero exactly at a minimizer. A small coefficient change alone need not imply that the optimality conditions hold, especially when only a working set has been updated. Here we check both the gradient mapping and the lasso conditions directly.
\[\nabla_j f(\beta)+\lambda\operatorname{sign}(\beta_j)=0 \quad\text{if }\beta_j\ne0, \qquad |\nabla_j f(\beta)|\le\lambda \quad\text{if }\beta_j=0.\]
grad = gradient(f, β_cd)
violation = maximum(eachindex(grad)) do j
iszero(β_cd[j]) ? max(abs(grad[j]) - λ, 0.0) :
abs(grad[j] + λ * sign(β_cd[j]))
end
@assert violation < 1e-7
@assert norm(gradient_mapping(f, g, β_pg, 1 / opnorm(X)^2), Inf) < 1e-8
(kkt_residual = violation, nonzeros = nactive(β_cd))(kkt_residual = 8.446450650012594e-9, nonzeros = 3)The loops above illustrate how we use ProximalBase to construct two optimization procedures from the same loss and penalty. For penalty paths, screening and complete solvers, see ProximalSolvers.jl. Next, continue with Coordinate descent for entry and block updates, or The interface for the mathematics of full proximal steps.