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

Linear Regression from Scratch in C++

This sub-series builds the linear-regression family in standard-library C++: no external linear-algebra dependency, only portable standard headers and vector<vector<double>>. It starts from the closed-form ordinary least squares solution and extends outward to numerically robust solvers, iterative optimization, regularized variants, robust losses, and diagnostics. The companion Machine Learning · Linear Regression series covers the conceptual treatment, projection geometry, statistical assumptions, and inference; this sub-series turns that theory into working, validated code.

Series Scope

Every post here compiles with a standard C++17 compiler and nothing else. There is no BLAS, no Eigen, no external matrix library: matrices are vector<vector<double>>, and every numerical routine, elimination, decomposition, or gradient step, is written out explicitly. That is a deliberate trade: production code should reach for a tuned linear-algebra library, but writing the routines by hand is how the normal equations, QR, SVD, and gradient-based solvers stop being black boxes.

The roadmap below covers six posts: the closed-form solution, two numerically safer solvers, an iterative alternative that scales to large $n$, penalized variants for collinear or high-dimensional data, robust and quantile variants for non-Gaussian errors, and a shared set of diagnostics that apply across all of them.

The Data and Model Contract

Every post in this sub-series fits the same shape of problem, so the interface never changes even as the solver underneath does:

using Matrix = vector<vector<double>>;

// X: n rows (samples) x d columns (features). No bias column.
// y: n regression targets, one per row of X.
// Every row of X must have exactly d entries, and n must equal y.size().
SymbolShapeMeaning
$n$scalarnumber of training rows
$d$scalarnumber of feature columns supplied by the caller
$X$$n \times d$raw feature matrix, no bias column
$y$$n$regression targets
$\beta_0$scalarintercept, owned by the model, never supplied by the caller
$\beta_{1..d}$$d$feature coefficients

A prediction is $\hat y = \beta_0 + \sum_{j=1}^{d}\beta_j x_j$ for every variant in the series; what changes from post to post is how $\beta$ is estimated, not what it means once found.

Handling the Intercept

The intercept is handled the same way in every post: the model owns it. Callers pass a raw $n \times d$ matrix with no column of ones; each fit implementation augments the problem internally, either by folding an implicit ones-column into the normal equations directly, as the ordinary least squares post does, or by centering $X$ and $y$ before optimizing and recovering the intercept from the means afterward, as the gradient-based and regularized posts do. Either way, the public contract is identical: fit(X, y) takes only the raw feature matrix, and the fitted model exposes a separate intercept alongside a coef vector of length $d$.

Keeping the intercept out of the public matrix avoids a common bug class: an intercept column of ones that a caller forgets to add, adds twice, or scales along with the other features during preprocessing. Because the series' data-preprocessing sub-series scales and encodes features before they reach any model here, an implicit intercept means preprocessing never has to special-case a bias column.

A Coherent Standard-Library Architecture

Every regressor in the series is a plain struct with two public entry points and a small set of validated fields, matching the pattern already used for the regression tree and GBM posts elsewhere in the from-scratch series:

struct SomeRegressor {
    bool fitted = false;
    int nFeatures = 0;
    double intercept = 0.0;
    vector<double> coef;          // length nFeatures once fitted

    void fit(const Matrix& X, const vector<double>& y);
    double predict(const vector<double>& x) const;
    vector<double> predict(const Matrix& X) const;
};

Exceptions follow one convention across every post: std::invalid_argument for a dimension or shape problem the caller could have caught before calling, std::runtime_error for a solver that failed on otherwise well-shaped input (a singular matrix, a non-converging iteration), and std::logic_error for calling predict before fit. None of the posts in this series swallow an exception with a broad catch or silently fall back to a default; a caller that wants a fallback makes that decision explicitly at its own call site.

The fit / predict / evaluate Lifecycle

Every post follows the same three-stage lifecycle:

Because every post shares this lifecycle, swapping OLSRegressor for a ridge or gradient-descent regressor later in the series is a one-line change at the call site: the shape of X, the shape of y, and the meaning of intercept and coef never move.

Solver and Variant Roadmap

Ordinary Least Squares

The closed-form baseline: the normal equations, solved directly rather than by explicit inversion.

  • Formation of $A = X_b^\top X_b$ and $b = X_b^\top y$ without materializing the design matrix.
  • A partial-pivoting Gaussian solve for $A\beta = b$.
  • Strong dimension checks and an explicit singular-matrix exception.

QR and SVD Least-Squares Solvers

Numerically safer alternatives for when the normal equations are too sensitive.

  • Direct operation on $X_b$ instead of the squared-condition-number $X_b^\top X_b$.
  • Graceful handling of rank-deficient and near-collinear feature sets.

Gradient Descent

An iterative alternative that scales to large $n$ or large $d$ where a direct solve becomes expensive.

  • Batch, mini-batch, and stochastic updates on the same squared-error objective.
  • Learning-rate and convergence behavior on the same synthetic dataset used for OLS.

Ridge, Lasso, and Elastic Net

Penalized coefficient estimation for collinear features or more predictors than is comfortable.

  • Ridge as a small, always-invertible modification of the OLS normal equations.
  • Lasso and Elastic Net, where the penalty needs an iterative solver.

Robust and Quantile Regression

Replacing squared loss so a handful of extreme points cannot dominate the fit.

  • Huber loss as a blend of squared and absolute error.
  • Pinball loss for estimating a conditional quantile rather than the conditional mean.

Diagnostics and Metrics

The evaluation layer shared by every variant above.

  • Residual checks, leverage, and influence.
  • $R^2$, adjusted $R^2$, and when each is the honest number to report.

Numerical Reliability

The normal equations square the condition number: $\operatorname{cond}(X_b^\top X_b) = \operatorname{cond}(X_b)^2$. For well-scaled features with no severe collinearity and a modest number of parameters, that is a non-issue and the direct solve in the OLS post is both the simplest and the fastest option. As features become more correlated, more poorly scaled, or more numerous relative to the number of rows, the squared condition number can erase real precision before elimination even starts. That is the entire motivation for the QR and SVD solvers post: same public interface, same $\hat y = \beta_0 + \sum_j \beta_j x_j$ prediction rule, a numerically steadier way of finding $\beta$.

Why Each Later Post Matters

PostRelevant when…
Ordinary Least Squares$d$ is modest and the features are reasonably well-conditioned; the direct solve is the fastest correct answer.
QR and SVD Solversfeatures are correlated or poorly scaled and the normal equations lose too much precision.
Gradient Descent$n$ or $d$ is large enough that forming $X_b^\top X_b$ directly is too expensive, or the data arrives incrementally.
Ridge, Lasso, Elastic Netfeatures are collinear, redundant, or more numerous than is comfortable relative to $n$.
Robust and Quantile Regressionthe target has heavy-tailed noise or outliers that squared loss would over-weight.
Diagnostics and Metricsa fitted model needs to be checked, not just trusted, before it is reported or shipped.
From scratch does not mean isolated. Every post reuses the same Matrix type, the same mse / rmse / r2 free functions, and the same fit/predict shape. Only the estimation method inside fit changes from post to post.