The group lasso
minimize ‖y - β₀ - Xβ‖² / (2n) + λ ∑ₖ wₖ ‖β[Gₖ]‖₂by block coordinate descent: each step updates one group Gₖ of coefficients, and the iterate is an AtomIterate that knows the groups. Groups arise when a predictor is coded by several columns, such as the dummy columns of a categorical variable or a basis expansion of a nonlinear effect: the group lasso keeps or drops the columns of a predictor together.
using HighDimensionalModels, ProximalBase, ProximalSolvers
using LinearAlgebra, Printf, Random, Statistics
Random.seed!(5)Random.TaskLocalRNG()An additive model y = β₀ + f₁(z₁) + f₂(z₂) + f₃(z₃) + ε in J = 30 variables zⱼ ~ Uniform(-1, 1), of which three matter. Each variable enters through a cubic polynomial, three standardized columns zⱼ, zⱼ², zⱼ³ that form a group.
n, J = 300, 30
Z = 2 * rand(n, J) .- 1
standardize(v) = (v .- mean(v)) / std(v)
X = reduce(hcat, [standardize(Z[:, j] .^ d) for j in 1:J for d in 1:3])
p = size(X, 2)
groups = [3(j-1)+1:3j for j in 1:J]
y = 1.0 .+ sin.(π * Z[:, 1]) + 2 * Z[:, 2] .^ 2 + Z[:, 3] + 0.5 * randn(n)
selected(x) = findall(G -> any(!iszero, x[G]), groups)selected (generic function with 1 method)One λ
GroupNormL2(λ, groups) takes the weights wₖ = √|Gₖ|, AtomIterate(p, groups) is a zero iterate with these groups, and the intercept is not penalized. The groups must cover all coefficients.
λ = 0.05
g = GroupNormL2(λ, groups)
f = CDLeastSquaresLoss(y, X; intercept = true)
x = AtomIterate(p, groups)
r = solve!(x, f, g, CDOptions())
β₀ = ProximalSolvers.intercept(f, x) # the intercept ȳ - x̄'β̂
println("selected variables ", selected(x), " in $(r.passes) passes; β̂₀ = ", round(β₀; digits = 2))
println("norms of the groups ", round.([norm(x[G]) for G in groups[1:6]]; digits = 2), " …")selected variables [1, 2, 3] in 234 passes; β̂₀ = 1.78
norms of the groups [0.9, 0.5, 0.41, 0.0, 0.0, 0.0] …A solution satisfies ‖∇G f‖ ≤ λ wₖ for the groups at zero and ∇G f = -λ wₖ β̂G / ‖β̂G‖ for the others, with ∇f = -(X - 1x̄')'r / n
r = y .- β₀ - X * x
∇f = -(X .- mean(X; dims = 1))' * r / n
kkt = maximum(enumerate(groups)) do (k, G)
w = g.w[k]
iszero(norm(x[G])) ? max(norm(∇f[G]) - λ * w, 0.0) : norm(∇f[G] + λ * w * x[G] / norm(x[G]))
end
@printf("largest violation of the optimality conditions: %.1e\n", kkt)largest violation of the optimality conditions: 2.9e-07Group weights
GroupNormL2(λ, groups, w) takes weights of your own. A zero weight leaves a group unpenalized, so that it is always in the model, here variable 10.
w = sqrt.(length.(groups))
w[10] = 0
gw = GroupNormL2(λ, groups, w)
xw = AtomIterate(p, groups)
coordinate_descent!(xw, f, gw)
println("with w₁₀ = 0: selected variables ", selected(xw))with w₁₀ = 0: selected variables [1, 2, 3, 10]Starting from zero along a path of penalties
With warmstart = false, coordinate descent solves along numsteps penalty levels from λmax = maxₖ ‖∇Gₖ f(0)‖ / wₖ down to λ, screening the groups with the sequential strong rule at each level. As for the lasso (02solver_options.jl), this costs more passes than a direct start unless a direct start converges slowly.
λsmall = 0.01
for (warm_start, num_steps) in ((true, 50), (false, 10), (false, 50))
xs = AtomIterate(p, groups)
res = solve!(xs, f, GroupNormL2(λsmall, groups), CDOptions(; warm_start, num_steps))
@printf("warm_start = %-5s num_steps = %-3s: %4d passes, %d groups selected\n", warm_start,
warm_start ? "-" : string(num_steps), res.passes, length(selected(xs)))
endwarm_start = true num_steps = - : 456 passes, 26 groups selected
warm_start = false num_steps = 10 : 552 passes, 26 groups selected
warm_start = false num_steps = 50 : 1226 passes, 26 groups selectedChoosing λ on a validation set
A loop over decreasing λ starts each fit from the previous solution, as ProximalSolvers's solve_path does (with screening, and a callback for a validation error). Here λ is chosen by the error on 100 held-out observations.
train, valid = 1:200, 201:300
ft = CDLeastSquaresLoss(y[train], X[train, :]; intercept = true)
∇0 = X[train, :]' * (y[train] .- mean(y[train])) / length(train)
λmax = maximum(norm(∇0[G]) / sqrt(length(G)) for G in groups)
xt = AtomIterate(p, groups)
println(" λ groups validation error")
best = (Inf, 0.0)
for λk in λmax * exp10.(range(0, -2.5; length = 15))
gk = GroupNormL2(λk, groups)
coordinate_descent!(xt, ft, gk) # warm start from the previous λ
pred = ProximalSolvers.intercept(ft, xt) .+ X[valid, :] * xt
err = mean(abs2, y[valid] - pred)
@printf(" %6.4f %6d %16.4f\n", λk, length(selected(xt)), err)
err < best[1] && (global best = (err, λk))
end
println("smallest validation error at λ = ", round(best[2]; digits = 4)) λ groups validation error
0.4212 0 1.4424
0.2792 3 1.0439
0.1851 3 0.7117
0.1227 3 0.5492
0.0813 3 0.4406
0.0539 9 0.3695
0.0357 14 0.3514
0.0237 19 0.3520
0.0157 25 0.3676
0.0104 28 0.3857
0.0069 28 0.4076
0.0046 30 0.4306
0.0030 30 0.4511
0.0020 30 0.4681
0.0013 30 0.4820
smallest validation error at λ = 0.0357The quadratic loss, and a dense starting point
CDQuadraticLoss(A, b), f(x) = x'Ax/2 + b'x, with A = X'X/n and b = -X'y/n for centered X and y has the same minimizer; an update then costs O(p) instead of O(n), which pays off when n is much larger than p. A dense Vector is accepted as well; it is overwritten with the solution.
Xc = X .- mean(X; dims = 1)
yc = y .- mean(y)
xq = zeros(p)
coordinate_descent!(xq, CDQuadraticLoss(Xc' * Xc / n, -Xc' * yc / n), g, CDOptions(; opt_tol = 1e-10))
xl = AtomIterate(p, groups)
coordinate_descent!(xl, f, g, CDOptions(; opt_tol = 1e-10))
@printf("largest difference to the least squares loss: %.1e\n", norm(xq - Vector(xl), Inf))largest difference to the least squares loss: 9.4e-10The estimators: group_lasso and GroupLassoPath
grouplasso(X, y, λ, groups) and GroupLassoPath(X, y, groups) fit an intercept and, by default, orthonormalize each group: the penalty λ ∑ₖ wₖ ‖X̃Gₖ βGₖ‖₂ / √n on the fitted values of the group (X̃ centered) does not depend on the basis of the group, so rescaling a column of the polynomial basis changes only its own coefficient, and every block step is exact. standardize = :none penalizes λ ∑ₖ wₖ ‖βGₖ‖₂ as above.
sol = group_lasso(X, y, λ, groups)
println("orthonormal groups: selected variables ", sol.activegroups, " in $(sol.iterations) passes")
sol_none = group_lasso(X, y, λ, groups; standardize = :none)
println("columns as given: selected variables ", sol_none.activegroups)
Xs = copy(X); Xs[:, 2] .*= 10
@printf("scaling column 2 by 10 divides its coefficient by %.2f\n", sol.x[2] / group_lasso(Xs, y, λ, groups).x[2])orthonormal groups: selected variables [1, 2, 3] in 10 passes
columns as given: selected variables [1, 2, 3]
scaling column 2 by 10 divides its coefficient by 10.00The path from λmax, with the group strong rule, and λ chosen on the validation set
path = GroupLassoPath(X[train, :], y[train], groups; nλ = 30)
errs = [mean(abs2, y[valid] .- path.β0path[k] .- X[valid, :] * path.βpath[k]) for k in eachindex(path.λpath)]
k = argmin(errs)
@printf("best λ = %.4f: variables %s, validation error %.4f\n", path.λpath[k], path.activegroups[k], errs[k])best λ = 0.0422: variables [1, 2, 3, 6, 7, 15, 25, 26], validation error 0.3123