Back to the course
Statistics & Regression · Module 08 of 10

Multiple Regression

Several predictors at once, "holding the others constant" — the matrix form b = (XᵀX)⁻¹Xᵀy, built from scratch, with standard errors, R², adjusted R², and the F-test.

Build
Matrix tools and a Cholesky solver
Solve
Full OLS with standard errors, the F-test, dummy variables
The finish line — what go test shows once every exercise in this module passes
$ go test -v ./08-multiple-regression/ | grep '^--- ' --- PASS: TestTransposeMul (0.00s) --- PASS: TestCholesky (0.00s) --- PASS: TestSolveCholesky (0.00s) --- PASS: TestInverseSPD (0.00s) --- PASS: TestDesignMatrix (0.00s) --- PASS: TestFit (0.00s) --- PASS: TestPredictAndTests (0.00s) --- PASS: TestDummies (0.00s)
Module 8 · tests 8
WHY

Why it matters.

Real outcomes have more than one cause. A house's price depends on its size, its age, its neighbourhood; a salary on experience, education, and role. Multiple regression estimates each predictor's effect while holding the others fixed, which is the closest thing to a controlled experiment you can get from observational data. It's the model behind most economics, epidemiology, and pricing work, and the foundation of the machine-learning models that came after it.

IDEAS

The core ideas.

The model. ŷ = b₀ + b₁x₁ + b₂x₂ + … + b_k·x_k. Each coefficient b_j is the change in the prediction when x_j rises by one unit and every other predictor stays the same. That "holding constant" is the whole point — and it's why coefficients change when you add or remove predictors.

The matrix form. Stack the data into a design matrix X — one row per observation, a first column of 1s for the intercept, one column per predictor. Least squares then has a one-line answer, the normal equations: (XᵀX)b = Xᵀy, so b = (XᵀX)⁻¹Xᵀy. XᵀX is small (one row and column per parameter) and symmetric positive definite, so the Cholesky factorization XᵀX = L·Lᵀ solves it cleanly.

Standard errors. The variance of each coefficient is σ² times the matching diagonal entry of (XᵀX)⁻¹, where σ is estimated by the residual standard error √(SSE/(n − p)) and p counts all parameters including the intercept. Each coefficient's t-test asks "does this predictor help, given all the others?"

R² always rises; adjusted R² doesn't. Adding any predictor — even random noise — can only lower SSE, so R² never falls. Adjusted R² = 1 − (1 − R²)(n − 1)/(n − p) charges for each parameter and drops when a predictor doesn't pull its weight.

The F-test asks whether the predictors, together, explain anything at all: it compares the variation the model explains per predictor with the leftover variation per residual degree of freedom.

Multicollinearity. When predictors move together (size and bedroom count), the data can't separate their effects well. The overall fit stays good, but the individual coefficients get large standard errors and can swing wildly or flip sign. If one predictor is an exact combination of others, XᵀX has no inverse at all.

Categorical predictors enter as dummy variables: one 0/1 column per category except a baseline. Each dummy's coefficient is that category's difference from the baseline, holding the other predictors fixed.

FORMULAS

Key formulas.

QuantityFormulaIn words
Coefficientsb = (XᵀX)⁻¹Xᵀythe normal equations
Residual SEσ̂ = √(SSE / (n − p))p includes the intercept
Coefficient SEσ̂·√((XᵀX)⁻¹ⱼⱼ)
Coefficient testt = bⱼ / SE(bⱼ), df = n − puseful given the others?
R²1 − SSE/SSTnever falls when you add predictors
Adjusted R²1 − (1 − R²)(n − 1)/(n − p)can fall
F statistic((SST − SSE)/(p − 1)) / (SSE/(n − p))any predictor useful?
Choleskya = L·Lᵀ, L lower triangularhow to invert XᵀX
IN GO

