← All Posts
From Scratch · C++ · ML · Regression · Linear Regression · Part 4

Ridge, Lasso, and Elastic Net in C++

The three solvers so far in this series, OLS, QR/SVD, and gradient descent, all minimize the same unpenalized objective $\lVert y - X_b\beta \rVert_2^2$. They can disagree on how they get there, but when the design is full rank they agree on the answer. That answer has a weakness that has nothing to do with numerical stability: when features are collinear, redundant, or simply numerous relative to the number of rows, the unique minimizer of squared error can have unstable, high-variance coefficients even though the fitted line predicts perfectly well. Regularization changes the objective itself, adding a penalty on the size of $\beta$, trading a small amount of bias for an often much larger reduction in variance. This post implements the three standard penalized estimators in standard-library C++: ridge regression as a closed-form modification of the normal equations, and lasso and elastic net via cyclic coordinate descent with soft-thresholding. All three keep the series' fit / predict contract, handle the intercept correctly (never penalized), and add a train-only standardization layer, warm-started coefficient paths, and deterministic cross-validation for choosing the penalty strength.

Why Penalize at All

The QR/SVD post already showed the mechanism: when two columns of $X_b$ are highly correlated, the design has a near-null direction, and the least-squares solution can move a long way along that direction while barely changing the fitted values. Formally, the variance of the OLS estimator is $\operatorname{Var}(\hat\beta) = \sigma^2 (X_b^\top X_b)^{-1}$, and a near-singular $X_b^\top X_b$ makes some entries of that inverse enormous. The fitted line is still fine; the individual coefficients are not trustworthy, and a slightly different sample can produce wildly different $\hat\beta$. Regularization addresses this directly by shrinking $\beta$ toward zero, which shrinks $\operatorname{Var}(\hat\beta)$ at the cost of introducing a small, known bias. The three penalties in this post make different trade-offs along that bias-variance line:

Ridge: A Small, Always-Invertible Modification

Ridge regression minimizes squared error plus an $\ell_2$ penalty on the coefficients (never the intercept):

$$J(\beta_0, \beta) = \frac12\sum_{i=1}^n\bigl(y_i - \beta_0 - x_i^\top\beta\bigr)^2 + \frac{\lambda}{2}\lVert\beta\rVert_2^2, \qquad \lambda \ge 0.$$

Differentiating with respect to $\beta$ (holding the intercept out of the penalty) and setting the gradient to zero gives a normal-equation system that looks almost identical to Part 1's, with one added term:

$$\bigl(X_b^\top X_b + \Lambda\bigr)\beta_{\text{full}} = X_b^\top y, \qquad \Lambda = \lambda\begin{bmatrix}0 & 0 \\ 0 & I_d\end{bmatrix},$$

where $\Lambda$ is a $(d{+}1)\times(d{+}1)$ diagonal matrix that is zero in the intercept's row/column and $\lambda$ everywhere else, so the intercept is estimated but never shrunk. Adding $\lambda I$ to the feature block of $X_b^\top X_b$ has an immediate practical benefit: $X_b^\top X_b$ is only positive semi-definite (it can be singular under exact collinearity, which is why solveLinearSystem can throw on duplicated columns), but for any $\lambda > 0$ the sum $X_b^\top X_b + \Lambda$ is strictly positive definite and therefore always invertible, regardless of how collinear the features are. Ridge does not merely tolerate collinearity; it is one of the standard fixes for it.

The scaling convention matters when $\lambda$ values are compared with libraries or formulas elsewhere. This post consistently uses an unnormalized half-sum-of-squares term, $\tfrac12\lVert y-X\beta\rVert^2$, so the ridge system contains $\lambda I$, the elastic-net threshold is $\lambda\alpha$, and its denominator contains $\lambda(1-\alpha)$. Under the equally common normalized objective $\tfrac1{2n}\lVert y-X\beta\rVert^2+\lambda_{\mathrm{norm}}[\alpha\lVert\beta\rVert_1+\tfrac{1-\alpha}{2}\lVert\beta\rVert_2^2]$, multiplying the stationarity equation by $n$ produces $X^\top X+n\lambda_{\mathrm{norm}}I$, threshold $n\lambda_{\mathrm{norm}}\alpha$, and denominator $\sum_i x_{ij}^2+n\lambda_{\mathrm{norm}}(1-\alpha)$. The two conventions describe identical fits when $\lambda_{\mathrm{code}}=n\lambda_{\mathrm{norm}}$; copying the same numeric $\lambda$ between them would change the strength of regularization by a factor of $n$.

Standardization Is Not Optional Here

The penalty $\lambda\lVert\beta\rVert_2^2$ treats every coefficient identically, but a coefficient's size is only meaningful relative to the scale of its feature. A feature measured in kilometers and the same feature measured in millimeters would need $\beta$ values that differ by a factor of $10^6$ to represent the same relationship, and a fixed $\lambda$ would penalize them completely differently. Every regularized estimator in this post therefore reuses the train-only standardization from Part 3: center and scale every feature column to zero mean and unit population variance, with the same zero-variance guard (a constant column gets scale $1$ and centers to exactly zero). The one difference from Part 3 is the target: ridge and lasso only center $y$, they do not rescale it, because there is no coefficient attached to $y$ that a penalty could distort.

$$\tilde x_{ij} = \frac{x_{ij}-\mu_j}{\sigma_j}, \qquad \tilde y_i = y_i - \mu_y.$$

Centering both $X$ and $y$ has a second, load-bearing consequence: it removes the intercept from the optimization entirely. For any fixed $\tilde\beta$, the optimal standardized intercept is $\overline{\tilde y - \tilde X\tilde\beta} = \overline{\tilde y} - \overline{\tilde X}\tilde\beta = 0 - 0 = 0$, exactly, because both $\tilde X$ and $\tilde y$ have mean zero by construction. So the standardized-space fit never carries an intercept term at all; it only ever solves for the $d$ feature weights $\tilde\beta$. This is true for ridge and for the penalized objectives later in this post alike, since centering is a property of the data, not of the penalty.

Converting back to raw units mirrors Part 3's derivation but is simpler, because $y$ was never rescaled:

$$\beta_j = \frac{\tilde\beta_j}{\sigma_j}, \qquad \beta_0 = \mu_y - \sum_{j=1}^d \beta_j\,\mu_j.$$

One consequence of standardizing to unit population variance is worth stating explicitly, because the rest of this post leans on it: for every non-constant column, $\frac1n\sum_i \tilde x_{ij}^2 = \sigma_j^2/\sigma_j^2 = 1$, so

$$\sum_{i=1}^n \tilde x_{ij}^2 = n \quad\text{for every column } j.$$

Every standardized column has exactly the same sum of squares, $n$, regardless of the original units or spread of that feature. This single fact is what makes the coordinate-descent update later in this post collapse to a clean, uniform formula instead of a per-feature-dependent one.

Ridge as a Spectral Filter

The SVD post wrote the unpenalized least-squares solution as $\beta = \sum_k \frac{u_k^\top y}{\sigma_k}v_k$, a sum over singular directions weighted by $1/\sigma_k$. Substituting the SVD $\tilde X = U S V^\top$ into the ridge normal equations $(\tilde X^\top\tilde X + \lambda I)\tilde\beta = \tilde X^\top \tilde y$ and simplifying (using $V^\top V = I$ and $S$ diagonal) gives the ridge solution in exactly the same singular-direction form, with one extra factor:

$$\tilde\beta_{\text{ridge}}(\lambda) = \sum_k \frac{\sigma_k}{\sigma_k^2+\lambda}\,(u_k^\top \tilde y)\,v_k.$$

At $\lambda = 0$ this is precisely the unpenalized $\sum_k \frac{u_k^\top \tilde y}{\sigma_k}v_k$. As $\lambda$ grows, every direction's contribution is scaled by the shrinkage factor $\sigma_k/(\sigma_k^2+\lambda)$, and that factor is not uniform across $k$: for a large singular value $\sigma_k \gg \sqrt\lambda$, the factor is close to $1/\sigma_k$ (barely shrunk), while for a small singular value $\sigma_k \ll \sqrt\lambda$, the factor is close to $\sigma_k/\lambda$ (shrunk almost proportionally to $\sigma_k$ itself, i.e., far more aggressively). Ridge therefore does not shrink every direction equally: it preferentially damps the directions the data constrains least (small $\sigma_k$, the near-null, high-variance directions responsible for the instability described above) while leaving the well-determined directions comparatively untouched.

Relative to the unpenalized contribution in singular direction $k$, Ridge retains the fraction

$$q_k(\lambda)=\frac{\sigma_k^2}{\sigma_k^2+\lambda}.$$

This exposes the selective damping directly. At $\lambda=4$, a well-determined direction with $\sigma=5$ retains $25/29\approx0.8621$ of its OLS contribution, while a weak direction with $\sigma=0.5$ retains only $0.25/4.25\approx0.0588$. The weak direction loses almost all of its contribution while the strong direction remains mostly intact.

A concrete coefficient calculation must preserve the implementation's unpenalized intercept. Reuse the one-feature data $x=[1,2,4]$, $y=[1,3,7]^\top$, whose OLS line is $\hat y=-1+2x$. The implementation centers $y$ and standardizes $x$, leaving a single standardized feature direction with $\sigma_{\tilde X}=\sqrt3$. Its retained fraction is $3/(3+\lambda)$, the raw slope is $6/(3+\lambda)$, and the intercept is recovered from the training means rather than penalized:

$$\beta_1(\lambda)=\frac{6}{3+\lambda}, \qquad \beta_0(\lambda)=\bar y-\beta_1(\lambda)\bar x=\frac{11}{3}-\frac{7}{3}\beta_1(\lambda).$$
$\lambda$retained feature contribution$\beta_0$ (unpenalized intercept)$\beta_1$ (slope)
01.0000−1.00002.0000
10.75000.16671.5000
40.42861.66670.8571
160.15792.92980.3158
640.04483.45770.0896

The slope approaches zero as $\lambda$ grows, but the intercept approaches $\bar y=11/3$ rather than zero. Its changing numeric value is the centering correction required to keep the fitted line anchored at $(\bar x,\bar y)$ while the slope shrinks, not evidence that the intercept was penalized. With several correlated features, the same singular-direction filtering produces a smooth, generally curved Ridge trace because each direction retains a different fraction.

RidgeRegressor Implementation

The struct matches the shape used throughout the series: a fitted flag, nFeatures, intercept, and coef. Fitting standardizes, solves the small $(d\times d)$ ridge normal equations with the exact solveLinearSystem from Part 1, and converts back:

struct Standardizer { vector<double> mu, sigma; double yMean; };

