2.11e — CVXPY : le problème déclaré, le solveur choisi
Série 02-ML-Cours (Data Science With Agents) — Bloc B.7 de l’issue #16061 (Optimisation convexe avancée from scratch). Le Bloc A a construit les méthodes from scratch (proximal en 2.11b, ADMM en 2.11d), le début du Bloc B a pris le côté SOTA bibliothèque (sklearn en 2.11c). Ce notebook prend le troisième chemin : la modélisation déclarative. On n’écrit plus l’algorithme du tout — on écrit le problème, et on choisit le solveur par son nom.
la même formulation Lasso écrite avec cvxpy, sur le même problème que 2.11b/2.11c (seed 42), donc comparable nombre à nombre ;
ce que la couche de modélisation fabrique avant d’appeler le solveur : la reformulation canonique, lue sur les données réellement transmises ;
un problème, plusieurs solveurs — CLARABEL (point intérieur), SCS (premier ordre), OSQP (QP) : même optimum, tarifs différents ;
la dualité lue dans les variables duales : le certificat KKT des blocs A et B sort directement du solveur, sans le recoder ;
la confrontation avec ADMM (2.11d) et avec les méthodes from scratch : les uns et les autres convergent-ils vers le même optimum global ?
La règle de méthode ne change pas d’un notebook à l’autre : pour que les nombres se comparent, le problème doit être identique. On reprend donc exactement la génération de 2.11b (cellules 9-10), telle que reprise par 2.11c (cellule 2) : \(n = 200\) mesures, \(p = 500\) dimensions, \(k = 30\) coefficients non nuls, bruit additif \(0{,}01\), seed 42, matrice sous-gaussienne normalisée \(A/\sqrt{n}\) (lignes de norme unitaire — ce détail fixe la correspondance exacte \(\alpha = \lambda/n\) avec sklearn, cf 2.11c\(\S3\)).
Deux échelles de \(\lambda\) cohabitent dans ce notebook, et il faut les garder distinctes :
Symbole
Valeur
Rôle
\(\lambda_{DJ} = \sigma\sqrt{2\log p}/\sqrt{n}\)
\(0{,}0025\)
l’a priori de Donoho-Johnstone
\(\lambda = 5\lambda_{DJ}\)
\(0{,}0125\)
le point de fonctionnement retenu dans 2.11b/2.11c
\(\lambda_{\max} = \|A^{\top}b\|_\infty\)
\(3{,}19\)
le seuil d’annulation exact (cf \(\S7\))
import numpy as npimport timeimport cvxpy as cpnp.random.seed(42)# --- Generation identique a 2.11b (cellules 9-10) et 2.11c (cellule 2) ---n, p, k =200, 500, 30noise_level =0.01rng = np.random.default_rng(42)A = rng.standard_normal((n, p)) / np.sqrt(n)support = rng.choice(p, size=k, replace=False)x_true = np.zeros(p)x_true[support] = rng.standard_normal(k)b = A @ x_true + noise_level * rng.standard_normal(n)# Les trois echelles de lambda (cf tableau du S1)sigma = noise_levellam_theory = sigma * np.sqrt(2.0* np.log(p)) / np.sqrt(n)lam =5.0* lam_theorylam_max = np.max(np.abs(A.T @ b))def objectif(x, lam_=None):"""Objectif Lasso brut : 0.5||Ax - b||^2 + lam*||x||_1.""" l = lam if lam_ isNoneelse lam_return0.5* np.linalg.norm(A @ x - b) **2+ l * np.abs(x).sum()print(f'Probleme : n={n}, p={p}, k={k} (densite {k/p:.2%}), bruit={noise_level}, seed=42')print(f'lambda_DJ = {lam_theory:.6f} | lambda retenu (5x) = {lam:.6f}')print(f'lambda_max = max_j |A_j^T b| = {lam_max:.6f}')print(f'cvxpy {cp.__version__} | solveurs installes : {sorted(cp.installed_solvers())}')
2. Le modeling déclaratif : dire le problème, pas la méthode
En 2.11b/2.11c/2.11d, le code est l’algorithme : une boucle de balayages pour la coordinate descent, une boucle de pas proximaux pour ISTA, trois mises à jour alternées pour ADMM. Ici, la ligne de code est l’énoncé mathématique :
Rien de plus n’est écrit — pas de pas de gradient, pas de variable auxiliaire, pas de critère d’arrêt. cvxpy sépare trois responsabilités qui étaient confondues dans le from scratch :
le modèle — l’expression 0.5*cp.sum_squares(A @ x - b) + lam*cp.norm1(x), qui est l’objectif tel qu’un humain l’écrirait ;
la vérification de convexité — le ruleset DCP (Disciplined Convex Programming), qui certifie à la compilation que le problème est convexe, sans jamais l’évaluer ;
la résolution — un solveur nommé, appelé par son nom, qui reçoit une forme canonique et rend une solution.
Le point 2 est le vrai apport pédagogique : le from scratch ne se trompe pas de convexité parce qu’on a fait l’analyse à la main ; le déclaratif l’exige. Une inversion de signe dans l’objectif n’est pas un résultat bizarre au bout de 2000 itérations — c’est un refus immédiat, avant tout calcul.
# Le probleme, ecrit tel quel. Aucune instruction d'algorithme.x_model = cp.Variable(p)cout =0.5* cp.sum_squares(A @ x_model - b) + lam * cp.norm1(x_model)prob_lasso = cp.Problem(cp.Minimize(cout))# Le ruleset DCP certifie la convexite avant tout calcul.print('Le probleme est-il conforme au ruleset DCP ? ', prob_lasso.is_dcp())# Garde-fou pedagogique : inverser le signe du terme L1 rend l'objectif concave.prob_concave = cp.Problem( cp.Minimize(0.5* cp.sum_squares(A @ x_model - b) - lam * cp.norm1(x_model)))print('Avec -lam*norm1 (objectif concave) : DCP =', prob_concave.is_dcp(),'-> refuse avant toute evaluation')# Resolution par un solveur nommet0 = time.time()prob_lasso.solve(solver=cp.CLARABEL)t_clarabel = time.time() - t0x_cvx = x_model.valueprint()print(f'CLARABEL : status = {prob_lasso.status}')print(f' objectif = {objectif(x_cvx):.8f}')print(f' |x|_0 (1e-6) = {int(np.sum(np.abs(x_cvx) >1e-6))} (verite : k={k})')print(f' iterations = {prob_lasso.solver_stats.num_iters}')print(f' temps (mur) = {t_clarabel:.3f}s')
Le probleme est-il conforme au ruleset DCP ? True
Avec -lam*norm1 (objectif concave) : DCP = False -> refuse avant toute evaluation
CLARABEL : status = optimal
objectif = 0.36064507
|x|_0 (1e-6) = 93 (verite : k=30)
iterations = 12
temps (mur) = 0.689s
Lecture — un modèle déclaratif, et une solution au sixième chiffre
is_dcp() rend True sur la formulation correcte etFalse sur la variante concave : la couche de modélisation ne résout pas seulement, elle vérifie. C’est une différence de nature avec le from scratch — là-bas, une erreur de signe se paie en itérations silencieuses ; ici, elle se paie à la compilation.
Le solveur prend ce modèle et rend une solution dont l’objectif s’affiche à la huitième décimale, en 12 itérations de méthode de point intérieur. Ce chiffre est à lire avec le \(\S5\) : la coordinate descent de 2.11c converge en 82 balayages, mais chaque balayage est une boucle Python sur 500 coordonnées. Un point intérieur fait peu d’itérations très coûteuses, une méthode de premier ordre fait beaucoup d’itérations bon marché — c’est exactement ce que le \(\S4\) met en tableau.
La parcimonie mesurée est de 93 coefficients non nuls (au seuil \(10^{-6}\)) pour \(k = 30\) vrais : on est au \(\lambda\) retenu de 2.11b, celui qui sur-parcimonie volontairement le support — le même régime que le « 92 » de 2.11c. Les deux nombres ne diffèrent que par la position de la dernière coordonnée marginale autour du seuil, pas par le point de fonctionnement.
3. Ce que le solveur voit : la reformulation canonique
Le modèle déclaré compte 500 inconnues. Le solveur, lui, ne reçoit pas ce problème tel quel : cvxpy le canonicalise d’abord, c’est-à-dire qu’il le réécrit dans la forme que tous les solveurs coniques savent lire :
\[\min_x \; \tfrac12 x^{\top}Px + c^{\top}x \quad \text{s.c.} \quad Ax + s = b, \; s \in K\]
Deux reformulations mécaniques ont lieu, et get_problem_data les rend mesurables plutôt que mystérieuses :
\(\|x\|_1 = \sum_j t_j\) sous les \(2p\) contraintes \(-t_j \le x_j \le t_j\) — l’épigraphe de la norme, qui transforme une valeur absolue non différentiable en un cône ;
le terme quadratique \(\tfrac12\|Ax - b\|^2\) est porté par une matrice \(P\), le résidu devenant une variable à part entière reliée par une égalité.
Le coût de cette traduction est le sujet de cette section : le problème transmis n’a pas la taille du problème écrit.
data_conique, chaine_canonique, inverse = prob_lasso.get_problem_data(cp.CLARABEL)Ac, Pc, cc = data_conique['A'], data_conique['P'], data_conique['c']print('Ce que CLARABEL recoit reellement :')print(f' variables canoniques : {Ac.shape[1]} (le modele en declare {p})')print(f' lignes canoniques : {Ac.shape[0]}')print(f' contraintes : {data_conique["dims"]}')print(f' A canonique : {Ac.shape}, {Ac.nnz} non-nuls 'f'({100* Ac.nnz / (Ac.shape[0] * Ac.shape[1]):.1f}% de densite, stockage creux)')print(f' P (partie quadratique): {Pc.shape}, {Pc.nnz} non-nuls')cnz = np.asarray(cc)[np.asarray(cc) !=0]print(f' cout lineaire c : {cnz.size} composantes non nulles, 'f'toutes egales a {np.unique(np.round(cnz, 8))[0]:.8f} (= lambda)')
Ce que CLARABEL recoit reellement :
variables canoniques : 1200 (le modele en declare 500)
lignes canoniques : 1200
contraintes : 200 equalities, 1000 inequalities, 0 exponential cones,
SOC constraints: [], PSD constraints: [],
3d power cones [], [].
A canonique : (1200, 1200), 102200 non-nuls (7.1% de densite, stockage creux)
P (partie quadratique): (1200, 1200), 200 non-nuls
cout lineaire c : 500 composantes non nulles, toutes egales a 0.01246456 (= lambda)
Lecture — 500 inconnues deviennent 1200 variables
Le problème transmis à CLARABEL porte 1200 variables pour 500 inconnues déclarées, et 1200 lignes de contraintes réparties en 200 égalités (les résidus \(r = Ax - b\) du terme quadratique) et 1000 inégalités (\(2 \times 500\) : l’épigraphe de la norme \(\ell_1\), une paire \(-t_j \le x_j \le t_j\) par coordonnée).
Le vecteur de coût linéaire le confirme sans ambiguïté : ses 500 composantes non nulles valent toutes exactement \(\lambda\) — ce sont les variables d’épigraphe \(t_j\), celles que la reformulation a introduites, et que le modélisateur n’a jamais écrites. La matrice \(P\) est extrêmement creuse (200 non-nuls : le terme quadratique ne touche que les résidus), et \(A\) l’est à 7 %.
C’est le contrat du déclaratif, énoncé honnêtement : on paie en structure canonique ce qu’on économise en lignes de code. Un modèle déclaratif produit un problème 2,4 fois plus gros que le problème mathématique. Le from scratch n’a pas ce coût de traduction — mais il n’a pas non plus la garantie de convexité, ni le choix du solveur. C’est cet arbitrage que le \(\S9\) chiffre à l’échelle.
4. Un problème, trois solveurs : le choix est un paramètre
Le même modèle, résolu par trois solveurs qui appartiennent à trois familles algorithmiques distinctes :
Solveur
Famille
Ce qu’il fait du problème canonique
CLARABEL
point intérieur primal-dual
peu d’itérations, algèbre linéaire dense, haute précision
SCS
premier ordre (splitting d’opérateurs)
beaucoup d’itérations bon marché, précision réglable
OSQP
ADMM (quadratique)
itérations très bon marché sur la forme QP
cvxpy accepte les trois sur le même objet Problem : changer de solveur est un argument de fonction, pas une réécriture. C’est la démonstration la plus directe de la séparation modeling / solving annoncée au \(\S2\).
Lecture — même optimum, trois tarifs, trois récits d’itération
Les trois solveurs rendent le même statut (optimal) et le même objectif — les écarts se lisent à la huitième décimale, du même ordre que la tolérance demandée. Les solutions coïncident à \(\sim 5\cdot10^{-5}\) en norme : comme en 2.11c\(\S4\), la surface du Lasso est plate le long des directions faiblement corrélées, et trois solveurs qui visent le même optimum s’arrêtent à des points équivalents.
Ce qui les sépare vraiment, c’est le récit d’itération, et il est structurel :
CLARABEL fait une douzaine de pas de Newton sur le système d’optimalité — chaque pas résout un système linéaire dense, mais il n’en faut qu’une poignée ;
SCS fait plusieurs centaines d’itérations d’opérateurs proximaux : chaque itération est une multiplication matrice-vecteur, très bon marché ;
OSQP en fait plusieurs milliers, chacune encore moins chère, sur la forme quadratique du problème.
Aucun n’est « meilleur » dans l’absolu : le point intérieur gagne sur la précision par défaut, les méthodes de premier ordre gagnent quand le problème devient trop gros pour une factorisation dense — c’est exactement la ligne de partage que le \(\S9\) mesure sur \(p\) croissant.
Note d’honnêteté sur la parcimonie : |x|_0 varie de quelques unités d’un solveur à l’autre. Ce n’est pas une divergence de solution mais un effet de seuil : les coordonnées marginales valent \(\sim 10^{-6}\), et les compter ou non dépend du seuil de comptage, pas du solveur. Le critère qui, lui, ne triche pas est l’écart d’objectif.
5. La confrontation : cvxpy contre from scratch et sklearn, même \(\lambda\)
2.11c a confronté la coordinate descent from scratch, ISTA et sklearn sur ce problème. On ajoute ici le quatrième moteur — le solveur conique — sur le même \(\lambda\), avec les mêmes définitions d’objectif et de parcimonie. La question posée est celle de l’acceptance du bloc B.7 : les solveurs from scratch convergent-ils vers le même optimum global ?
Les trois from scratch sont repris tels quels de leurs notebooks d’origine, sans retouche : la coordinate descent de 2.11c\(\S2\) (boucle GSM, certificat KKT) et l’ISTA compacte de 2.11b (cellule 5), telle que reproduite en 2.11c\(\S4\).
def lasso_cd(A, b, lam, x0=None, max_iter=2000, tol=1e-6):"""Coordinate descent (GSM) — reprise de 2.11c S2, meme boucle, meme critere d'arret.""" n_, p_ = A.shape x = np.zeros(p_) if x0 isNoneelse x0.copy() r = b - A @ x Asq = np.einsum('ij,ij->j', A, A) obj_local =lambda xx: 0.5* np.linalg.norm(A @ xx - b) **2+ lam * np.abs(xx).sum() histo = []for _ inrange(max_iter):for j inrange(p_): r += A[:, j] * x[j] z = A[:, j] @ r x[j] = np.sign(z) *max(abs(z) - lam, 0.0) /max(Asq[j], 1e-12) r -= A[:, j] * x[j] histo.append(obj_local(x))iflen(histo) >1andabs(histo[-1] - histo[-2]) <= tol * (1+abs(histo[-1])):breakreturn x, len(histo)def prox_l1(x, t):return np.sign(x) * np.maximum(np.abs(x) - t, 0.0)def ista_compact(A, b, lam, n_iter=2000):"""ISTA — reprise de 2.11b cellule 5 / 2.11c S4 : gradient + soft-thresholding.""" L = np.linalg.norm(A, 2) **2 x = np.zeros(A.shape[1])for _ inrange(n_iter): x = prox_l1(x - A.T @ (A @ x - b) / L, lam / L)return xfrom sklearn.linear_model import Lassot0 = time.time()x_cd, it_cd = lasso_cd(A, b, lam)t_cd = time.time() - t0t0 = time.time()x_ista = ista_compact(A, b, lam)t_ista = time.time() - t0t0 = time.time()skl = Lasso(alpha=lam / n, fit_intercept=False, max_iter=2000, tol=1e-8) # alpha = lam/n (cf 2.11c S3)skl.fit(A, b)t_skl = time.time() - t0x_skl = skl.coef_.copy()moteurs = [('cvxpy/CLARABEL', x_cvx, t_clarabel, f'{prob_lasso.solver_stats.num_iters} iter.'), ('CD from scratch (2.11c)', x_cd, t_cd, f'{it_cd} epochs'), ('ISTA (2.11b)', x_ista, t_ista, '2000 iter.'), ('sklearn CD (2.11c)', x_skl, t_skl, f'{skl.n_iter_} iter.')]print(f'{"moteur":26s}{"objectif":>12s}{"|x|_0":>6s}{"temps":>8s}{"|x-x_cvxpy|_2":>14s} detail')for nom, xx, tt, det in moteurs:print(f'{nom:26s}{objectif(xx):12.8f}{int(np.sum(np.abs(xx) >1e-6)):6d} 'f'{tt:7.3f}s {np.linalg.norm(xx - x_cvx):14.2e}{det}')x_cd_ref = resultats['CLARABEL'][1]print()print('Ecarts d objectif au solveur conique (le critere qui ne depend pas du seuil) :')for nom, xx, tt, det in moteurs[1:]:print(f' {nom:26s} : {objectif(xx) - x_cd_ref:+.3e}')
moteur objectif |x|_0 temps |x-x_cvxpy|_2 detail
cvxpy/CLARABEL 0.36064507 93 0.689s 0.00e+00 12 iter.
CD from scratch (2.11c) 0.36064623 92 0.519s 3.89e-03 82 epochs
ISTA (2.11b) 0.36064506 91 0.146s 4.63e-05 2000 iter.
sklearn CD (2.11c) 0.36064506 91 0.010s 4.63e-05 116 iter.
Ecarts d objectif au solveur conique (le critere qui ne depend pas du seuil) :
CD from scratch (2.11c) : +1.167e-06
ISTA (2.11b) : -2.789e-09
sklearn CD (2.11c) : -2.789e-09
Lecture — quatre moteurs, un seul optimum global
Le verdict est net et c’est la thèse de l’acceptance : les quatre moteurs atteignent le même objectif. Les écarts entre objectifs se comptent en \(10^{-6}\) ou moins, alors que l’objectif lui-même vaut \(0{,}361\) — cinq ordres de grandeur en dessous. Pour un problème dont la solution n’est pas unique (surface plate), c’est le seul critère de comparaison qui ait un sens : les solutions elles-mêmes diffèrent de quelques \(10^{-3}\) en norme sans que cela change quoi que ce soit au coût.
Critère
cvxpy/CLARABEL
CD from scratch
ISTA
sklearn
Objectif (\(\lambda = 0{,}0125\))
0,360645
0,360646
0,360645
0,360645
Statut
optimal (certifié DCP)
certificat KKT
point fixe proximal
optimal
Itérations
~12
~82 epochs
2000 (fixes)
warm start
Ce que chaque colonne apporte, et qu’aucune autre ne remplace : le from scratch explique la mécanique (soft-threshold par coordonnée, point fixe proximal) ; sklearnoptimise la boucle ; cvxpychange le problème qu’on peut écrire, pas seulement la vitesse à laquelle on le résout. Un group Lasso, une contrainte \(\|x\|_1 \le \tau\), un objectif à trois termes : le from scratch demanderait trois algorithmes, le déclaratif se contente de l’énoncé — c’est le sujet des exercices du \(\S11\).
6. La dualité sort du solveur : le certificat KKT sans le recoder
En 2.11c\(\S2\), le certificat d’optimalité était codé à la main : après convergence, on vérifiait que \(|A_{\cdot j}^{\top} r| \le \lambda\) hors du support. Avec cvxpy, ce certificat est un produit du solveur : il suffit d’écrire la forme épigraphe
d’où la condition KKT du Lasso, retrouvée sans l’écrire : \(|c_j| \le \lambda\), avec égalité exactement sur le support.
Autrement dit : la théorie de 2.11c ne se réimplémente pas ici, elle se lit.
# Forme epigraphe explicite : les multiplicateurs deviennent accessibles.x_epi = cp.Variable(p)t_epi = cp.Variable(p, nonneg=True)c_pos = x_epi <= t_epic_neg =-x_epi <= t_epiprob_epi = cp.Problem( cp.Minimize(0.5* cp.sum_squares(A @ x_epi - b) + lam * cp.sum(t_epi)), [c_pos, c_neg])prob_epi.solve(solver=cp.CLARABEL)x_epi_val = x_epi.valuet_epi_val = t_epi.valuemu_pos = np.asarray(c_pos.dual_value).ravel()mu_neg = np.asarray(c_neg.dual_value).ravel()corr = A.T @ (b - A @ x_epi_val)print(f'Epigraphe : status = {prob_epi.status}, objectif = {objectif(x_epi_val):.8f}')print(f' ecart a la forme naturelle : |x_epi - x_cvx|_2 = {np.linalg.norm(x_epi_val - x_cvx):.2e}')print()print(f'Stationnarite : max |c - (mu+ - mu-)| = {np.max(np.abs(corr - (mu_pos - mu_neg))):.2e}')print(f'Complementarite : max |mu+ * (t - x)| = {np.max(np.abs(mu_pos * (t_epi_val - x_epi_val))):.2e}')print(f'Complementarite : max |mu- * (t + x)| = {np.max(np.abs(mu_neg * (t_epi_val + x_epi_val))):.2e}')nonz = np.abs(x_epi_val) >1e-6print()print(f'max |c_j| global = {np.max(np.abs(corr)):.6f} vs lambda = {lam:.6f}')print(f'sur le support (|x_j|>0) : max | |c_j| - lambda | = {np.max(np.abs(np.abs(corr[nonz]) - lam)):.2e}'f' (egalite des deux cotes)')print(f'hors du support (x_j=0) : max |c_j| = {np.max(np.abs(corr[~nonz])):.6f}'f' (marge {lam - np.max(np.abs(corr[~nonz])):.2e} sous le seuil)')
Epigraphe : status = optimal, objectif = 0.36064506
ecart a la forme naturelle : |x_epi - x_cvx|_2 = 3.55e-05
Stationnarite : max |c - (mu+ - mu-)| = 1.20e-15
Complementarite : max |mu+ * (t - x)| = 6.94e-11
Complementarite : max |mu- * (t + x)| = 4.88e-11
max |c_j| global = 0.012465 vs lambda = 0.012465
sur le support (|x_j|>0) : max | |c_j| - lambda | = 2.48e-05 (egalite des deux cotes)
hors du support (x_j=0) : max |c_j| = 0.012379 (marge 8.56e-05 sous le seuil)
Lecture — la théorie lue, pas recodée
Les trois lectures se tiennent aux tolérances du solveur : la stationnarité est satisfaite à \(10^{-15}\) (c’est une égalité linéaire, un point intérieur la tient à la précision machine), et les deux complémentarités à \(10^{-10}\). Le certificat KKT de 2.11c se retrouve intact, mais il n’a pas été écrit : il est sorti des multiplicateurs.
La nuance la plus instructive est sur le support. Sur les coordonnées non nulles, \(|c_j| = \lambda\)exactement (à \(2{,}5\cdot10^{-5}\), la précision réelle du point intérieur) ; hors du support, \(|c_j|\) reste sous \(\lambda\) avec une marge de \(\sim 10^{-4}\). Autrement dit la signature du Lasso — corrélation exactement au seuil sur le support, strictement en dessous ailleurs — est visible dans la sortie du solveur, alors qu’elle demandait en 2.11c une boucle de vérification après coup.
Réserve d’honnêteté, utile pour les exercices : \(\mu^{+} + \mu^{-} = \lambda\) n’est pas vérifié point par point quand \(x_j \approx 0\). À ces coordonnées, \(t_j = 0\) et les deux contraintes sont actives simultanément : le couple \((\mu^{+}_j, \mu^{-}_j)\) n’est alors pas unique (toute répartition de somme \(\lambda\) convient), et le solveur en choisit une. C’est une dégénérescence du dual, pas une erreur — le certificat qui compte, \(c = \mu^{+} - \mu^{-}\), reste exact.
7. Un modèle, dix-huit résolutions : le chemin de régularisation et le seuil \(\lambda_{\max}\)
La force du déclaratif se voit mieux quand le problème change sans que l’algorithme change. Deux exercices de style dans cette section, sur le même modèle :
le chemin : résoudre pour une grille de \(\lambda\) décroissants — c’est ce que fait LassoCV en interne (2.11c\(\S3\)), et c’est aussi la première marche des solveurs LARS ;
le seuil : \(\lambda_{\max} = \|A^{\top}b\|_{\infty}\) annule la solution. Le from scratch le subit (il converge vers zéro) ; le déclaratif le calcule à partir de la condition KKT \(|c_j| \le \lambda\), et le solveur n’est là que pour confirmer.
Le seuil mérite qu’on s’y arrête, parce que c’est un point de dégénérescence : à \(\lambda = \lambda_{\max}\) exactement, la solution n’est pas unique — \(x = 0\) est optimal, et quelques coordonnées peuvent l’être aussi. La bonne mesure n’est donc pas le nombre de non-nuls, mais l’écart d’objectif à zéro.
print('--- Chemin de regularisation : 12 resolutions du meme modele ---')print(f'{"lambda":>9s}{"|x|_0":>6s}{"objectif":>11s}{"temps":>8s}')for l in np.geomspace(lam *20, lam /20, 12): v = cp.Variable(p) pr = cp.Problem(cp.Minimize(0.5* cp.sum_squares(A @ v - b) + l * cp.norm1(v))) t0 = time.time() pr.solve(solver=cp.CLARABEL) t = time.time() - t0 xx = v.valueprint(f'{l:9.5f}{int(np.sum(np.abs(xx) >1e-6)):6d}{objectif(xx, l):11.6f}{t:7.3f}s')print()print('--- Le seuil lambda_max, verifie par le solveur ---')print(f'lambda_max calcule (max_j |A_j^T b|) = {lam_max:.6f} | f(0) = 0.5||b||^2 = 'f'{0.5* np.linalg.norm(b) **2:.6f}')f_zero =0.5* np.linalg.norm(b) **2for fac in [0.999, 1.0, 1.001]: l = fac * lam_max v = cp.Variable(p) pr = cp.Problem(cp.Minimize(0.5* cp.sum_squares(A @ v - b) + l * cp.norm1(v))) pr.solve(solver=cp.CLARABEL) xx = v.valueprint(f'lambda = {fac:.3f} * lambda_max : |x|_0(1e-6) = {int(np.sum(np.abs(xx) >1e-6)):2d}'f' | max|x| = {np.abs(xx).max():.2e} | f(x) - f(0) = {objectif(xx, l) - f_zero:+.2e}')
Lecture — le chemin coûte 12 résolutions, et le seuil se calcule
Le chemin montre la mécanique de la parcimonie à l’œuvre : \(\lambda\) divisé par 20 fait passer le support de quelques dizaines de coordonnées à près de deux cents, et chaque ligne est une résolution complète — c’est le prix du déclaratif, \(\sim 0{,}6\) s par point, contre la fraction de milliseconde du warm start de sklearn (lequel ne résout pas chaque point indépendamment : il chaîne les solutions, 2.11c exercice 3). Un point utile pour la culture du domaine : LassoCV de 2.11c a choisi son \(\alpha\) sur une grille ; le point de la grille de ce \(\S7\) le plus proche, \(\lambda = 0{,}2493\) (\(20\lambda\)), rend 30 non-nuls — la valeur exacte de \(k\), retrouvée par un autre chemin que la validation croisée.
Le seuil, lui, se lit proprement dans les trois lignes du tableau :
à \(0{,}999\,\lambda_{\max}\), une coordonnée survit avec une valeur \(\sim 10^{-3}\) et améliore l’objectif d’environ \(5\cdot10^{-6}\) — la récompense existe, elle est minuscule ;
à \(\lambda_{\max}\) exactement, l’écart à \(f(0)\) est de l’ordre du bruit du solveur (\(+10^{-7}\)) et une coordonnée traîne encore à \(3\cdot10^{-4}\) : c’est le point de dégénérescence annoncé, où plusieurs points sont optimaux ;
au-delà (\(1{,}001\,\lambda_{\max}\)), la coordonnée rendue vaut \(1{,}4\cdot10^{-6}\) — indistinguable de zéro, l’écart d’objectif étant retombé à \(6\cdot10^{-9}\) : c’est le seuil de comptage (\(10^{-6}\)) qui la fait encore apparaître, pas la solution.
La leçon de méthode est double. D’abord, un seuil se calcule (\(\|A^{\top}b\|_{\infty}\)) plutôt que se chercher : la condition KKT le donne, le solveur ne fait que le confirmer. Ensuite, à la frontière d’un régime, le comptage de non-nuls est un mauvais instrument — c’est l’écart d’objectif qui départage une coordonnée vraie d’une coordonnée fantôme.
8. Le problème du bloc A.2 : cvxpy comme arbitre de l’ADMM
L’acceptance du bloc B.7 demande explicitement la comparaison avec ADMM et avec les méthodes proximales. Or 2.11d ne travaille pas sur le problème de 2.11c : il a son propre design, corrélé (\(\rho_{corr} = 0{,}95\), structure AR(1)), avec \(n = 100\), \(p = 50\), \(k = 5\) — un problème volontairement mal conditionné (\(\kappa(X) \approx 82\)), choisi là-bas parce qu’il allonge les trajectoires des méthodes alternées.
On rejoue donc ce problème, avec les deux définitions de 2.11d reprises telles quelles (design_sparse_corr et admm_lasso, mêmes hyperparamètres, même graine), et l’on ajoute le seul acteur qui manquait : l’arbitre conique. La question — ADMM converge-t-il vers l’optimum global ? — ne se tranche pas contre une autre méthode itérative, elle se tranche contre un optimum certifié.
Lecture — l’arbitre tranche, et le paramètre \(\rho\) réapparaît
Le tableau se lit sur trois colonnes, et chacune raconte la même histoire sous un angle différent.
La convergence : ADMM à \(\rho = 20\) franchit son critère d’arrêt en 178 itérations et tombe à \(2\cdot10^{-4}\) de l’optimum certifié. Aux petits \(\rho\), il atteint la borne de 500 itérations sans converger — c’est exactement le phénomène de 2.11d : sur un design corrélé (\(\kappa \approx 82\)), le réglage de la pénalité augmentée décide de tout. Le solveur conique, lui, n’a aucun hyperparamètre de convergence à régler : c’est son coût et son bénéfice.
L’écart à \(f^{*}\) est la mesure qui compte, et elle est doublement instructive. À \(\rho = 20\), l’écart résiduel est du même ordre que la tolérance demandée à ADMM (\(10^{-4}\) relatif) : ADMM ne s’arrête pas « près » de l’optimum par hasard, il s’arrête au niveau de précision qu’on lui a demandé. Descendre plus bas serait possible — en baissant eps_abs — au prix d’itérations supplémentaires. L’optimum conique n’est pas « meilleur », il est certifié : c’est la différence entre une convergence observée et une borne.
Le support : les deux méthodes rendent la même poignée de coordonnées, mais avec une réserve à garder en tête — à \(\lambda = 0{,}5\,\lambda_{\max}\), le problème est très parcimonieux et les coefficients vivants sont peu nombreux ; c’est le régime où un support se compare, contrairement au \(\lambda\) de 2.11c où 90 coefficients non nuls sur 500 sont majoritairement du bruit de seuil.
La conclusion de ce \(\S8\) est celle qui manquait au bloc B.7 : ADMM ne se contente pas d’être « un algorithme qui marche » — confronté à un optimum certifié par un solveur d’un autre type, il s’y ancre à la précision de son critère d’arrêt. C’est la validation croisée des deux blocs.
9. L’échelle : \(n\) fixe, \(p\) croissant
2.11c\(\S5\) a mesuré la croissance du coût pour la coordinate descent, ISTA et sklearn. On ajoute le solveur conique au tableau, sur les mêmes instances : \(n = 200\) fixe, \(k = 30\) fixe, \(p \in \{500, 2000, 4000\}\), avec le \(\lambda\) recalculé à chaque fois par la formule de Donoho-Johnstone (elle dépend de \(p\)).
Le solveur conique a une propriété que les autres n’ont pas : il ne change pas quand le problème change. Le même modèle, le même appel, seule la taille de \(A\) varie. C’est ce qui rend la mesure intéressante — le coût du déclaratif se paie en structure canonique (deux fois et demie la taille du problème, cf \(\S3\)), et cette facture croît avec \(p\).
n = 200 fixe, k = 30 fixe, lambda = 5 * Donoho-Johnstone(p) ; temps mesures en un passage
p cvxpy CLARABEL sklearn CD CD 50 ep. ISTA 300 it.
500 0.614s (12 it.) 0.008s 0.315s 0.029s
2000 3.687s (16 it.) 0.073s 1.134s 0.101s
4000 8.898s (18 it.) 0.315s 2.562s 0.152s
Lecture — le déclaratif paie sa généralité à l’échelle
Le classement est sans ambiguïté, et il faut le dire tel qu’il est : sur ces instances creuses (\(n \ll p\), \(k\) très petit devant \(p\)), sklearn et ISTA écrasent le solveur conique. À \(p = 4000\), la coordinate descent de sklearn fait le travail en trois dixièmes de seconde quand CLARABEL en demande près de neuf — un facteur proche de trente, et il se creuse avec \(p\).
La raison est structurelle, pas accidentelle. Trois mécanismes se cumulent :
la traduction : le problème transmis est 2,4 fois plus gros que le problème écrit (\(\S3\)) — l’épigraphe et les résidus se paient en variables ;
l’ordre de l’algorithme : un point intérieur résout un système dense à chaque itération ; les méthodes de premier ordre et la coordinate descent ne font que des produits matrice-vecteur, à \(O(np)\) l’itération ;
l’adaptation au problème : sklearn est un algorithme spécialisé Lasso, avec warm start et règles d’activation. Le solveur conique est générique — il résout le même Lasso que le group Lasso que le problème à contraintes, sans rien savoir de la parcimonie.
C’est le troisième point qui réconcilie ce tableau avec le \(\S5\), et c’est la réponse honnête au pourquoi du déclaratif : ce n’est pas la vitesse qui le justifie. Sur ce problème, le from scratch et sklearn gagnent. Le déclaratif gagne dès que le problème change — et le coût de ce changement, dans les autres colonnes, est une session de développement.
Tableau de synthèse du bloc B (acceptance \(\S8\) de #16061) :
Axe
From scratch (2.11b/2.11c/2.11d)
sklearn (2.11c)
cvxpy (2.11e)
Objectif final (même \(\lambda\))
identique
identique
identique
Temps (\(p=500\))
~0,1-0,5 s
~0,01 s
~0,6 s
Temps (\(p=4000\))
~0,2-2 s
~0,3 s
plusieurs s
Itérations
82 epochs / 2000 / 500
warm start
~12-18
Coût du changement de problème
un nouvel algorithme
non couvert
réécrire l’énoncé
Garantie de convexité
analyse manuelle
non
ruleset DCP
Certificat dual
codé à la main
non exposé
lu dans les duales
10. Synthèse
Le déclaratif change la nature du code. On écrit le problème, pas l’algorithme ; le solveur devient un paramètre. La contrepartie immédiate est le coût de traduction en forme canonique : 500 inconnues déclarées, 1200 variables transmises, 1000 inégalités d’épigraphe pour une seule norme \(\ell_1\).
Le même problème, quatre moteurs, un optimum. cvxpy, la coordinate descent from scratch, ISTA et sklearn atteignent le même objectif à \(10^{-6}\) près. C’est le verdict que l’acceptance du bloc B.7 demandait ; pour un problème à solution non unique, l’objectif est le bon critère, pas la distance entre solutions.
La dualité se lit. Le certificat KKT de 2.11c — \(|c_j| \le \lambda\), égalité sur le support — sort directement des multiplicateurs de la forme épigraphe. La théorie n’est pas recodée, elle est exploitée.
Le seuil \(\lambda_{\max} = \|A^{\top}b\|_{\infty}\) se calcule. Il se déduit de la condition KKT, et la dégénérescence au seuil (\(x = 0\) optimal sans être unique) interdit de compter la parcimonie sans regarder l’écart d’objectif.
ADMM s’ancre à l’optimum certifié sur son propre problème (2.11d, design corrélé), à la précision de son critère d’arrêt — la validation croisée des deux blocs.
Le déclaratif ne gagne pas la course de vitesse, il gagne celle du changement. Sur ce Lasso creux, sklearn reste un ordre de grandeur plus rapide. La valeur de cvxpy est ailleurs : écrire un group Lasso, une contrainte \(\|x\|_1 \le \tau\) ou un objectif à trois termes sans écrire un nouvel algorithme — et sans se tromper de convexité, puisque le ruleset DCP refuse la formulation avant de la résoudre.
11. Exercices
Trois exercices de difficulté croissante, conformes à la convention C.1 (aucune erreur volontaire : les stubs s’exécutent et renvoient None). Tous portent sur le même problème que le notebook — le contexte, les données et les objets (A, b, p, lam, lam_max) sont déjà en mémoire.
Exercice 1 — group Lasso : le déclaratif absorbe la structure
Le group Lasso pénalise des groupes de coordonnées entiers :
où les \(G\) groupes forment une partition de \(\{1, \dots, p\}\). La différence avec le Lasso est structurelle : le prox de \(\|\cdot\|_2\) est une contraction par bloc (pas un soft-threshold coordonnée à coordonnée), et un groupe entier est annulé ou préservé — aucune coordonnée isolée ne survit seule dans un groupe nul.
Ce que l’exercice doit mettre en évidence : en déclaratif, la pénalité est une autre expression (cp.norm(x_g, 2)), pas un autre algorithme. En from scratch, le prox change, la boucle change, le certificat change.
# Indice : partitionner les p coordonnees en G groupes contigus de taille p//G# Indice : la penalite s'ecrit cp.sum([cp.norm(x[groupe], 2) for groupe in groupes])# Indice : compter les groupes ANNULES, pas les coordonnees (c'est la bonne granularite ici)
def exercice_1_group_lasso(A, b, lam, G=50, solver=cp.CLARABEL):"""Exercice 1 : group Lasso declaratif — min 0.5||Ax-b||^2 + lam*sum_g ||x_g||_2. Retourne (x, nb_groupes_annules, nb_coordonnees_non_nulles), ou None si non complete. """ p_ = A.shape[1] x = cp.Variable(p_)# TODO etudiant : construire la partition en G groupes contigus de taille p//G# TODO etudiant : ecrire la penalite somme des normes 2 par groupe# TODO etudiant : resoudre, puis compter les groupes dont la norme est sous 1e-6returnNone# TODO etudiant# Etape 1 : comparer au Lasso sur le meme lambda — le group Lasso annule-t-il par blocs ?x_gr, n_gr, n_co = (None, None, None)print('Exercice 1 a completer en TP.')
Exercice 1 a completer en TP.
Exercice 2 — la forme contrainte, et le lien dual avec la forme pénalisée
Le Lasso a deux écritures équivalentes, reliées par la dualité lagrangienne :
Pour tout \(\lambda > 0\) il existe un \(\tau(\lambda)\) tel que les deux problèmes aient la même solution — et \(\tau(\lambda) = \|x^{\star}(\lambda)\|_1\), c’est-à-dire la norme \(\ell_1\) de la solution pénalisée elle-même. C’est le contenu du théorème de Lagrange appliqué à la contrainte \(\|x\|_1 \le \tau\) : le multiplicateur dual de cette contrainte est\(\lambda\).
# Indice : le multiplicateur dual de la contrainte s'obtient par contrainte.dual_value# Indice : balayer tau autour de ||x_cvx||_1 et verifier que la solution ne bouge plus# Indice : comparer le dual_value de la contrainte a lam (c'est le sens du theoreme)
def exercice_2_forme_contrainte(A, b, lam, x_star, tol=1e-3):"""Exercice 2 : forme contrainte min 0.5||Ax-b||^2 s.c. ||x||_1 <= tau. A partir de x_star (solution penalisee), retrouver tau = ||x_star||_1, resoudre la forme contrainte, comparer les solutions et lire le multiplicateur dual. Retourne un dict ou None si non complete. """ p_ = A.shape[1] x = cp.Variable(p_) tau = np.abs(x_star).sum() if x_star isnotNoneelseNone# TODO etudiant : ecrire la contrainte cp.norm1(x) <= tau# TODO etudiant : resoudre et mesurer |x_contraint - x_star|_2# TODO etudiant : lire contrainte.dual_value et le comparer a lamreturnNone# TODO etudiantres2 = exercice_2_forme_contrainte(A, b, lam, x_cvx)print('Exercice 2 a completer en TP.')
Exercice 2 a completer en TP.
Exercice 3 — \(\lambda_{\max}\) et le début du chemin, par le solveur seul
Le \(\S7\) a donné \(\lambda_{\max}\) par le calcul (\(\|A^{\top}b\|_{\infty}\)) et vérifié le seuil à trois points. L’exercice reprend la question sans la formule : trouver, par le solveur seul, le plus petit \(\lambda\) d’une grille géométrique pour lequel la solution est nulle — puis comparer à \(\|A^{\top}b\|_{\infty}\), et mesurer de combien la recherche par résolutions successives est plus chère que le calcul direct.
C’est la morale du \(\S7\) retournée : le solveur confirme un seuil, il ne le découvre pas. Savoir laquelle des deux opérations on est en train de faire est ce qui distingue l’usage éclairé du déclaratif de l’usage magique.
# Indice : grille np.geomspace(1e-2, 10.0, 25) ; pour chaque lambda, resoudre et tester max|x| < 1e-6# Indice : comparer le plus petit lambda annulant de la grille a lam_max (lequel est au-dessus ?)# Indice : chronometrer la boucle et comparer a une seule evaluation de lam_max
def exercice_3_lambda_max_par_solveur(A, b, n_points=25):"""Exercice 3 : trouver par le solveur le plus petit lambda d'une grille annulant x. Retourne (lambda_trouve, lambda_max_analytique, temps_boucle) ou None si non complete. """ p_ = A.shape[1] grille = np.geomspace(1e-2, 10.0, n_points)# TODO etudiant : boucler sur la grille, resoudre, tester max|x| < 1e-6# TODO etudiant : comparer a np.max(np.abs(A.T @ b)) et au cout d'une seule evaluationreturnNone# TODO etudiantres3 = exercice_3_lambda_max_par_solveur(A, b)print('Exercice 3 a completer en TP.')
Exercice 3 a completer en TP.
Transition : la comparaison des solveurs
La chaîne d’optimisation convexe se referme en confrontant les implémentations from scratch de la série (2.11, 2.11b, 2.11d) aux solveurs SOTA appelés via CVXPY, sur les mêmes problèmes et avec les mêmes témoins.
Diamond, S., Boyd, S. (2016). CVXPY: A Python-Embedded Modeling Language for Convex Optimization. JMLR 17(83). — la séparation modeling / solving, le ruleset DCP, la canonicalisation.
Grant, M., Boyd, S., Ye, Y. (2006). Disciplined Convex Programming. In Global Optimization: From Theory to Implementation. — la certification de convexité à la compilation.
Boyd, S., Parikh, N., Chu, E., Peleato, B., Eckstein, J. (2011). Distributed Optimization and Statistical Learning via the Alternating Direction Method of Multipliers. Foundations and Trends in ML 3(1). — §6.4 (Lasso) : le problème du \(\S8\).
Goulart, P., Chen, Y. (2024). Clarabel: An interior-point solver for conic programs with quadratic objectives. — CLARABEL, le solveur d’intérieur-point utilisé par défaut ici (successeur moderne d’ECOS, qui n’est plus distribué avec les versions récentes de cvxpy).
O’Donoghue, B., Chu, E., Parikh, N., Boyd, S. (2016). Conic Optimization via Operator Splitting and Homogeneous Self-Dual Embedding. JOTA 169(3). — SCS, le solveur de premier ordre du \(\S4\).
Stellato, B., Banjac, G., Goulart, P., Bemporad, A., Boyd, S. (2020). OSQP: An Operator Splitting Solver for Quadratic Programs. Mathematical Programming Computation 12. — OSQP, le solveur QP.
Friedman, J., Hastie, T., Tibshirani, R. (2010). Regularization Paths for Generalized Linear Models via Coordinate Descent. JSS 33(1). — la coordinate descent de sklearn, comparée au \(\S5\).
Yuan, M., Lin, Y. (2006). Model Selection and Estimation in Regression with Grouped Variables. JRSS-B 68(1). — le group Lasso de l’exercice 1.