Go specifics.

  • A named slice type gets methods. type Matrix [][]float64 with Rows() and Cols() reads naturally, and a Matrix still indexes like any [][]float64.
  • Allocate every row. make(Matrix, rows) creates rows that are all nil; each needs its own make([]float64, cols). NewMatrix does it once, correctly.
  • Loop order matters for speed. In Mul, the i-k-j order (for k, aik := range a[i] then for j, bkj := range b[k]) walks memory row by row; the textbook i-j-k order jumps between rows of b on every step.
  • Return an error for the impossible case. A duplicated predictor makes XᵀX singular. Cholesky notices when a pivot isn't positive and returns ErrNotPositiveDefinite; callers check it with errors.Is.
  • Factor once, solve many times. Inverting XᵀX is Cholesky once plus one cheap triangular solve per column of the identity.
  • Variadic parameters — DesignMatrix(cols ...[]float64) — take any number of predictor columns; pass a slice of them with DesignMatrix(cols...).
EXAMPLE

Worked example: what "holding the others constant" does.

package main

import "fmt"

// centred returns Σ(a − ā)(b − b̄).
func centred(a, b []float64) float64 {
	n := float64(len(a))
	var ma, mb float64
	for i := range a {
		ma += a[i]
		mb += b[i]
	}
	ma, mb = ma/n, mb/n
	s := 0.0
	for i := range a {
		s += (a[i] - ma) * (b[i] - mb)
	}
	return s
}

func main() {
	// 15 made-up house sales: bedrooms, size (hundreds of sq ft), price ($k).
	beds := []float64{3, 3, 2, 4, 4, 3, 3, 2, 5, 3, 2, 4, 3, 4, 3}
	size := []float64{14.2, 16.8, 12.5, 19.6, 22.1, 15.4, 18.3, 11.9, 24.6, 17.2, 13.8, 20.5, 16.1, 21.7, 14.9}
	price := []float64{220.4, 252.2, 172.0, 304.6, 352.2, 215.8, 277.6, 149.8, 389.6, 246.4, 192.6, 323.0, 236.8, 329.2, 224.2}

	// price on bedrooms alone: slope = S(beds, price) / S(beds, beds)
	alone := centred(beds, price) / centred(beds, beds)

	// price on bedrooms AND size: the two-predictor least-squares formulas
	s11, s22, s12 := centred(beds, beds), centred(size, size), centred(beds, size)
	s1y, s2y := centred(beds, price), centred(size, price)
	det := s11*s22 - s12*s12
	bBeds := (s1y*s22 - s2y*s12) / det
	bSize := (s2y*s11 - s1y*s12) / det

	fmt.Printf("price ~ bedrooms:          %+6.1f $k per bedroom\n", alone)
	fmt.Printf("price ~ bedrooms + size:   %+6.1f $k per bedroom, %+5.1f $k per 100 sq ft\n", bBeds, bSize)
}

It prints:

$ go run . price ~ bedrooms: +76.6 $k per bedroom price ~ bedrooms + size: +13.2 $k per bedroom, +15.3 $k per 100 sq ft

On its own, each bedroom seems to add $77k, because houses with more bedrooms are also bigger, and the simple regression credits bedrooms with the size's effect. Holding size fixed, an extra bedroom is worth about $13k. Neither number is "wrong" — they answer different questions — but only the second is the effect of a bedroom. (The two predictors correlate at 0.95, which is why the bedroom coefficient in the full model has a large standard error.)

PITFALLS

Mistakes to avoid.

  • Leaving out a variable that matters. If a cause of y is correlated with a predictor you included, that predictor's coefficient absorbs its effect (omitted-variable bias), as in the example.
  • Reading coefficients as causal. "Holding constant" is statistical, not experimental. Unmeasured confounders still bias the estimates.
  • Comparing raw coefficient sizes. A coefficient's size depends on its units: dollars per square foot and dollars per bedroom aren't comparable. Standardize the predictors first if you want to rank them.
  • Chasing R². It always rises. Use adjusted R², or better, test the model on data it wasn't fitted to (Module 9).
  • The dummy-variable trap. A dummy for every category plus an intercept makes the columns sum to the intercept column — perfect collinearity, and XᵀX can't be inverted. Drop one category as the baseline.