Standardizer standardizeFit(const Matrix& X, const vector<double>& y,
                             Matrix& Xs, vector<double>& ys) {
    int n = (int)X.size(), d = (int)X[0].size();
    Standardizer s;
    s.mu.assign(d, 0.0); s.sigma.assign(d, 0.0);
    for (int i = 0; i < n; ++i) for (int j = 0; j < d; ++j) s.mu[j] += X[i][j];
    for (int j = 0; j < d; ++j) s.mu[j] /= n;

    vector<double> maxDev(d, 0.0);
    for (int i = 0; i < n; ++i)
        for (int j = 0; j < d; ++j) maxDev[j] = max(maxDev[j], fabs(X[i][j]-s.mu[j]));
    for (int j = 0; j < d; ++j) {
        if (maxDev[j] == 0.0) { s.sigma[j] = 1.0; continue; }
        double acc = 0.0;
        for (int i = 0; i < n; ++i) { double z = (X[i][j]-s.mu[j])/maxDev[j]; acc += z*z; }
        s.sigma[j] = maxDev[j] * sqrt(acc / n);
    }
    s.yMean = accumulate(y.begin(), y.end(), 0.0) / n;

    Xs.assign(n, vector<double>(d));
    for (int i = 0; i < n; ++i)
        for (int j = 0; j < d; ++j) Xs[i][j] = (X[i][j]-s.mu[j]) / s.sigma[j];
    ys.assign(n, 0.0);
    for (int i = 0; i < n; ++i) ys[i] = y[i] - s.yMean;
    return s;
}

void toRaw(const vector<double>& betaTilde, const Standardizer& s,
           double& intercept, vector<double>& coef) {
    int d = (int)betaTilde.size();
    coef.assign(d, 0.0);
    for (int j = 0; j < d; ++j) coef[j] = betaTilde[j] / s.sigma[j];
    intercept = s.yMean;
    for (int j = 0; j < d; ++j) intercept -= coef[j] * s.mu[j];
}

vector<double> ridgeFitStandardized(const Matrix& Xs, const vector<double>& ys, double lambda) {
    int n = (int)Xs.size(), d = (int)Xs[0].size();
    Matrix A(d, vector<double>(d, 0.0));
    vector<double> b(d, 0.0);
    for (int i = 0; i < n; ++i)
        for (int a = 0; a < d; ++a) {
            b[a] += Xs[i][a] * ys[i];
            for (int c = 0; c < d; ++c) A[a][c] += Xs[i][a] * Xs[i][c];
        }
    for (int a = 0; a < d; ++a) A[a][a] += lambda;   // the entire ridge modification
    return solveLinearSystem(A, b);   // reuses Part 1's partial-pivoting solver
}

struct RidgeRegressor {
    bool fitted = false;
    int nFeatures = 0;
    double intercept = 0.0;
    vector<double> coef;

    void fit(const Matrix& X, const vector<double>& y, double lambda) {
        fitted = false;
        validateFitInput(X, y, "RidgeRegressor");
        if (!isfinite(lambda) || lambda < 0.0)
            throw invalid_argument("RidgeRegressor::fit: lambda must be finite and non-negative");
        Matrix Xs; vector<double> ys;
        Standardizer s = standardizeFit(X, y, Xs, ys);
        vector<double> betaTilde = ridgeFitStandardized(Xs, ys, lambda);
        toRaw(betaTilde, s, intercept, coef);
        nFeatures = (int)X[0].size();
        fitted = true;
    }
    // predict(x) / predict(X): identical shape to every other regressor in the series.
};

Ridge never allocates an $n\times(d{+}1)$ augmented matrix the way QR and SVD do: the feature block $\tilde X^\top\tilde X$ is only $d\times d$, and the intercept is recovered afterward from the means, exactly as it was in Part 1's unpenalized normal equations. The only new line, relative to a plain ridge-free normal-equation solve on standardized data, is A[a][a] += lambda.

Elastic Net: When the Penalty Needs an Iterative Solver

Lasso replaces the $\ell_2$ penalty with $\ell_1$, and elastic net keeps both, controlled by a mixing parameter $\alpha\in[0,1]$ (the code calls this l1Ratio, following the common convention that $\alpha=1$ is pure lasso and $\alpha=0$ is pure ridge):

$$J(\tilde\beta) = \frac12\lVert\tilde y - \tilde X\tilde\beta\rVert_2^2 + \lambda\left[\frac{1-\alpha}{2}\lVert\tilde\beta\rVert_2^2 + \alpha\lVert\tilde\beta\rVert_1\right].$$

The $\ell_1$ term is not differentiable at $\tilde\beta_j=0$, so there is no analog of the closed-form ridge solve; the standard fitting algorithm is cyclic coordinate descent: fix every coefficient except $\tilde\beta_j$, minimize the one-dimensional objective in $\tilde\beta_j$ exactly, then move to the next coordinate, repeating until nothing changes. Because $J$ is convex (strictly, whenever $\alpha<1$ or the design has full column rank), this converges to the global minimum regardless of the order coordinates are visited, though the speed of convergence does depend on that order and on how correlated the columns are, a point the coefficient path later in this post returns to.

Holding every coefficient but $\tilde\beta_j$ fixed, write $r_{ij} = \tilde y_i - \sum_{k\ne j}\tilde x_{ik}\tilde\beta_k$ for the partial residual with feature $j$'s contribution removed, and $z_j = \sum_i \tilde x_{ij}\,r_{ij}$ for that feature's correlation with the partial residual. The one-dimensional objective in $\tilde\beta_j$ is a quadratic plus an absolute value, and its minimizer is the classic soft-thresholding update:

$$\tilde\beta_j \leftarrow \frac{S(z_j,\ \lambda\alpha)}{\sum_i \tilde x_{ij}^2 + \lambda(1-\alpha)}, \qquad S(z,\gamma) = \operatorname{sign}(z)\max(|z|-\gamma,\ 0).$$