EXERCISES

Your exercises.

Part A builds the matrix tools: transpose, multiply, Cholesky, triangular solves, and the inverse of a symmetric positive definite matrix. Part B fits the model and fills in every field of Model, then predicts and tests each coefficient. Part C turns a categorical variable into dummy columns.

go test ./08-multiple-regression/

The expected values come from statsmodels' OLS on 15 made-up house sales (and from NumPy for the matrix tests). The house data was generated as 50 + 12·size + 8·beds − 1.2·age + noise; look at how far the fitted coefficients land from those true values, and at which ones the t-tests can tell from zero.

The files for this module

Everything below is also in the course download, in 08-multiple-regression/. Work in exercises.go; the tests decide when you’re done.

▶ types.go the structs and helpers you’re given · 63 lines›
// Package multireg is Module 8: multiple regression.
//
// Several predictors at once: ŷ = b₀ + b₁x₁ + … + b_k·x_k. Written with
// matrices it's one equation — b = (XᵀX)⁻¹Xᵀy — so Part A builds the matrix
// tools first, then Part B uses them to fit and test the model.
package multireg

import (
	"errors"

	"github.com/bozcode/go-statistics-and-regression/internal/special"
)

// Matrix is a dense matrix stored row by row: m[i][j] is row i, column j.
type Matrix [][]float64

// NewMatrix returns a rows×cols matrix of zeros.
func NewMatrix(rows, cols int) Matrix {
	m := make(Matrix, rows)
	for i := range m {
		m[i] = make([]float64, cols)
	}
	return m
}

// Rows returns the number of rows.
func (m Matrix) Rows() int { return len(m) }

// Cols returns the number of columns (0 for an empty matrix).
func (m Matrix) Cols() int {
	if len(m) == 0 {
		return 0
	}
	return len(m[0])
}

// ErrNotPositiveDefinite is returned by Cholesky when the matrix isn't
// symmetric positive definite. For XᵀX that means a predictor is a perfect
// combination of the others (perfect multicollinearity).
var ErrNotPositiveDefinite = errors.New("multireg: matrix is not positive definite")

// Model is a fitted multiple regression. Index 0 of Coef and SE is the
// intercept; index j is predictor j.
type Model struct {
	Coef  []float64 // b₀, b₁, …, b_k
	SE    []float64 // standard error of each coefficient
	R2    float64   // 1 − SSE/SST
	AdjR2 float64   // R² penalized for the number of predictors
	Sigma float64   // residual standard error √(SSE/(n − p))
	F     float64   // overall F statistic: do the predictors explain anything?
	FP    float64   // p-value of F
	N, P  int       // observations; parameters including the intercept
}

// The helpers below are given — the t and F distributions from
// internal/special (you built the t distribution in Module 4).

// tCDF returns P(T <= t) for Student's t with df degrees of freedom.
func tCDF(t, df float64) float64 { return special.TCDF(t, df) }

// fSF returns the upper tail P(F > f) for an F distribution with d1 and d2
// degrees of freedom — the p-value of an F statistic.
func fSF(f, d1, d2 float64) float64 { return special.FSF(f, d1, d2) }
exercises.go — your work: every function is a stub marked TODO111 lines
//go:build !solutions

package multireg

// ============================================================
// Part A — the matrix tools
// ============================================================

// Transpose returns aᵀ: row i of the result is column i of a.
func Transpose(a Matrix) Matrix {
	// TODO
	return nil
}

// Mul returns the product ab: entry (i, j) is row i of a dotted with
// column j of b. a.Cols() must equal b.Rows(). Target: O(rows·cols·inner).
func Mul(a, b Matrix) Matrix {
	// TODO
	return nil
}

// Cholesky factors a symmetric positive definite matrix as a = L·Lᵀ, with L
// lower triangular. Column by column:
//
//	L[j][j] = √(a[j][j] − Σ_{k<j} L[j][k]²)
//	L[i][j] = (a[i][j] − Σ_{k<j} L[i][k]·L[j][k]) / L[j][j]   for i > j
//
// If a diagonal value under the square root is <= 0, return
// ErrNotPositiveDefinite.
func Cholesky(a Matrix) (Matrix, error) {
	// TODO
	return nil, nil
}

// SolveCholesky solves (L·Lᵀ)x = b given the Cholesky factor L: first
// forward substitution for L·z = b (top row down), then back substitution
// for Lᵀ·x = z (bottom row up). Target: O(n²).
func SolveCholesky(l Matrix, b []float64) []float64 {
	// TODO
	return nil
}

// InverseSPD returns a⁻¹ for a symmetric positive definite matrix: factor
// once with Cholesky, then solve for each column of the identity.
func InverseSPD(a Matrix) (Matrix, error) {
	// TODO
	return nil, nil
}

// ============================================================
// Part B — fitting the model
// ============================================================

// DesignMatrix builds X from predictor columns: a first column of 1s (for
// the intercept), then one column per predictor. With n observations and k
// predictors, X is n×(k+1).
func DesignMatrix(cols ...[]float64) Matrix {
	// TODO
	return nil
}

// Fit fits y = Xb by least squares and fills in the whole Model:
//
//	b      = (XᵀX)⁻¹Xᵀy            (use InverseSPD on XᵀX)
//	SSE    = Σ(y − ŷ)²,  SST = Σ(y − ȳ)²,  p = X.Cols()
//	R2     = 1 − SSE/SST
//	AdjR2  = 1 − (1 − R2)(n − 1)/(n − p)
//	Sigma  = √(SSE/(n − p))
//	SE[j]  = Sigma·√((XᵀX)⁻¹[j][j])
//	F      = ((SST − SSE)/(p − 1)) / (SSE/(n − p)),  FP = fSF(F, p−1, n−p)
//
// Returns ErrNotPositiveDefinite if XᵀX can't be inverted.
func Fit(x Matrix, y []float64) (Model, error) {
	// TODO
	return Model{}, nil
}

// Predict returns b₀ + b₁x₁ + … for one observation's predictor values
// (without the leading 1).
func (m Model) Predict(x []float64) float64 {
	// TODO
	return 0
}

// TStats returns Coef[j] / SE[j] for every coefficient.
func (m Model) TStats() []float64 {
	// TODO
	return nil
}

// PValues returns the two-sided p-value for each coefficient's t statistic,
// with n − p degrees of freedom: "is this predictor useful, given all the
// others?"
func (m Model) PValues() []float64 {
	// TODO
	return nil
}

// ============================================================
// Part C — categorical predictors
// ============================================================

// Dummies turns a categorical variable into 0/1 columns (treatment
// coding). The levels are sorted; the first is the baseline and gets no
// column, and every other level gets one column that is 1 where the value
// equals that level. Returns the columns and their level names.
// ["b" "a" "c" "a"] -> columns for "b" [1 0 0 0] and "c" [0 0 1 0].
func Dummies(values []string) (cols [][]float64, names []string) {
	// TODO
	return nil, nil
}
▶ multireg_test.go the tests — the same file checks yours or the reference · 184 lines›
package multireg

import (
	"errors"
	"math"
	"slices"
	"testing"
)

// near reports whether a and b agree to about 9 significant digits.
// Expected values come from NumPy and statsmodels' OLS.
func near(a, b float64) bool {
	return math.Abs(a-b) <= 1e-9*math.Max(1, math.Abs(b))
}

// relNear checks 9 significant digits with no absolute floor, for tiny
// p-values.
func relNear(a, b float64) bool {
	return math.Abs(a-b) <= 1e-9*math.Abs(b)
}

func slicesNear(t *testing.T, name string, got, want []float64) {
	t.Helper()
	if len(got) != len(want) {
		t.Fatalf("%s has %d values, want %d", name, len(got), len(want))
	}
	for i := range want {
		if !near(got[i], want[i]) {
			t.Errorf("%s[%d] = %v, want %v", name, i, got[i], want[i])
		}
	}
}