$S$ shifts $z_j$ toward zero by $\gamma=\lambda\alpha$ and clips it at zero if it would cross, which is exactly what makes lasso ($\alpha>0$) capable of setting a coefficient to exactly zero, something the smooth ridge penalty can never do (ridge's shrinkage factor $\sigma_k/(\sigma_k^2+\lambda)$ is strictly positive for every finite $\lambda$). Here is where the standardization fact from earlier collapses the formula: because every standardized column satisfies $\sum_i\tilde x_{ij}^2=n$ exactly, the denominator is the same constant, $n+\lambda(1-\alpha)$, for every feature, and only the numerator $z_j$ varies across coordinates. Without standardization, the denominator would be a different, data-dependent number for every feature, and comparing penalty strength across features would not be meaningful.

Coordinate Descent by Hand

A concrete pass makes the update mechanical rather than abstract. Six rows carry two informative features and one weak, largely uninformative one: raw $x_1=[1,2,3,4,5,6]$, $x_2=[2,1,4,3,6,5]$, $x_3=[0.3,-0.4,0.1,-0.2,0.5,-0.3]$, and $y=[3.2,2.9,7.8,7.6,12.4,11.9]$ (so $x_1$ and $x_2$ are correlated with each other at $\rho=0.83$, and $x_3$ is nearly unrelated to $y$). Standardizing gives $\tilde x_1 = [-1.4639,-0.8783,-0.2928,0.2928,0.8783,1.4639]$, $\tilde x_2=[-0.8783,-1.4639,0.2928,-0.2928,1.4639,0.8783]$, $\tilde x_3=[0.9186,-1.2247,0.3062,-0.6124,1.5309,-0.9186]$, and $\tilde y = [-4.4333,-4.7333,0.1667,-0.0333,4.7667,4.2667]$; every column's sum of squares is exactly $6=n$, as the standardization fact guarantees. Starting from $\tilde\beta=(0,0,0)$ and using $\lambda=6,\ \alpha=1$ (pure lasso, so $\gamma=6$ and the denominator is just $n=6$ for every coordinate):

  1. Coordinate 1. With the other two coefficients at zero, $z_1 = \tilde x_1^\top\tilde y = 21.0209$. Soft-thresholding: $S(21.0209,6)=15.0209$, so $\tilde\beta_1 \leftarrow 15.0209/6 = 2.5035$.
  2. Coordinate 2. The partial residual now excludes $\tilde\beta_1\tilde x_1$: $z_2 = \tilde x_2^\top(\tilde y - \tilde\beta_1\tilde x_1) = 9.1605$. $S(9.1605,6)=3.1605$, so $\tilde\beta_2\leftarrow 3.1605/6=0.5268$.
  3. Coordinate 3. $z_3 = \tilde x_3^\top(\tilde y - \tilde\beta_1\tilde x_1 - \tilde\beta_2\tilde x_2) = 5.1045$. Since $|z_3|=5.1045 < \gamma=6$, soft-thresholding clips it to exactly zero: $\tilde\beta_3\leftarrow 0.0000$. One sweep already finds the weak feature uninformative enough to remove.
  4. Sweep 2, coordinate 1. $z_1 = 18.4021$ (smaller than before, because the fit already explains part of $y$); $\tilde\beta_1\leftarrow (18.4021-6)/6=2.0670$.
  5. Sweep 2, coordinate 2. $z_2=11.3304$; $\tilde\beta_2\leftarrow(11.3304-6)/6=0.8884$.
  6. Sweep 2, coordinate 3. $z_3=3.8972$, still below $\gamma=6$; $\tilde\beta_3$ stays at exactly $0.0000$.

After these two sweeps, $\tilde\beta=(2.0670,\ 0.8884,\ 0.0000)$. Continuing the same cycle (verified by script, not shown step by step) converges after 62 sweeps to $\tilde\beta\approx(1.1111,\ 1.6804,\ 0.0000)$: coordinates 1 and 2 keep trading weight back and forth, slowly, because they are correlated ($\rho=0.83$) and the objective is nearly flat along the direction that keeps $\tilde\beta_1+\tilde\beta_2$ constant, while coordinate 3 never leaves zero once it arrives there, since its $z_3$ only shrinks further as the fit improves. The animation below replays exactly these six update steps.

▶ Coordinate Descent With Soft-Thresholding

Six rows, three standardized features, $\lambda=6$, $\alpha=1$ (pure lasso). Every coordinate update runs the same four-stage chain: form the partial residual $r^{(j)}=\tilde y-\sum_{k\ne j}\tilde x_k\tilde\beta_k$, correlate it with the feature to get $z_j=\tilde x_j^\top r^{(j)}$, soft-threshold by $\gamma=\lambda\alpha=6$, then divide by the denominator $n=6$. The weak feature $\tilde\beta_3$ lands at exactly zero and stays there.

ElasticNetRegressor Implementation

Rather than writing a separate Lasso struct, the implementation follows the same convention real libraries use: one ElasticNetRegressor whose alpha (l1Ratio) parameter recovers lasso at $\alpha=1$. A dedicated closed-form RidgeRegressor still exists for the reasons above (it is cheaper and exact), but as a consistency check, $\alpha=0$ in the coordinate-descent code below must converge to the same answer as the closed-form ridge solve, since both are minimizing the identical convex quadratic; the complete program later confirms this bit-for-bit.

double softThreshold(double z, double gamma) {
    if (z > gamma) return z - gamma;
    if (z < -gamma) return z + gamma;
    return 0.0;
}

// Coordinate descent on already-standardized data. `beta` is both the
// warm-start input and the output (modified in place).
int coordinateDescentFit(const Matrix& Xs, const vector<double>& ys, double lambda, double alpha,
                          vector<double>& beta, int maxIter, double tol,
                          bool* didConverge = nullptr) {
    int n = (int)Xs.size(), d = (int)Xs[0].size();
    if (didConverge) *didConverge = false;
    vector<double> colSq(d, 0.0);
    for (int j = 0; j < d; ++j) {
        double s = 0.0; for (int i = 0; i < n; ++i) s += Xs[i][j]*Xs[i][j]; colSq[j] = s;
    }  // == n for every non-constant column, by construction

    vector<double> resid(n);
    for (int i = 0; i < n; ++i) {
        double pred = 0.0;
        for (int j = 0; j < d; ++j) pred += Xs[i][j] * beta[j];
        resid[i] = ys[i] - pred;
    }

    int iter = 0;
    for (; iter < maxIter; ++iter) {
        double maxDelta = 0.0;
        for (int j = 0; j < d; ++j) {
            if (colSq[j] == 0.0) continue;  // constant column: coefficient stays at 0
            double zj = 0.0;
            for (int i = 0; i < n; ++i) zj += Xs[i][j] * (resid[i] + Xs[i][j] * beta[j]);
            double newBeta = softThreshold(zj, lambda * alpha) / (colSq[j] + lambda * (1.0 - alpha));
            double delta = newBeta - beta[j];
            if (delta != 0.0) for (int i = 0; i < n; ++i) resid[i] -= Xs[i][j] * delta;
            maxDelta = max(maxDelta, fabs(delta));
            beta[j] = newBeta;
        }
        if (maxDelta < tol) {
            ++iter;
            if (didConverge) *didConverge = true;
            break;
        }
    }
    return iter;   // reported as iterationsRun; no throw if maxIter is hit
}

struct ElasticNetRegressor {
    bool fitted = false;
    int nFeatures = 0;
    double intercept = 0.0;
    vector<double> coef;
    int iterationsRun = 0;
    bool converged = false;

    void fit(const Matrix& X, const vector<double>& y, double lambda, double alpha,
             int maxIter = 20000, double tol = 1e-9) {
        fitted = false;
        validateFitInput(X, y, "ElasticNetRegressor");
        if (!isfinite(lambda) || lambda < 0.0)
            throw invalid_argument("ElasticNetRegressor::fit: lambda must be finite and non-negative");
        if (!isfinite(alpha) || alpha < 0.0 || alpha > 1.0)
            throw invalid_argument("ElasticNetRegressor::fit: alpha (l1Ratio) must be in [0, 1]");

        Matrix Xs; vector<double> ys;
        Standardizer s = standardizeFit(X, y, Xs, ys);
        vector<double> betaTilde(X[0].size(), 0.0);
        bool didConverge = false;
        iterationsRun = coordinateDescentFit(
            Xs, ys, lambda, alpha, betaTilde, maxIter, tol, &didConverge);
        converged = didConverge;
        toRaw(betaTilde, s, intercept, coef);
        nFeatures = (int)X[0].size();
        fitted = true;
    }
    // predict(x) / predict(X): identical shape to every other regressor in the series.
};

Two cheap checks confirm the coordinate-descent solver reaches the true minimum rather than merely stopping. First, the elastic-net objective $J(\tilde\beta)=\tfrac12\lVert\tilde y-\tilde X\tilde\beta\rVert_2^2+\lambda\left[\tfrac{1-\alpha}{2}\lVert\tilde\beta\rVert_2^2+\alpha\lVert\tilde\beta\rVert_1\right]$ is non-increasing across sweeps; the complete program prints it after the first three sweeps of the $\lambda=5$ lasso fit ($J_1=69.5524$, $J_2=69.5376$, $J_3=69.5375$) down to the converged $J_{\text{final}}=69.5294$. Second, at a minimum every coordinate satisfies the KKT (subgradient stationarity) condition: $\tilde x_j^\top(\tilde y-\tilde X\tilde\beta)=\lambda\alpha\operatorname{sign}(\tilde\beta_j)+\lambda(1-\alpha)\tilde\beta_j$ for an active coefficient, and $\lvert\tilde x_j^\top(\tilde y-\tilde X\tilde\beta)\rvert\le\lambda\alpha$ for one held at zero. The largest violation of that condition at the CV-selected $\lambda=5$ solution is $1.97\times10^{-7}$, certifying a coordinate-wise optimum to solver precision.

Coefficient Paths and Warm Starts

A single $\lambda$ is rarely the right unit of analysis; what a reader (or a cross-validation loop) usually wants is the whole path of solutions across a range of $\lambda$. Fitting each $\lambda$ from a cold start ($\tilde\beta=0$) works but wastes effort: adjacent $\lambda$ values on a reasonably fine grid have similar solutions, so initializing coordinate descent from the previous $\lambda$'s answer (a warm start) needs far fewer sweeps to reconverge. The natural traversal order is descending $\lambda$: start at the most-penalized (simplest, fastest-converging) end of the path and relax the penalty gradually.

vector<vector<double>> elasticNetPath(const Matrix& X, const vector<double>& y,
                                       const vector<double>& lambdasDesc, double alpha,
                                       vector<int>* itersOut = nullptr) {
    Matrix Xs; vector<double> ys;
    Standardizer s = standardizeFit(X, y, Xs, ys);
    vector<double> beta(X[0].size(), 0.0);      // carried across the whole loop: the warm start
    vector<vector<double>> path;
    for (double lambda : lambdasDesc) {
        int iters = coordinateDescentFit(Xs, ys, lambda, alpha, beta, 20000, 1e-9);
        if (itersOut) itersOut->push_back(iters);
        double intercept; vector<double> coef;
        toRaw(beta, s, intercept, coef);
        vector<double> row{intercept};
        for (double c : coef) row.push_back(c);
        path.push_back(row);
    }
    return path;
}

On the six-feature dataset used in Scenario B below, fitting a ten-point lasso path with a fresh cold start at every $\lambda$ costs $20{,}132$ total coordinate sweeps; warm-starting the identical path costs $16{,}372$, about $19\%$ fewer. That saving is smaller than the roughly eight-fold reduction a less collinear toy example shows, and the reason is instructive: the slow-converging direction in this dataset is the near-duplicate pair of features (introduced in Scenario B), and that direction is slow to resolve at every $\lambda$, warm-started or not, because $\lambda$ mostly regularizes the other four coordinates rather than breaking the near-degeneracy between the duplicated pair. Warm-starting still helps, just less dramatically when the bottleneck is collinearity rather than the distance between successive $\lambda$ values.

The path has a natural upper endpoint as well. Starting from $\tilde\beta=0$, the soft-thresholding update leaves coordinate $j$ at zero for as long as $\lvert\tilde x_j^\top\tilde y\rvert\le\lambda\alpha$, so every coefficient is exactly zero once $\lambda\ge\lambda_{\max}=\max_j\lvert\tilde x_j^\top\tilde y\rvert$ (taking $\alpha=1$). Any larger $\lambda$ returns the same empty model, which is why a principled lasso grid begins at $\lambda_{\max}$ and descends from there. The complete program computes $\lambda_{\max}\approx675.67$ for Scenario B and confirms the fit at $\lambda_{\max}$ keeps zero coefficients nonzero; the cross-validation grid below tops out at $\lambda=50$, well inside the path, so even its most-penalized point already holds three features active.

Deterministic K-Fold Cross-Validation

Choosing $\lambda$ from the training data alone (e.g., minimizing training error) always prefers $\lambda\to 0$, since less regularization can only fit the training set better. $\lambda$ has to be chosen by estimated out-of-sample error, and $k$-fold cross-validation is the standard way to estimate that without setting aside a separate validation set permanently. The fold assignment is seeded exactly like the shuffles elsewhere in this series, so the same seed always reproduces the same folds:

vector<vector<int>> kFoldIndices(int n, int k, unsigned seed) {
    vector<int> order(n);
    iota(order.begin(), order.end(), 0);
    mt19937 rng(seed);
    shuffle(order.begin(), order.end(), rng);
    vector<vector<int>> folds(k);
    for (int i = 0; i < n; ++i) folds[i % k].push_back(order[i]);
    return folds;
}

For each candidate $\lambda$, every fold in turn is held out as validation while the model is refit (from scratch, standardized on the training folds only, exactly as a real train/validation split would be) on the remaining $k-1$ folds. The reported curve pools squared errors across all held-out predictions before dividing by the total validation-row count, so every row receives equal weight even when fold sizes differ by one. With equal-sized folds this is exactly the arithmetic mean of the fold MSE values. The full CV loop for ridge (elastic net's is identical except for the model being refit) is:

vector<double> cvCurveRidge(const Matrix& X, const vector<double>& y,
                            const vector<double>& lambdas, int k, unsigned seed) {
    vector<vector<int>> folds = kFoldIndices((int)X.size(), k, seed);
    vector<double> curve(lambdas.size(), 0.0);
    for (size_t li = 0; li < lambdas.size(); ++li) {
        double total = 0.0; int count = 0;
        for (int f = 0; f < k; ++f) {
            vector<int> trainIdx;
            for (int g = 0; g < k; ++g) if (g != f) for (int i : folds[g]) trainIdx.push_back(i);
            RidgeRegressor model;
            model.fit(rowsOf(X, trainIdx), valsOf(y, trainIdx), lambdas[li]);
            auto pred = model.predict(rowsOf(X, folds[f]));
            auto yva = valsOf(y, folds[f]);
            for (size_t i = 0; i < yva.size(); ++i) { double e = yva[i]-pred[i]; total += e*e; }
            count += (int)yva.size();
        }
        curve[li] = total / count;
    }
    return curve;
}

The single CV minimum is not the only defensible choice of $\lambda$. Because each fold contributes its own held-out error, the cross-validation estimate carries a standard error across folds, and the one-standard-error rule selects the largest (most regularized, simplest) $\lambda$ whose CV error still lies within one SE of the best. On Scenario B's lasso curve the minimum is $0.39667$ at $\lambda=5$ with a fold-level standard error of $0.04041$, and every $\lambda$ up to $20$ falls inside that one-SE band, so the rule prefers $\lambda=20$: a much sparser model at negligible cost in estimated error. The complete program reports both the raw minimum and this one-SE choice.

▶ The Lasso Path Meets the CV Curve

Scenario B's collinear six-feature dataset, lasso ($\alpha=1$), traversed high-to-low across the ten-point $\lambda$ grid (every number is taken from the complete program's output below). The top panel is the coefficient path; the bottom panel is the pooled 5-fold cross-validation error. Each step drops to the next-smaller $\lambda$, extends both curves together, and the CV-selected $\lambda$ (the error minimum) is marked once reached.

Validation and Errors

Complete Program and Output

The program below runs two scenarios. Scenario A refits the exact shared two-feature dataset from Part 1 (seed 7, $n=240$, no collinearity) with a vanishingly small $\lambda$, confirming that both ridge and lasso reduce to the known OLS answer when the penalty is switched off. Scenario B builds a six-feature dataset ($n=200$) with $x_1,x_2$ as true signal (coefficients $2.0,-1.5$), $x_3$ a near-duplicate of $x_1$ (correlation $0.9965$), and $x_4,x_5,x_6$ pure noise (true coefficient $0$), then compares OLS, cross-validated ridge, cross-validated lasso, and cross-validated elastic net, prints both a ridge and a lasso coefficient path, and closes with the $\alpha=0$-equals-ridge cross-check.

#include <algorithm>
#include <cmath>
#include <cstdio>
#include <limits>
#include <numeric>
#include <random>
#include <stdexcept>
#include <string>
#include <vector>
using namespace std;
using Matrix = vector<vector<double>>;

// ============================================================
// Shared validation and metrics (same conventions as Parts 1-3).
// ============================================================
static void validateFitInput(const Matrix& X, const vector<double>& y, const string& who) {
    if (X.size() != y.size())
        throw invalid_argument(who + "::fit: X has " + to_string(X.size()) +
                               " rows but y has " + to_string(y.size()) + " entries");
    int n = (int)X.size();
    if (n == 0) throw invalid_argument(who + "::fit: empty training set");
    int d = (int)X[0].size();
    if (d == 0) throw invalid_argument(who + "::fit: zero feature columns");
    for (int i = 0; i < n; ++i) {
        if ((int)X[i].size() != d)
            throw invalid_argument(who + "::fit: row " + to_string(i) +
                                   " has " + to_string(X[i].size()) +
                                   " columns, expected " + to_string(d));
        for (int j = 0; j < d; ++j)
            if (!isfinite(X[i][j]))
                throw invalid_argument(who + "::fit: non-finite feature in row " + to_string(i));
        if (!isfinite(y[i]))
            throw invalid_argument(who + "::fit: non-finite target at row " + to_string(i));
    }
    if (n < d + 1)
        throw invalid_argument(who + "::fit: need at least " + to_string(d + 1) +
                               " rows for " + to_string(d) + " features plus intercept, got " +
                               to_string(n));
}

static void validateMetricInputs(const vector<double>& yTrue, const vector<double>& yPred, const string& name) {
    if (yTrue.size() != yPred.size()) throw invalid_argument(name + ": size mismatch");
    if (yTrue.empty()) throw invalid_argument(name + ": vectors must be non-empty");
    for (size_t i = 0; i < yTrue.size(); ++i)
        if (!isfinite(yTrue[i]) || !isfinite(yPred[i]))
            throw invalid_argument(name + ": non-finite value at index " + to_string(i));
}

static double mse(const vector<double>& yTrue, const vector<double>& yPred) {
    validateMetricInputs(yTrue, yPred, "mse");
    double s = 0.0;
    for (size_t i = 0; i < yTrue.size(); ++i) { double e = yTrue[i] - yPred[i]; s += e * e; }
    return s / yTrue.size();
}
static double rmse(const vector<double>& yTrue, const vector<double>& yPred) { return sqrt(mse(yTrue, yPred)); }
static double r2(const vector<double>& yTrue, const vector<double>& yPred) {
    validateMetricInputs(yTrue, yPred, "r2");
    double meanY = accumulate(yTrue.begin(), yTrue.end(), 0.0) / yTrue.size();
    double ssRes = 0.0, ssTot = 0.0;
    for (size_t i = 0; i < yTrue.size(); ++i) {
        ssRes += (yTrue[i]-yPred[i])*(yTrue[i]-yPred[i]);
        ssTot += (yTrue[i]-meanY)*(yTrue[i]-meanY);
    }
    if (ssTot == 0.0) throw invalid_argument("r2: target values are constant, so R^2 is undefined");
    return 1.0 - ssRes / ssTot;
}

// ============================================================
// Partial-pivoting Gaussian solver (identical to Part 1).
// ============================================================
static vector<double> solveLinearSystem(Matrix A, vector<double> b) {
    int n = (int)A.size();
    if (n == 0) throw invalid_argument("solveLinearSystem: A must be non-empty");
    if (b.size() != A.size()) throw invalid_argument("solveLinearSystem: A and b dimensions do not match");

    double matrixScale = 0.0;
    for (int i = 0; i < n; ++i) {
        if (A[i].size() != A.size())
            throw invalid_argument("solveLinearSystem: A must be square; row " +
                                   to_string(i) + " has " + to_string(A[i].size()) +
                                   " entries, expected " + to_string(n));
        for (double v : A[i]) {
            if (!isfinite(v)) throw invalid_argument("solveLinearSystem: A contains a non-finite value");
            matrixScale = max(matrixScale, fabs(v));
        }
        if (!isfinite(b[i])) throw invalid_argument("solveLinearSystem: b contains a non-finite value");
    }
    const double tol = numeric_limits<double>::epsilon() * max(1.0, matrixScale) * n * 100.0;

    for (int k = 0; k < n; ++k) {
        int piv = k;
        double best = fabs(A[k][k]);
        for (int i = k + 1; i < n; ++i) if (fabs(A[i][k]) > best) { best = fabs(A[i][k]); piv = i; }
        if (best <= tol)
            throw runtime_error("solveLinearSystem: matrix is singular or near-singular "
                                 "(no usable pivot in column " + to_string(k) + "); "
                                 "check for duplicated/collinear features");
        if (piv != k) { swap(A[k], A[piv]); swap(b[k], b[piv]); }
        for (int i = k + 1; i < n; ++i) {
            double factor = A[i][k] / A[k][k];
            if (factor == 0.0) continue;
            for (int j = k; j < n; ++j) A[i][j] -= factor * A[k][j];
            b[i] -= factor * b[k];
        }
    }
    vector<double> beta(n, 0.0);
    for (int k = n - 1; k >= 0; --k) {
        double s = b[k];
        for (int j = k + 1; j < n; ++j) s -= A[k][j] * beta[j];
        beta[k] = s / A[k][k];
    }
    return beta;
}

// ============================================================
// Direct OLS via the exact Part-1 normal equations (used as the
// unpenalized baseline; independent of the penalized solvers below).
// ============================================================
struct OLSFit { double intercept; vector<double> coef; };

static OLSFit fitOLSDirect(const Matrix& X, const vector<double>& y) {
    validateFitInput(X, y, "fitOLSDirect");
    int n = (int)X.size(), d = (int)X[0].size(), p = d + 1;
    Matrix A(p, vector<double>(p, 0.0));
    vector<double> b(p, 0.0);
    for (int i = 0; i < n; ++i) {
        vector<double> row(p);
        row[0] = 1.0;
        for (int j = 0; j < d; ++j) row[j+1] = X[i][j];
        for (int a = 0; a < p; ++a) {
            b[a] += row[a] * y[i];
            for (int c = 0; c < p; ++c) A[a][c] += row[a] * row[c];
        }
    }
    vector<double> beta = solveLinearSystem(A, b);
    OLSFit out;
    out.intercept = beta[0];
    out.coef.assign(beta.begin()+1, beta.end());
    return out;
}

// ============================================================
// Train-only standardization: center y, standardize X columns to
// zero mean / unit population variance (constant columns -> scale 1).
// Because every standardized column has population variance 1,
// sum_i xTilde_ij^2 == n exactly for every non-constant column.
// ============================================================
struct Standardizer { vector<double> mu, sigma; double yMean; };

static Standardizer standardizeFit(const Matrix& X, const vector<double>& y, Matrix& Xs, vector<double>& ys) {
    int n = (int)X.size(), d = (int)X[0].size();
    Standardizer s;
    s.mu.assign(d, 0.0);
    s.sigma.assign(d, 0.0);
    for (int i = 0; i < n; ++i) for (int j = 0; j < d; ++j) s.mu[j] += X[i][j];
    for (int j = 0; j < d; ++j) s.mu[j] /= n;

    vector<double> maxDev(d, 0.0);
    for (int i = 0; i < n; ++i)
        for (int j = 0; j < d; ++j) maxDev[j] = max(maxDev[j], fabs(X[i][j] - s.mu[j]));
    for (int j = 0; j < d; ++j) {
        if (maxDev[j] == 0.0) { s.sigma[j] = 1.0; continue; }
        double acc = 0.0;
        for (int i = 0; i < n; ++i) { double z = (X[i][j] - s.mu[j]) / maxDev[j]; acc += z * z; }
        s.sigma[j] = maxDev[j] * sqrt(acc / n);
    }
    s.yMean = accumulate(y.begin(), y.end(), 0.0) / n;

    Xs.assign(n, vector<double>(d));
    for (int i = 0; i < n; ++i)
        for (int j = 0; j < d; ++j) Xs[i][j] = (X[i][j] - s.mu[j]) / s.sigma[j];
    ys.assign(n, 0.0);
    for (int i = 0; i < n; ++i) ys[i] = y[i] - s.yMean;
    return s;
}

static void toRaw(const vector<double>& betaTilde, const Standardizer& s,
                   double& intercept, vector<double>& coef) {
    int d = (int)betaTilde.size();
    coef.assign(d, 0.0);
    for (int j = 0; j < d; ++j) coef[j] = betaTilde[j] / s.sigma[j];
    intercept = s.yMean;
    for (int j = 0; j < d; ++j) intercept -= coef[j] * s.mu[j];
}

// ============================================================
// Ridge: closed form on standardized data via the Part-1 solver.
// (X~^T X~ + lambda*I) beta~ = X~^T y~   (intercept excluded: centering
// already makes the standardized-space intercept exactly zero).
// ============================================================
static vector<double> ridgeFitStandardized(const Matrix& Xs, const vector<double>& ys, double lambda) {
    int n = (int)Xs.size(), d = (int)Xs[0].size();
    Matrix A(d, vector<double>(d, 0.0));
    vector<double> b(d, 0.0);
    for (int i = 0; i < n; ++i)
        for (int a = 0; a < d; ++a) {
            b[a] += Xs[i][a] * ys[i];
            for (int c = 0; c < d; ++c) A[a][c] += Xs[i][a] * Xs[i][c];
        }
    for (int a = 0; a < d; ++a) A[a][a] += lambda;
    return solveLinearSystem(A, b);
}

struct RidgeRegressor {
    bool fitted = false;
    int nFeatures = 0;
    double intercept = 0.0;
    vector<double> coef;

    void fit(const Matrix& X, const vector<double>& y, double lambda) {
        fitted = false;
        validateFitInput(X, y, "RidgeRegressor");
        if (!isfinite(lambda) || lambda < 0.0)
            throw invalid_argument("RidgeRegressor::fit: lambda must be finite and non-negative");
        Matrix Xs; vector<double> ys;
        Standardizer s = standardizeFit(X, y, Xs, ys);
        vector<double> betaTilde = ridgeFitStandardized(Xs, ys, lambda);
        toRaw(betaTilde, s, intercept, coef);
        for (double c : coef) if (!isfinite(c))
            throw runtime_error("RidgeRegressor::fit: conversion produced a non-finite coefficient");
        nFeatures = (int)X[0].size();
        fitted = true;
    }
    double predict(const vector<double>& x) const {
        if (!fitted) throw logic_error("RidgeRegressor::predict: model is not fitted");
        if ((int)x.size() != nFeatures)
            throw invalid_argument("RidgeRegressor::predict: expected " + to_string(nFeatures) +
                                   " features, got " + to_string(x.size()));
        double out = intercept;
        for (int j = 0; j < nFeatures; ++j) {
            if (!isfinite(x[j])) throw invalid_argument("RidgeRegressor::predict: non-finite feature at index " + to_string(j));
            out += coef[j] * x[j];
        }
        if (!isfinite(out)) throw runtime_error("RidgeRegressor::predict: prediction became non-finite");
        return out;
    }
    vector<double> predict(const Matrix& X) const {
        vector<double> out; out.reserve(X.size());
        for (const auto& row : X) out.push_back(predict(row));
        return out;
    }
};

static vector<vector<double>> ridgePath(const Matrix& X, const vector<double>& y, const vector<double>& lambdas) {
    Matrix Xs; vector<double> ys;
    Standardizer s = standardizeFit(X, y, Xs, ys);
    vector<vector<double>> path;
    for (double lambda : lambdas) {
        vector<double> betaTilde = ridgeFitStandardized(Xs, ys, lambda);
        double intercept; vector<double> coef;
        toRaw(betaTilde, s, intercept, coef);
        vector<double> row; row.push_back(intercept);
        for (double c : coef) row.push_back(c);
        path.push_back(row);
    }
    return path;
}

// ============================================================
// Elastic Net (Lasso is the alpha=1 special case): cyclic coordinate
// descent with soft-thresholding on standardized data.
//   beta~_j <- S(z_j, lambda*alpha) / (colSq_j + lambda*(1-alpha))
//   z_j = x~_j . (y~ - X~ beta~ + x~_j * beta~_j)     [partial residual]
// colSq_j == n exactly for every standardized column.
// ============================================================
static inline double softThreshold(double z, double gamma) {
    if (z > gamma) return z - gamma;
    if (z < -gamma) return z + gamma;
    return 0.0;
}

// Coordinate descent on already-standardized data. beta is both the
// warm-start input and the result (modified in place).
static int coordinateDescentFit(const Matrix& Xs, const vector<double>& ys, double lambda, double alpha,
                                 vector<double>& beta, int maxIter, double tol,
                                 bool* didConverge = nullptr) {
    int n = (int)Xs.size(), d = (int)Xs[0].size();
    if (didConverge) *didConverge = false;
    vector<double> colSq(d, 0.0);
    for (int j = 0; j < d; ++j) { double s = 0.0; for (int i = 0; i < n; ++i) s += Xs[i][j]*Xs[i][j]; colSq[j] = s; }

    vector<double> resid(n);
    for (int i = 0; i < n; ++i) {
        double pred = 0.0;
        for (int j = 0; j < d; ++j) pred += Xs[i][j] * beta[j];
        resid[i] = ys[i] - pred;
    }

    int iter = 0;
    for (; iter < maxIter; ++iter) {
        double maxDelta = 0.0;
        for (int j = 0; j < d; ++j) {
            if (colSq[j] == 0.0) continue;  // constant column, coefficient stays at its warm-start value (0)
            double zj = 0.0;
            for (int i = 0; i < n; ++i) zj += Xs[i][j] * (resid[i] + Xs[i][j] * beta[j]);
            double newBeta = softThreshold(zj, lambda * alpha) / (colSq[j] + lambda * (1.0 - alpha));
            if (!isfinite(newBeta))
                throw runtime_error("coordinateDescentFit: coefficient became non-finite; check lambda/alpha");
            double delta = newBeta - beta[j];
            if (delta != 0.0) for (int i = 0; i < n; ++i) resid[i] -= Xs[i][j] * delta;
            maxDelta = max(maxDelta, fabs(delta));
            beta[j] = newBeta;
        }
        if (maxDelta < tol) {
            ++iter;
            if (didConverge) *didConverge = true;
            break;
        }
    }
    return iter;
}

struct ElasticNetRegressor {
    bool fitted = false;
    int nFeatures = 0;
    double intercept = 0.0;
    vector<double> coef;
    int iterationsRun = 0;
    bool converged = false;

    void fit(const Matrix& X, const vector<double>& y, double lambda, double alpha,
             int maxIter = 20000, double tol = 1e-9) {
        fitted = false;
        validateFitInput(X, y, "ElasticNetRegressor");
        if (!isfinite(lambda) || lambda < 0.0)
            throw invalid_argument("ElasticNetRegressor::fit: lambda must be finite and non-negative");
        if (!isfinite(alpha) || alpha < 0.0 || alpha > 1.0)
            throw invalid_argument("ElasticNetRegressor::fit: alpha (l1Ratio) must be in [0, 1]");
        if (maxIter <= 0) throw invalid_argument("ElasticNetRegressor::fit: maxIter must be positive");
        if (!isfinite(tol) || tol <= 0.0) throw invalid_argument("ElasticNetRegressor::fit: tol must be finite and positive");

        Matrix Xs; vector<double> ys;
        Standardizer s = standardizeFit(X, y, Xs, ys);
        vector<double> betaTilde(X[0].size(), 0.0);
        bool didConverge = false;
        int iters = coordinateDescentFit(
            Xs, ys, lambda, alpha, betaTilde, maxIter, tol, &didConverge);
        iterationsRun = iters;
        converged = didConverge;
        toRaw(betaTilde, s, intercept, coef);
        for (double c : coef) if (!isfinite(c))
            throw runtime_error("ElasticNetRegressor::fit: conversion produced a non-finite coefficient");
        nFeatures = (int)X[0].size();
        fitted = true;
    }
    double predict(const vector<double>& x) const {
        if (!fitted) throw logic_error("ElasticNetRegressor::predict: model is not fitted");
        if ((int)x.size() != nFeatures)
            throw invalid_argument("ElasticNetRegressor::predict: expected " + to_string(nFeatures) +
                                   " features, got " + to_string(x.size()));
        double out = intercept;
        for (int j = 0; j < nFeatures; ++j) {
            if (!isfinite(x[j])) throw invalid_argument("ElasticNetRegressor::predict: non-finite feature at index " + to_string(j));
            out += coef[j] * x[j];
        }
        if (!isfinite(out)) throw runtime_error("ElasticNetRegressor::predict: prediction became non-finite");
        return out;
    }
    vector<double> predict(const Matrix& X) const {
        vector<double> out; out.reserve(X.size());
        for (const auto& row : X) out.push_back(predict(row));
        return out;
    }
};

// Pathwise fit across a descending sequence of lambdas, warm-starting
// each fit from the previous (larger-lambda) solution.
static vector<vector<double>> elasticNetPath(const Matrix& X, const vector<double>& y,
                                              const vector<double>& lambdasDesc, double alpha,
                                              vector<int>* itersOut = nullptr,
                                              int maxIter = 20000, double tol = 1e-9) {
    Matrix Xs; vector<double> ys;
    Standardizer s = standardizeFit(X, y, Xs, ys);
    int d = (int)X[0].size();
    vector<double> beta(d, 0.0);
    vector<vector<double>> path;
    for (double lambda : lambdasDesc) {
        int iters = coordinateDescentFit(Xs, ys, lambda, alpha, beta, maxIter, tol);
        if (itersOut) itersOut->push_back(iters);
        double intercept; vector<double> coef;
        toRaw(beta, s, intercept, coef);
        vector<double> row; row.push_back(intercept);
        for (double c : coef) row.push_back(c);
        path.push_back(row);
    }
    return path;
}

// ============================================================
// Elastic-net solver diagnostics: objective value, lambda_max, and the
// KKT (subgradient stationarity) violation of a solution. All operate on
// already-standardized data, matching the coordinate-descent solver above.
// ============================================================
static double enetObjective(const Matrix& Xs, const vector<double>& ys,
                            const vector<double>& beta, double lambda, double alpha) {
    int n = (int)Xs.size(), d = (int)Xs[0].size();
    double sse = 0.0;
    for (int i = 0; i < n; ++i) {
        double pred = 0.0;
        for (int j = 0; j < d; ++j) pred += Xs[i][j] * beta[j];
        double e = ys[i] - pred; sse += e * e;
    }
    double l2 = 0.0, l1 = 0.0;
    for (int j = 0; j < d; ++j) { l2 += beta[j] * beta[j]; l1 += fabs(beta[j]); }
    return 0.5 * sse + lambda * (0.5 * (1.0 - alpha) * l2 + alpha * l1);
}

// Smallest lambda that leaves every coefficient at zero (pure-lasso scale):
// from beta=0 the update keeps coord j at zero iff |x~_j . y~| <= lambda*alpha,
// so lambda_max = max_j |x~_j . y~| (taking alpha = 1).
static double lambdaMaxLasso(const Matrix& Xs, const vector<double>& ys) {
    int n = (int)Xs.size(), d = (int)Xs[0].size();
    double m = 0.0;
    for (int j = 0; j < d; ++j) {
        double z = 0.0;
        for (int i = 0; i < n; ++i) z += Xs[i][j] * ys[i];
        m = max(m, fabs(z));
    }
    return m;
}

// Maximum KKT stationarity violation of an elastic-net solution. For each j let
// g_j = x~_j . (y~ - X~ beta). Optimality requires g_j = lambda*alpha*sign(beta_j)
// + lambda*(1-alpha)*beta_j when beta_j != 0, and |g_j| <= lambda*alpha otherwise.
// A value near zero certifies a coordinate-wise optimum.
static double enetKktViolation(const Matrix& Xs, const vector<double>& ys,
                               const vector<double>& beta, double lambda, double alpha) {
    int n = (int)Xs.size(), d = (int)Xs[0].size();
    double worst = 0.0;
    for (int j = 0; j < d; ++j) {
        double g = 0.0;
        for (int i = 0; i < n; ++i) {
            double pred = 0.0;
            for (int k = 0; k < d; ++k) pred += Xs[i][k] * beta[k];
            g += Xs[i][j] * (ys[i] - pred);
        }
        double v;
        if (beta[j] != 0.0) {
            double target = lambda * alpha * (beta[j] > 0.0 ? 1.0 : -1.0) + lambda * (1.0 - alpha) * beta[j];
            v = fabs(g - target);
        } else {
            v = max(0.0, fabs(g) - lambda * alpha);
        }
        worst = max(worst, v);
    }
    return worst;
}

// ============================================================
// Deterministic k-fold cross-validation.
// ============================================================
static vector<vector<int>> kFoldIndices(int n, int k, unsigned seed) {
    vector<int> order(n);
    iota(order.begin(), order.end(), 0);
    mt19937 rng(seed);
    shuffle(order.begin(), order.end(), rng);
    vector<vector<int>> folds(k);
    for (int i = 0; i < n; ++i) folds[i % k].push_back(order[i]);
    return folds;
}

static Matrix rowsOf(const Matrix& X, const vector<int>& idx) { Matrix out; out.reserve(idx.size()); for (int i : idx) out.push_back(X[i]); return out; }
static vector<double> valsOf(const vector<double>& y, const vector<int>& idx) { vector<double> out; out.reserve(idx.size()); for (int i : idx) out.push_back(y[i]); return out; }

static vector<double> cvCurveRidge(const Matrix& X, const vector<double>& y, const vector<double>& lambdas, int k, unsigned seed) {
    int n = (int)X.size();
    vector<vector<int>> folds = kFoldIndices(n, k, seed);
    vector<double> curve(lambdas.size(), 0.0);
    for (size_t li = 0; li < lambdas.size(); ++li) {
        double total = 0.0; int totalCount = 0;
        for (int f = 0; f < k; ++f) {
            vector<int> valIdx = folds[f], trainIdx;
            for (int g = 0; g < k; ++g) if (g != f) for (int i : folds[g]) trainIdx.push_back(i);
            RidgeRegressor model;
            model.fit(rowsOf(X, trainIdx), valsOf(y, trainIdx), lambdas[li]);
            Matrix Xva = rowsOf(X, valIdx); vector<double> yva = valsOf(y, valIdx);
            auto pred = model.predict(Xva);
            for (size_t i = 0; i < yva.size(); ++i) { double e = yva[i]-pred[i]; total += e*e; }
            totalCount += (int)yva.size();
        }
        curve[li] = total / totalCount;
    }
    return curve;
}

static vector<double> cvCurveEnet(const Matrix& X, const vector<double>& y, const vector<double>& lambdas, double alpha, int k, unsigned seed) {
    int n = (int)X.size();
    vector<vector<int>> folds = kFoldIndices(n, k, seed);
    vector<double> curve(lambdas.size(), 0.0);
    for (size_t li = 0; li < lambdas.size(); ++li) {
        double total = 0.0; int totalCount = 0;
        for (int f = 0; f < k; ++f) {
            vector<int> valIdx = folds[f], trainIdx;
            for (int g = 0; g < k; ++g) if (g != f) for (int i : folds[g]) trainIdx.push_back(i);
            ElasticNetRegressor model;
            model.fit(rowsOf(X, trainIdx), valsOf(y, trainIdx), lambdas[li], alpha);
            Matrix Xva = rowsOf(X, valIdx); vector<double> yva = valsOf(y, valIdx);
            auto pred = model.predict(Xva);
            for (size_t i = 0; i < yva.size(); ++i) { double e = yva[i]-pred[i]; total += e*e; }
            totalCount += (int)yva.size();
        }
        curve[li] = total / totalCount;
    }
    return curve;
}

// Per-fold validation MSE for elastic net (same folds and seed as cvCurveEnet),
// used only for the one-standard-error rule; the pooled cvCurveEnet curve above is
// left untouched so its printed numbers do not change.
static vector<vector<double>> cvFoldMseEnet(const Matrix& X, const vector<double>& y, const vector<double>& lambdas, double alpha, int k, unsigned seed) {
    int n = (int)X.size();
    vector<vector<int>> folds = kFoldIndices(n, k, seed);
    vector<vector<double>> foldMse(lambdas.size());
    for (size_t li = 0; li < lambdas.size(); ++li) {
        for (int f = 0; f < k; ++f) {
            vector<int> valIdx = folds[f], trainIdx;
            for (int g = 0; g < k; ++g) if (g != f) for (int i : folds[g]) trainIdx.push_back(i);
            ElasticNetRegressor model;
            model.fit(rowsOf(X, trainIdx), valsOf(y, trainIdx), lambdas[li], alpha);
            Matrix Xva = rowsOf(X, valIdx); vector<double> yva = valsOf(y, valIdx);
            auto pred = model.predict(Xva);
            double sderr = 0.0; for (size_t i = 0; i < yva.size(); ++i) { double e = yva[i]-pred[i]; sderr += e*e; }
            foldMse[li].push_back(sderr / yva.size());
        }
    }
    return foldMse;
}

static int argminIdx(const vector<double>& v) { return (int)(min_element(v.begin(), v.end()) - v.begin()); }

static void printVec(const char* label, const vector<double>& v) {
    printf("%s=[", label);
    for (size_t i = 0; i < v.size(); ++i) printf("%s%.4f", i ? ", " : "", v[i]);
    printf("]");
}

// ============================================================
// main
// ============================================================
int main() {
    // === Scenario A: sanity check against Part 1's OLS on the series' shared dataset ===
    printf("=== Scenario A: sanity check on the shared 2-feature dataset (seed 7) ===\n");
    {
        mt19937 rng(7);
        uniform_real_distribution<double> ux(-3.0, 3.0);
        normal_distribution<double> noise(0.0, 0.6);
        const int N = 240;
        Matrix X; vector<double> y;
        for (int i = 0; i < N; ++i) {
            double x1 = ux(rng), x2 = ux(rng);
            X.push_back({x1, x2});
            y.push_back(3.0 + 2.0*x1 - 1.5*x2 + noise(rng));
        }
        vector<int> order(N); iota(order.begin(), order.end(), 0);
        shuffle(order.begin(), order.end(), rng);
        Matrix Xtr, Xte; vector<double> ytr, yte;
        int nTrain = (int)(0.8 * N);
        for (int k = 0; k < N; ++k) {
            int i = order[k];
            if (k < nTrain) { Xtr.push_back(X[i]); ytr.push_back(y[i]); }
            else            { Xte.push_back(X[i]); yte.push_back(y[i]); }
        }
        OLSFit ols = fitOLSDirect(Xtr, ytr);
        printf("[OLS]      intercept=%.4f  coef=[%.4f, %.4f]\n", ols.intercept, ols.coef[0], ols.coef[1]);

        RidgeRegressor ridge; ridge.fit(Xtr, ytr, 1e-6);
        printf("[Ridge]    intercept=%.4f  coef=[%.4f, %.4f]  (lambda=1e-6)\n", ridge.intercept, ridge.coef[0], ridge.coef[1]);

        ElasticNetRegressor lasso; lasso.fit(Xtr, ytr, 1e-6, 1.0);
        printf("[Lasso]    intercept=%.4f  coef=[%.4f, %.4f]  (lambda=1e-6, iters=%d)\n",
               lasso.intercept, lasso.coef[0], lasso.coef[1], lasso.iterationsRun);

        auto predRidge = ridge.predict(Xte);
        auto predLasso = lasso.predict(Xte);
        printf("[Ridge]    test RMSE=%.4f  test R^2=%.4f\n", rmse(yte, predRidge), r2(yte, predRidge));
        printf("[Lasso]    test RMSE=%.4f  test R^2=%.4f\n", rmse(yte, predLasso), r2(yte, predLasso));
    }

    // === Scenario B: collinear duplicate + irrelevant noise features ===
    printf("\n=== Scenario B: collinear duplicate + 3 irrelevant features (n=200, d=6) ===\n");
    Matrix Xb; vector<double> yb;
    {
        mt19937 rng(2026);
        uniform_real_distribution<double> ux(-3.0, 3.0);
        normal_distribution<double> dupNoise(0.0, 0.15);
        normal_distribution<double> irrelevant(0.0, 1.0);
        normal_distribution<double> yNoise(0.0, 0.6);
        const int N = 200;
        for (int i = 0; i < N; ++i) {
            double x1 = ux(rng), x2 = ux(rng);
            double x3 = x1 + dupNoise(rng);
            double x4 = irrelevant(rng), x5 = irrelevant(rng), x6 = irrelevant(rng);
            Xb.push_back({x1, x2, x3, x4, x5, x6});
            yb.push_back(3.0 + 2.0*x1 - 1.5*x2 + yNoise(rng));
        }
    }
    printf("corr(x1,x3) = %.6f\n", [&]{
        double m1=0,m3=0; int n=(int)Xb.size();
        for (auto&r:Xb){m1+=r[0];m3+=r[2];} m1/=n; m3/=n;
        double cov=0,v1=0,v3=0;
        for (auto&r:Xb){cov+=(r[0]-m1)*(r[2]-m3); v1+=(r[0]-m1)*(r[0]-m1); v3+=(r[2]-m3)*(r[2]-m3);}
        return cov/sqrt(v1*v3);
    }());

    OLSFit olsB = fitOLSDirect(Xb, yb);
    printf("[OLS]        intercept=%.4f  ", olsB.intercept); printVec("coef", olsB.coef); printf("\n");

    vector<double> ridgeLambdas = {0.01, 0.03, 0.1, 0.3, 1, 3, 10, 30, 100, 300};
    vector<double> enetLambdas  = {0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50};

    auto ridgeCv = cvCurveRidge(Xb, yb, ridgeLambdas, 5, 123);
    auto lassoCv = cvCurveEnet(Xb, yb, enetLambdas, 1.0, 5, 123);
    auto enetCv  = cvCurveEnet(Xb, yb, enetLambdas, 0.5, 5, 123);

    printf("\n-- 5-fold CV mean-squared error (Ridge) --\n");
    for (size_t i = 0; i < ridgeLambdas.size(); ++i) printf("  lambda=%-8g cvMSE=%.5f\n", ridgeLambdas[i], ridgeCv[i]);
    int ridgeBest = argminIdx(ridgeCv);
    printf("-- 5-fold CV mean-squared error (Lasso, alpha=1.0) --\n");
    for (size_t i = 0; i < enetLambdas.size(); ++i) printf("  lambda=%-8g cvMSE=%.5f\n", enetLambdas[i], lassoCv[i]);
    int lassoBest = argminIdx(lassoCv);
    printf("-- 5-fold CV mean-squared error (Elastic Net, alpha=0.5) --\n");
    for (size_t i = 0; i < enetLambdas.size(); ++i) printf("  lambda=%-8g cvMSE=%.5f\n", enetLambdas[i], enetCv[i]);
    int enetBest = argminIdx(enetCv);

    printf("\nCV-selected lambda: Ridge=%g  Lasso=%g  ElasticNet(0.5)=%g\n",
           ridgeLambdas[ridgeBest], enetLambdas[lassoBest], enetLambdas[enetBest]);

    RidgeRegressor ridgeB; ridgeB.fit(Xb, yb, ridgeLambdas[ridgeBest]);
    ElasticNetRegressor lassoB; lassoB.fit(Xb, yb, enetLambdas[lassoBest], 1.0);
    ElasticNetRegressor enetB; enetB.fit(Xb, yb, enetLambdas[enetBest], 0.5);

    printf("\n[Ridge, CV]      intercept=%.4f  ", ridgeB.intercept); printVec("coef", ridgeB.coef); printf("\n");
    printf("[Lasso, CV]      intercept=%.4f  ", lassoB.intercept); printVec("coef", lassoB.coef);
    printf("  (iters=%d, converged=%s)\n", lassoB.iterationsRun, lassoB.converged ? "true" : "false");
    printf("[ElasticNet, CV] intercept=%.4f  ", enetB.intercept); printVec("coef", enetB.coef);
    printf("  (iters=%d, converged=%s)\n", enetB.iterationsRun, enetB.converged ? "true" : "false");

    auto predOls  = [&]{ vector<double> p; for (auto&r:Xb) { double v=olsB.intercept; for(int j=0;j<6;++j) v+=olsB.coef[j]*r[j]; p.push_back(v); } return p; }();
    printf("\n[OLS]            train RMSE=%.4f  train R^2=%.4f\n", rmse(yb, predOls), r2(yb, predOls));
    printf("[Ridge, CV]      train RMSE=%.4f  train R^2=%.4f\n", rmse(yb, ridgeB.predict(Xb)), r2(yb, ridgeB.predict(Xb)));
    printf("[Lasso, CV]      train RMSE=%.4f  train R^2=%.4f\n", rmse(yb, lassoB.predict(Xb)), r2(yb, lassoB.predict(Xb)));
    printf("[ElasticNet, CV] train RMSE=%.4f  train R^2=%.4f\n", rmse(yb, enetB.predict(Xb)), r2(yb, enetB.predict(Xb)));

    // Held-out clean test set from the same true model (independent RNG, so the
    // n=200 training data above is unchanged); measures recovery, not training fit.
    printf("\n-- Held-out test metrics on an independent clean test set (n=100) --\n");
    Matrix Xtest; vector<double> ytest;
    {
        mt19937 rng(9001);
        uniform_real_distribution<double> ux(-3.0, 3.0);
        normal_distribution<double> dupNoise(0.0, 0.15);
        normal_distribution<double> irrelevant(0.0, 1.0);
        normal_distribution<double> yNoise(0.0, 0.6);
        for (int i = 0; i < 100; ++i) {
            double x1 = ux(rng), x2 = ux(rng);
            double x3 = x1 + dupNoise(rng);
            double x4 = irrelevant(rng), x5 = irrelevant(rng), x6 = irrelevant(rng);
            Xtest.push_back({x1, x2, x3, x4, x5, x6});
            ytest.push_back(3.0 + 2.0*x1 - 1.5*x2 + yNoise(rng));
        }
    }
    auto predOlsTe = [&]{ vector<double> p; for (auto&r:Xtest){ double v=olsB.intercept; for(int j=0;j<6;++j) v+=olsB.coef[j]*r[j]; p.push_back(v);} return p; }();
    printf("[OLS]            test RMSE=%.4f  test R^2=%.4f\n", rmse(ytest, predOlsTe), r2(ytest, predOlsTe));
    printf("[Ridge, CV]      test RMSE=%.4f  test R^2=%.4f\n", rmse(ytest, ridgeB.predict(Xtest)), r2(ytest, ridgeB.predict(Xtest)));
    printf("[Lasso, CV]      test RMSE=%.4f  test R^2=%.4f\n", rmse(ytest, lassoB.predict(Xtest)), r2(ytest, lassoB.predict(Xtest)));
    printf("[ElasticNet, CV] test RMSE=%.4f  test R^2=%.4f\n", rmse(ytest, enetB.predict(Xtest)), r2(ytest, enetB.predict(Xtest)));

    // Coefficient paths: ridge (shrink-and-group) vs lasso (shrink-to-zero).
    printf("\n-- Ridge coefficient path (shrink toward zero, grouping on x1/x3) --\n");
    auto rPath = ridgePath(Xb, yb, ridgeLambdas);
    for (size_t i = 0; i < ridgeLambdas.size(); ++i) {
        printf("  lambda=%-8g intercept=%.4f  coef=[%.4f, %.4f, %.4f, %.4f, %.4f, %.4f]\n",
               ridgeLambdas[i], rPath[i][0], rPath[i][1], rPath[i][2], rPath[i][3], rPath[i][4], rPath[i][5], rPath[i][6]);
    }

    printf("\n-- Lasso coefficient path, warm-started high-to-low lambda (shrink to exact zero) --\n");
    vector<double> lassoDesc = enetLambdas; sort(lassoDesc.rbegin(), lassoDesc.rend());
    vector<int> pathIters;
    auto lPath = elasticNetPath(Xb, yb, lassoDesc, 1.0, &pathIters);
    for (size_t i = 0; i < lassoDesc.size(); ++i) {
        printf("  lambda=%-8g intercept=%.4f  coef=[%.4f, %.4f, %.4f, %.4f, %.4f, %.4f]  iters=%d\n",
               lassoDesc[i], lPath[i][0], lPath[i][1], lPath[i][2], lPath[i][3], lPath[i][4], lPath[i][5], lPath[i][6], pathIters[i]);
    }
    int totalWarm = 0; for (int it : pathIters) totalWarm += it;
    vector<int> coldIters;
    for (double lam : lassoDesc) {
        Matrix Xs; vector<double> ys; Standardizer s = standardizeFit(Xb, yb, Xs, ys);
        vector<double> beta(6, 0.0);
        coldIters.push_back(coordinateDescentFit(Xs, ys, lam, 1.0, beta, 20000, 1e-9));
    }
    int totalCold = 0; for (int it : coldIters) totalCold += it;
    printf("Total iterations across the path: warm-started=%d, cold-started=%d\n", totalWarm, totalCold);

    // Solver diagnostics on the lasso path: lambda_max, objective history, KKT, one-SE rule.
    printf("\n-- Solver diagnostics (lasso, alpha=1) --\n");
    {
        Matrix Xs; vector<double> ys;
        standardizeFit(Xb, yb, Xs, ys);

        double lmax = lambdaMaxLasso(Xs, ys);
        vector<double> bzero(6, 0.0);
        coordinateDescentFit(Xs, ys, lmax, 1.0, bzero, 20000, 1e-9);
        int nzAtMax = 0; for (double c : bzero) if (c != 0.0) ++nzAtMax;
        printf("lambda_max = max_j |x~_j . y~| = %.4f; the fit at lambda_max keeps %d of 6 coefficients nonzero\n", lmax, nzAtMax);

        printf("objective J(lambda=5) by sweep: ");
        for (int m = 1; m <= 3; ++m) {
            vector<double> bm(6, 0.0);
            coordinateDescentFit(Xs, ys, 5.0, 1.0, bm, m, 1e-9);
            printf("J%d=%.4f  ", m, enetObjective(Xs, ys, bm, 5.0, 1.0));
        }
        vector<double> bfin(6, 0.0);
        coordinateDescentFit(Xs, ys, 5.0, 1.0, bfin, 20000, 1e-9);
        printf("J_final=%.4f (monotone non-increasing)\n", enetObjective(Xs, ys, bfin, 5.0, 1.0));
        printf("KKT max violation at CV-selected lambda=5: %.2e (certifies a coordinate-wise optimum)\n",
               enetKktViolation(Xs, ys, bfin, 5.0, 1.0));
    }
    {
        vector<vector<double>> foldMse = cvFoldMseEnet(Xb, yb, enetLambdas, 1.0, 5, 123);
        double mean = 0.0; for (double v : foldMse[lassoBest]) mean += v; mean /= (double)foldMse[lassoBest].size();
        double var = 0.0; for (double v : foldMse[lassoBest]) var += (v-mean)*(v-mean); var /= (double)(foldMse[lassoBest].size()-1);
        double se = sqrt(var) / sqrt((double)foldMse[lassoBest].size());
        double thresh = lassoCv[lassoBest] + se;
        double lam1se = enetLambdas[lassoBest];
        for (size_t i = 0; i < enetLambdas.size(); ++i) if (lassoCv[i] <= thresh && enetLambdas[i] > lam1se) lam1se = enetLambdas[i];
        printf("one-SE rule: min cvMSE %.5f at lambda=%g, fold SE %.5f; largest lambda within one SE = %g\n",
               lassoCv[lassoBest], enetLambdas[lassoBest], se, lam1se);
    }

    // Cross-check: ElasticNet(alpha=0) must agree with the closed-form Ridge at the same lambda.
    printf("\n-- Cross-check: ElasticNet(alpha=0) vs closed-form Ridge, lambda=10 --\n");
    ElasticNetRegressor enetAsRidge; enetAsRidge.fit(Xb, yb, 10.0, 0.0, 20000, 1e-12);
    RidgeRegressor ridgeDirect; ridgeDirect.fit(Xb, yb, 10.0);
    printf("[ElasticNet a=0] intercept=%.6f  ", enetAsRidge.intercept); printVec("coef", enetAsRidge.coef);
    printf("  (iters=%d)\n", enetAsRidge.iterationsRun);
    printf("[Ridge closed]   intercept=%.6f  ", ridgeDirect.intercept); printVec("coef", ridgeDirect.coef); printf("\n");

    // Error-handling demonstrations.
    printf("\n-- Error handling --\n");
    try { RidgeRegressor bad; bad.fit(Xb, yb, -1.0); }
    catch (const invalid_argument& e) { printf("caught: %s\n", e.what()); }
    try { ElasticNetRegressor bad; bad.fit(Xb, yb, 1.0, 1.5); }
    catch (const invalid_argument& e) { printf("caught: %s\n", e.what()); }
    try { RidgeRegressor m; m.fit(Xb, yb, 1.0); m.predict(vector<double>{1.0, 2.0}); }
    catch (const invalid_argument& e) { printf("caught: %s\n", e.what()); }

    return 0;
}

Compiled with g++ -O2 -std=c++17 -Wall -Wextra -pedantic, this prints:

=== Scenario A: sanity check on the shared 2-feature dataset (seed 7) === [OLS] intercept=2.9639 coef=[1.9777, -1.5173] [Ridge] intercept=2.9639 coef=[1.9777, -1.5173] (lambda=1e-6) [Lasso] intercept=2.9639 coef=[1.9777, -1.5173] (lambda=1e-6, iters=6) [Ridge] test RMSE=0.5638 test R^2=0.9813 [Lasso] test RMSE=0.5638 test R^2=0.9813 === Scenario B: collinear duplicate + 3 irrelevant features (n=200, d=6) === corr(x1,x3) = 0.996521 [OLS] intercept=2.9468 coef=[1.8355, -1.5394, 0.1811, 0.0013, 0.0013, -0.0113] -- 5-fold CV mean-squared error (Ridge) -- lambda=0.01 cvMSE=0.40520 lambda=0.03 cvMSE=0.40491 lambda=0.1 cvMSE=0.40432 lambda=0.3 cvMSE=0.40430 lambda=1 cvMSE=0.40699 lambda=3 cvMSE=0.41331 lambda=10 cvMSE=0.45341 lambda=30 cvMSE=0.73334 lambda=100 cvMSE=2.45607 lambda=300 cvMSE=6.94182 -- 5-fold CV mean-squared error (Lasso, alpha=1.0) -- lambda=0.05 cvMSE=0.40495 lambda=0.1 cvMSE=0.40478 lambda=0.2 cvMSE=0.40446 lambda=0.5 cvMSE=0.40350 lambda=1 cvMSE=0.40209 lambda=2 cvMSE=0.39976 lambda=5 cvMSE=0.39667 lambda=10 cvMSE=0.39987 lambda=20 cvMSE=0.42376 lambda=50 cvMSE=0.59943 -- 5-fold CV mean-squared error (Elastic Net, alpha=0.5) -- lambda=0.05 cvMSE=0.40490 lambda=0.1 cvMSE=0.40453 lambda=0.2 cvMSE=0.40400 lambda=0.5 cvMSE=0.40350 lambda=1 cvMSE=0.40389 lambda=2 cvMSE=0.40517 lambda=5 cvMSE=0.40932 lambda=10 cvMSE=0.42336 lambda=20 cvMSE=0.48259 lambda=50 cvMSE=0.85667 CV-selected lambda: Ridge=0.3 Lasso=5 ElasticNet(0.5)=0.5 [Ridge, CV] intercept=2.9462 coef=[1.5747, -1.5353, 0.4392, 0.0063, 0.0038, -0.0172] [Lasso, CV] intercept=2.9480 coef=[1.8292, -1.5254, 0.1713, 0.0000, 0.0000, 0.0000] (iters=1955, converged=true) [ElasticNet, CV] intercept=2.9465 coef=[1.6078, -1.5353, 0.4056, 0.0044, 0.0018, -0.0152] (iters=1694, converged=true) [OLS] train RMSE=0.6113 train R^2=0.9814 [Ridge, CV] train RMSE=0.6125 train R^2=0.9813 [Lasso, CV] train RMSE=0.6125 train R^2=0.9813 [ElasticNet, CV] train RMSE=0.6122 train R^2=0.9813 -- Held-out test metrics on an independent clean test set (n=100) -- [OLS] test RMSE=0.6197 test R^2=0.9793 [Ridge, CV] test RMSE=0.6268 test R^2=0.9788 [Lasso, CV] test RMSE=0.6178 test R^2=0.9794 [ElasticNet, CV] test RMSE=0.6254 test R^2=0.9789 -- Ridge coefficient path (shrink toward zero, grouping on x1/x3) -- lambda=0.01 intercept=2.9468 coef=[1.8231, -1.5392, 0.1935, 0.0015, 0.0014, -0.0115] lambda=0.03 intercept=2.9467 coef=[1.7992, -1.5389, 0.2171, 0.0019, 0.0016, -0.0121] lambda=0.1 intercept=2.9465 coef=[1.7257, -1.5379, 0.2899, 0.0034, 0.0024, -0.0138] lambda=0.3 intercept=2.9462 coef=[1.5747, -1.5353, 0.4392, 0.0063, 0.0038, -0.0172] lambda=1 intercept=2.9460 coef=[1.3321, -1.5283, 0.6769, 0.0111, 0.0054, -0.0223] lambda=3 intercept=2.9470 coef=[1.1463, -1.5114, 0.8505, 0.0154, 0.0042, -0.0246] lambda=10 intercept=2.9518 coef=[1.0289, -1.4582, 0.9285, 0.0202, -0.0053, -0.0201] lambda=30 intercept=2.9643 coef=[0.9444, -1.3257, 0.9103, 0.0276, -0.0294, -0.0043] lambda=100 intercept=2.9931 coef=[0.7910, -1.0049, 0.7809, 0.0396, -0.0744, 0.0309] lambda=300 intercept=3.0258 coef=[0.5538, -0.5916, 0.5502, 0.0419, -0.0951, 0.0574] -- Lasso coefficient path, warm-started high-to-low lambda (shrink to exact zero) -- lambda=50 intercept=2.9577 coef=[1.6450, -1.3964, 0.2190, 0.0000, 0.0000, 0.0000] iters=2049 lambda=20 intercept=2.9513 coef=[1.7678, -1.4824, 0.1872, 0.0000, 0.0000, 0.0000] iters=1884 lambda=10 intercept=2.9491 coef=[1.8088, -1.5110, 0.1766, 0.0000, 0.0000, 0.0000] iters=1726 lambda=5 intercept=2.9480 coef=[1.8292, -1.5254, 0.1713, 0.0000, 0.0000, 0.0000] iters=1626 lambda=2 intercept=2.9474 coef=[1.8415, -1.5340, 0.1681, 0.0000, 0.0000, 0.0000] iters=1552 lambda=1 intercept=2.9472 coef=[1.8395, -1.5367, 0.1736, 0.0000, 0.0000, -0.0055] iters=1646 lambda=0.5 intercept=2.9471 coef=[1.8383, -1.5380, 0.1766, 0.0000, 0.0000, -0.0084] iters=1556 lambda=0.2 intercept=2.9470 coef=[1.8373, -1.5388, 0.1786, 0.0002, 0.0000, -0.0101] iters=1518 lambda=0.1 intercept=2.9469 coef=[1.8365, -1.5391, 0.1798, 0.0007, 0.0006, -0.0107] iters=1456 lambda=0.05 intercept=2.9469 coef=[1.8360, -1.5392, 0.1805, 0.0010, 0.0009, -0.0110] iters=1359 Total iterations across the path: warm-started=16372, cold-started=20132 -- Solver diagnostics (lasso, alpha=1) -- lambda_max = max_j |x~_j . y~| = 675.6686; the fit at lambda_max keeps 0 of 6 coefficients nonzero objective J(lambda=5) by sweep: J1=69.5524 J2=69.5376 J3=69.5375 J_final=69.5294 (monotone non-increasing) KKT max violation at CV-selected lambda=5: 1.97e-07 (certifies a coordinate-wise optimum) one-SE rule: min cvMSE 0.39667 at lambda=5, fold SE 0.04041; largest lambda within one SE = 20 -- Cross-check: ElasticNet(alpha=0) vs closed-form Ridge, lambda=10 -- [ElasticNet a=0] intercept=2.951789 coef=[1.0289, -1.4582, 0.9285, 0.0202, -0.0053, -0.0201] (iters=248) [Ridge closed] intercept=2.951789 coef=[1.0289, -1.4582, 0.9285, 0.0202, -0.0053, -0.0201] -- Error handling -- caught: RidgeRegressor::fit: lambda must be finite and non-negative caught: ElasticNetRegressor::fit: alpha (l1Ratio) must be in [0, 1] caught: RidgeRegressor::predict: expected 6 features, got 2

Reading the Two Scenarios

Scenario A confirms correctness before anything interesting happens: with $\lambda$ effectively switched off, ridge and lasso reproduce Part 1's OLS coefficients ($2.9639$, $1.9777$, $-1.5173$) to four decimal places, and lasso does it in only $6$ coordinate sweeps because there is no collinearity to slow it down. This is the same kind of sanity check the QR/SVD post ran against OLS: different algorithm, same answer, when the underlying objective is the same.

Scenario B is where the three penalties diverge. OLS splits the near-duplicate pair unevenly and unstably: $1.8355$ on $x_1$ and $0.1811$ on $x_3$, summing to about $2.02$, close to the combined true effect of $2.0$, but the individual split is an artifact of exactly which noise realization landed in the training data (the same identifiability argument the SVD post made for exact rank deficiency applies here in a softer form for near rank deficiency). Cross-validated ridge ($\lambda=0.3$) gives $1.5747$ and $0.4392$, only slightly more even than OLS at this small a $\lambda$; the coefficient path table above shows the grouping effect much more clearly at larger $\lambda$: by $\lambda=10$, the split is $1.0289$ and $0.9285$, nearly equal, and it stays nearly equal all the way to $\lambda=300$. This is ridge's signature behavior under collinearity: correlated features are shrunk together, toward a shared value, rather than arbitrarily apart. Cross-validated lasso ($\lambda=5$) keeps the noise features at exactly $0.0000$ and puts most of the weight on $x_1$ ($1.8292$) while leaving $x_3$ small but nonzero ($0.1713$); the lasso path table shows this pattern holds across the whole range of $\lambda$, lasso does not group the duplicated pair evenly the way ridge does, it just picks a dominant one. Cross-validated elastic net ($\lambda=0.5,\ \alpha=0.5$) lands in between, $1.6078$ and $0.4056$, closer to ridge's even split than to lasso's lopsided one, while still shrinking the noise coefficients ($0.0044,\ 0.0018,\ -0.0152$) more than ridge does at a comparable $\lambda$.

Worth noting honestly: the $\lambda$ that cross-validation selects for prediction accuracy is not the same $\lambda$ that produces the cleanest, most interpretable coefficient pattern. The CV curves are fairly flat across a wide range of $\lambda$ for this dataset (ridge's cvMSE only moves from $0.4052$ to $0.4043$ between $\lambda=0.01$ and its $\lambda=0.3$ optimum), because the noise level ($\sigma=0.6$) is large enough that heavy shrinkage costs almost nothing in validation error, but also does not buy much. A slightly larger $\lambda$ than the CV optimum, chosen deliberately from the path table rather than from the CV curve, sacrifices negligible test accuracy for a substantially sparser or more evenly grouped model, a legitimate reason to look at the whole path rather than only the single CV-selected point. The closing cross-check, ElasticNetRegressor with $\alpha=0$ against the closed-form RidgeRegressor at the same $\lambda=10$, matches to six decimal places ($2.951789$ intercept, identical coefficients down to the last printed digit), confirming the iterative solver and the closed form are minimizing the same objective.

The held-out numbers keep that comparison honest. Evaluated on an independent $100$-row clean test set drawn from the same model, cross-validated lasso posts the best test RMSE ($0.6178$), just ahead of OLS ($0.6197$), with ridge ($0.6268$) and elastic net ($0.6254$) a little behind at their CV-selected $\lambda$. The gaps are small because the noise floor ($\sigma=0.6$) dominates, but lasso's edge is exactly the payoff of setting the three irrelevant coefficients to zero instead of fitting them to noise. The solver diagnostics printed alongside, $\lambda_{\max}\approx675.67$, a monotone objective history, a KKT violation of $1.97\times10^{-7}$, and a one-SE $\lambda$ of $20$, together certify that each reported fit is a real optimum of its stated objective and quantify how much simpler a model the one-standard-error rule would accept.

When to Use Each

SituationRecommended penaltyReason
Features correlated but all plausibly relevantRidgeShrinks correlated features together; never discards one arbitrarily.
Many candidate features, most likely irrelevantLassoDrives irrelevant coefficients to exactly zero; the result is a sparse, interpretable model.
Groups of correlated features, some groups relevantElastic netSelects like lasso across groups while grouping like ridge within a correlated cluster.
Need an exact, closed-form, always-invertible solveRidgeThe only one of the three with no iterative solver in the loop.
Heavy-tailed noise or outliers dominate the residualsNeither (see next post)All three penalties still minimize squared error at their core; the loss itself needs to change.

Complexity and Memory

StepRidgeElastic Net / Lasso
Standardization$O(nd)$$O(nd)$
Normal-equation assembly and solve$O(nd^2)$ assembly, $O(d^3)$ solven/a
One coordinate sweepn/a$O(nd)$
Full fit$O(nd^2+d^3)$, one shot$O(s\cdot nd)$, $s$ = sweeps to convergence
$k$-fold CV over $m$ values of $\lambda$$O\!\left(km(n_f d^2+d^3)\right)$$O(kmsn_fd)$; warm starts can reduce $s$, but all $m$ solutions are still fitted

Ridge's cost is fixed per fit regardless of how the data behaves. Coordinate descent's cost is data-dependent: well-separated, weakly-correlated features converge in a handful of sweeps (Scenario A's $6$), while strongly collinear ones can need thousands (Scenario B's lasso path, $1300$-$2000$ sweeps per $\lambda$), because the per-sweep contraction rate for cyclic coordinate descent degrades as the condition number of the correlated block grows. Warm-starting the path amortizes this cost across $\lambda$ values but does not eliminate the underlying slow direction.

Practical Diagnostics

Limitations and Edge Cases

Next Step

Every solver in this series so far, penalized or not, has minimized some form of squared error, which is the right loss when noise is roughly symmetric and light-tailed but is exactly the wrong loss when a handful of extreme observations should not be allowed to dominate the fit. The next post reuses the weighted-least-squares idea underneath this post's ridge normal equations, a fixed diagonal penalty $\Lambda$ folded into $X_b^\top X_b$ turns out to generalize naturally to a diagonal reweighting $X_b^\top W X_b$ that changes every iteration, but replaces the loss itself: Huber loss for outlier-resistant fitting, and the pinball loss for quantile regression, both fit by iteratively reweighted least squares rather than coordinate descent.