// 15 made-up house sales: size (hundreds of sq ft), bedrooms, age (years),
// price ($k). They were generated as 50 + 12·size + 8·beds − 1.2·age + noise.
var (
	size  = []float64{14.2, 16.8, 12.5, 19.6, 22.1, 15.4, 18.3, 11.9, 24.6, 17.2, 13.8, 20.5, 16.1, 21.7, 14.9}
	beds  = []float64{3, 3, 2, 4, 4, 3, 3, 2, 5, 3, 2, 4, 3, 4, 3}
	age   = []float64{25, 12, 40, 8, 5, 30, 15, 45, 3, 20, 35, 10, 22, 6, 28}
	price = []float64{220.4, 252.2, 172.0, 304.6, 352.2, 215.8, 277.6, 149.8, 389.6, 246.4, 192.6, 323.0, 236.8, 329.2, 224.2}
	neigh = []string{"east", "west", "north", "east", "west", "north", "east", "north", "west", "east", "north", "west", "east", "west", "north"}
)

// spd is a small symmetric positive definite matrix.
var spd = Matrix{{4, 2, 0.6}, {2, 5, 1.5}, {0.6, 1.5, 3}}

func TestTransposeMul(t *testing.T) {
	a := Matrix{{1, 2, 3}, {4, 5, 6}}
	at := Transpose(a)
	if at.Rows() != 3 || at.Cols() != 2 || at[2][0] != 3 || at[0][1] != 4 {
		t.Fatalf("Transpose = %v, want [[1 4] [2 5] [3 6]]", at)
	}
	p := Mul(a, at) // 2×2
	want := Matrix{{14, 32}, {32, 77}}
	if p.Rows() != 2 || p.Cols() != 2 {
		t.Fatalf("Mul(a, aᵀ) is %d×%d, want 2×2", p.Rows(), p.Cols())
	}
	for i := range want {
		if !slices.Equal(p[i], want[i]) {
			t.Fatalf("Mul(a, aᵀ) = %v, want %v", p, want)
		}
	}
}

func TestCholesky(t *testing.T) {
	l, err := Cholesky(spd)
	if err != nil {
		t.Fatalf("Cholesky: %v", err)
	}
	want := Matrix{{2, 0, 0}, {1, 2, 0}, {0.3, 0.6, 1.5968719422671311}}
	if l.Rows() != 3 {
		t.Fatalf("Cholesky returned %d rows, want 3", l.Rows())
	}
	for i := range want {
		slicesNear(t, "L row", l[i], want[i])
	}
	// a predictor that duplicates another makes XᵀX singular
	if _, err := Cholesky(Matrix{{1, 2}, {2, 4}}); !errors.Is(err, ErrNotPositiveDefinite) {
		t.Errorf("Cholesky of a singular matrix: err = %v, want ErrNotPositiveDefinite", err)
	}
}

func TestSolveCholesky(t *testing.T) {
	l, err := Cholesky(spd)
	if err != nil {
		t.Fatalf("Cholesky: %v", err)
	}
	slicesNear(t, "SolveCholesky", SolveCholesky(l, []float64{1, 2, 3}),
		[]float64{0.0625, 0.09264705882352943, 0.9411764705882353})
}

func TestInverseSPD(t *testing.T) {
	inv, err := InverseSPD(spd)
	if err != nil {
		t.Fatalf("InverseSPD: %v", err)
	}
	want := Matrix{
		{0.3125, -0.125, 0},
		{-0.125, 0.2852941176470588, -0.11764705882352942},
		{0, -0.11764705882352942, 0.3921568627450981},
	}
	if inv.Rows() != 3 {
		t.Fatalf("InverseSPD returned %d rows, want 3", inv.Rows())
	}
	for i := range want {
		slicesNear(t, "inverse row", inv[i], want[i])
	}
}

func TestDesignMatrix(t *testing.T) {
	x := DesignMatrix([]float64{5, 6}, []float64{7, 8})
	want := Matrix{{1, 5, 7}, {1, 6, 8}}
	if x.Rows() != 2 || x.Cols() != 3 || !slices.Equal(x[0], want[0]) || !slices.Equal(x[1], want[1]) {
		t.Errorf("DesignMatrix = %v, want %v", x, want)
	}
}

func TestFit(t *testing.T) {
	m, err := Fit(DesignMatrix(size, beds, age), price)
	if err != nil {
		t.Fatalf("Fit: %v", err)
	}
	slicesNear(t, "Coef", m.Coef, []float64{11.950288606041, 13.10973245262107, 11.334057593889174, -0.7900514417688478})
	slicesNear(t, "SE", m.SE, []float64{33.27433301780821, 1.9653731480602177, 6.961457337870392, 0.41570223566684117})
	if !near(m.R2, 0.9918230904103226) || !near(m.AdjR2, 0.9895930241585924) || !near(m.Sigma, 7.023439381709451) {
		t.Errorf("R2, AdjR2, Sigma = %v, %v, %v; want 0.99182, 0.98959, 7.02344", m.R2, m.AdjR2, m.Sigma)
	}
	if !near(m.F, 444.75050444838416) || !relNear(m.FP, 9.296103842548068e-12) {
		t.Errorf("F, FP = %v, %v; want 444.75050444838416, 9.296103842548068e-12", m.F, m.FP)
	}
	if m.N != 15 || m.P != 4 {
		t.Errorf("N, P = %d, %d; want 15, 4", m.N, m.P)
	}
	// age duplicated: XᵀX can't be inverted
	if _, err := Fit(DesignMatrix(size, age, age), price); !errors.Is(err, ErrNotPositiveDefinite) {
		t.Errorf("Fit with a duplicated predictor: err = %v, want ErrNotPositiveDefinite", err)
	}
}

func TestPredictAndTests(t *testing.T) {
	m, err := Fit(DesignMatrix(size, beds, age), price)
	if err != nil {
		t.Fatalf("Fit: %v", err)
	}
	if len(m.Coef) != 4 || len(m.SE) != 4 {
		t.Fatalf("Fit returned %d coefficients and %d standard errors, want 4 and 4", len(m.Coef), len(m.SE))
	}
	if got := m.Predict([]float64{18, 3, 10}); !near(got, 274.0271311171993) {
		t.Errorf("Predict(18, 3, 10) = %v, want 274.0271311171993", got)
	}
	slicesNear(t, "TStats", m.TStats(), []float64{0.3591443470751249, 6.670352887216406, 1.6281156435782198, -1.9005224749429148})
	ps := m.PValues()
	want := []float64{0.7262859679619491, 3.513440911883796e-05, 0.13177752448152952, 0.08387623321614873}
	if len(ps) != len(want) {
		t.Fatalf("PValues has %d values, want %d", len(ps), len(want))
	}
	for i := range want {
		if !relNear(ps[i], want[i]) {
			t.Errorf("PValues[%d] = %v, want %v", i, ps[i], want[i])
		}
	}
}

func TestDummies(t *testing.T) {
	cols, names := Dummies([]string{"b", "a", "c", "a"})
	if !slices.Equal(names, []string{"b", "c"}) || len(cols) != 2 ||
		!slices.Equal(cols[0], []float64{1, 0, 0, 0}) || !slices.Equal(cols[1], []float64{0, 0, 1, 0}) {
		t.Fatalf("Dummies([b a c a]) = %v, %v; want [[1 0 0 0] [0 0 1 0]], [b c]", cols, names)
	}
	// price ~ size + neighbourhood, baseline "east" (statsmodels' C(neigh))
	cols, names = Dummies(neigh)
	if !slices.Equal(names, []string{"north", "west"}) {
		t.Fatalf("Dummies(neigh) names = %v, want [north west]", names)
	}
	m, err := Fit(DesignMatrix(append([][]float64{size}, cols...)...), price)
	if err != nil {
		t.Fatalf("Fit: %v", err)
	}
	slicesNear(t, "Coef", m.Coef, []float64{-39.465193581427116, 17.36681461249573, -7.580166609764497, 1.570732673267437})
	slicesNear(t, "SE", m.SE, []float64{19.46582176230474, 1.1174667512592549, 6.597446475670623, 7.0600201579773225})
	if !near(m.R2, 0.9878742640551367) {
		t.Errorf("R2 = %v, want 0.9878742640551367", m.R2)
	}
}
Before you open the solution

Try not to open solution.go until your tests pass, or until you’ve been stuck for a solid half hour. Then compare: different-but-correct is fine; notice where the reference is simpler. go test -tags solutions ./08-multiple-regression/ runs the same tests against it.

▶ solution.go reference solutions — build tag solutions · 199 lines›
//go:build solutions

package multireg

import (
	"math"
	"slices"
)

// ============================================================
// Part A — the matrix tools
// ============================================================

func Transpose(a Matrix) Matrix {
	t := NewMatrix(a.Cols(), a.Rows())
	for i := range a {
		for j := range a[i] {
			t[j][i] = a[i][j]
		}
	}
	return t
}

func Mul(a, b Matrix) Matrix {
	out := NewMatrix(a.Rows(), b.Cols())
	for i := range a {
		for k, aik := range a[i] {
			for j, bkj := range b[k] {
				out[i][j] += aik * bkj
			}
		}
	}
	return out
}

func Cholesky(a Matrix) (Matrix, error) {
	n := a.Rows()
	l := NewMatrix(n, n)
	for j := 0; j < n; j++ {
		d := a[j][j]
		for k := 0; k < j; k++ {
			d -= l[j][k] * l[j][k]
		}
		if d <= 1e-12*math.Abs(a[j][j]) || d <= 0 {
			return nil, ErrNotPositiveDefinite
		}
		l[j][j] = math.Sqrt(d)
		for i := j + 1; i < n; i++ {
			s := a[i][j]
			for k := 0; k < j; k++ {
				s -= l[i][k] * l[j][k]
			}
			l[i][j] = s / l[j][j]
		}
	}
	return l, nil
}

func SolveCholesky(l Matrix, b []float64) []float64 {
	n := len(b)
	z := make([]float64, n)
	for i := 0; i < n; i++ {
		s := b[i]
		for k := 0; k < i; k++ {
			s -= l[i][k] * z[k]
		}
		z[i] = s / l[i][i]
	}
	x := make([]float64, n)
	for i := n - 1; i >= 0; i-- {
		s := z[i]
		for k := i + 1; k < n; k++ {
			s -= l[k][i] * x[k]
		}
		x[i] = s / l[i][i]
	}
	return x
}

func InverseSPD(a Matrix) (Matrix, error) {
	l, err := Cholesky(a)
	if err != nil {
		return nil, err
	}
	n := a.Rows()
	inv := NewMatrix(n, n)
	for j := 0; j < n; j++ {
		e := make([]float64, n)
		e[j] = 1
		col := SolveCholesky(l, e)
		for i := range col {
			inv[i][j] = col[i]
		}
	}
	return inv, nil
}

// ============================================================
// Part B — fitting the model
// ============================================================

func DesignMatrix(cols ...[]float64) Matrix {
	n := len(cols[0])
	x := NewMatrix(n, len(cols)+1)
	for i := 0; i < n; i++ {
		x[i][0] = 1
		for j, c := range cols {
			x[i][j+1] = c[i]
		}
	}
	return x
}

func Fit(x Matrix, y []float64) (Model, error) {
	xt := Transpose(x)
	inv, err := InverseSPD(Mul(xt, x))
	if err != nil {
		return Model{}, err
	}
	yCol := NewMatrix(len(y), 1)
	for i, v := range y {
		yCol[i][0] = v
	}
	bCol := Mul(inv, Mul(xt, yCol))
	n, p := x.Rows(), x.Cols()
	m := Model{Coef: make([]float64, p), SE: make([]float64, p), N: n, P: p}
	for j := range m.Coef {
		m.Coef[j] = bCol[j][0]
	}
	ybar := 0.0
	for _, v := range y {
		ybar += v / float64(n)
	}
	var sse, sst float64
	for i := range x {
		fit := 0.0
		for j, xij := range x[i] {
			fit += xij * m.Coef[j]
		}
		sse += (y[i] - fit) * (y[i] - fit)
		sst += (y[i] - ybar) * (y[i] - ybar)
	}
	dfRes := float64(n - p)
	m.R2 = 1 - sse/sst
	m.AdjR2 = 1 - (1-m.R2)*float64(n-1)/dfRes
	m.Sigma = math.Sqrt(sse / dfRes)
	for j := range m.SE {
		m.SE[j] = m.Sigma * math.Sqrt(inv[j][j])
	}
	m.F = ((sst - sse) / float64(p-1)) / (sse / dfRes)
	m.FP = fSF(m.F, float64(p-1), dfRes)
	return m, nil
}

func (m Model) Predict(x []float64) float64 {
	y := m.Coef[0]
	for j, v := range x {
		y += m.Coef[j+1] * v
	}
	return y
}

func (m Model) TStats() []float64 {
	out := make([]float64, len(m.Coef))
	for j := range out {
		out[j] = m.Coef[j] / m.SE[j]
	}
	return out
}

func (m Model) PValues() []float64 {
	df := float64(m.N - m.P)
	out := make([]float64, len(m.Coef))
	for j, t := range m.TStats() {
		out[j] = 2 * tCDF(-math.Abs(t), df)
	}
	return out
}

// ============================================================
// Part C — categorical predictors
// ============================================================

func Dummies(values []string) (cols [][]float64, names []string) {
	levels := slices.Clone(values)
	slices.Sort(levels)
	levels = slices.Compact(levels)
	for _, lv := range levels[1:] {
		col := make([]float64, len(values))
		for i, v := range values {
			if v == lv {
				col[i] = 1
			}
		}
		cols = append(cols, col)
		names = append(names, lv)
	}
	return cols, names
}
FURTHER

Going further.

  • Solve the least-squares problem with a QR factorization instead of the normal equations. It's more accurate when predictors are nearly collinear, because forming XᵀX squares the problem's condition number.
  • Add an interaction column (size × age) and interpret its coefficient: does size matter less for older houses?
  • Practice: regress your monthly bill on usage and the number of days in the month, with a dummy for winter.
QUIZ

Check yourself.

Four questions. Answer each one in your head — or out loud — before you open the answer.

Question 1

In ŷ = 20 + 13·size + 11·beds − 0.8·age, what does −0.8 mean?

  • AEvery house loses $0.8k a year
  • BComparing houses of the same size and bedroom count, each extra year of age goes with about $0.8k lower predicted price
  • CAge explains 80% of the price
  • DAge is not significant
Show the answer

B — A multiple-regression coefficient is a comparison holding the other predictors fixed. It's an association, not proof that ageing a house lowers its price.

Question 2

You add a column of random numbers to a regression. What happens to R² and adjusted R²?

  • ABoth fall
  • BR² rises (or stays the same); adjusted R² usually falls
  • CBoth rise
  • DNeither changes
Show the answer

B — Any extra column can only reduce SSE, so R² can't fall. Adjusted R² charges for the extra parameter, and noise rarely earns it back.

Question 3

Size and bedroom count are strongly correlated. What does that usually do to their coefficients?

  • AMakes them both exactly zero
  • BInflates their standard errors, so each looks less certain even though the overall fit is good
  • CMakes R² fall
  • DNothing
Show the answer

B — The data can't tell which of two predictors that move together deserves the credit. Module 9 measures this with the variance inflation factor.

Question 4

A "region" variable has 4 categories. How many dummy columns go into a model with an intercept?

  • A4
  • B3
  • C1
  • D2
Show the answer

B — One per category except the baseline. With all four, the dummies add up to the intercept column and XᵀX can't be inverted (the dummy-variable trap).

NEXT

Where to next.