TAI BUI
Back to curriculum
BuildPython

Linear Regression

Linear regression draws the best straight line through your data — the "hello world" of machine learning.

TIME: ~90 minutesPREREQUISITES: Phase 1 (Linear Algebra, Calculus, Optimization), Phase 2 Lesson 1

Learning Objectives

  • Derive the gradient descent update rules for mean squared error and implement linear regression from scratch
  • Compare gradient descent and the normal equation in terms of computational complexity and when to use each
  • Build a multiple linear regression model with feature standardization and interpret the learned weights
  • Explain how Ridge regression (L2 regularization) prevents overfitting by penalizing large weights

The Problem

Say you have house sizes and their sale prices, and you want to predict what a new house should sell for given only its size. You could eyeball a trend line on a scatter plot, but eyeballing doesn't scale, and it definitely doesn't turn into code that takes a size and returns a price. What you need is an actual formula: a line that fits the data closely enough to extrapolate from.

That's what linear regression gives you. But the real reason to start here isn't the line. It's that linear regression is your first full pass through the entire ML training loop: define a model, define a cost function, optimize the parameters until the cost stops improving. Most of what's coming later in this phase, logistic regression, SVMs, gradient-boosted ensembles, runs that same three-step pattern with a fancier model and a fancier optimizer bolted on. A few algorithms skip the optimizer step entirely (KNN just measures distances, decision trees split greedily instead of minimizing a cost), but even those still define a model and a criterion for fitting it. Learn the pattern cold on the simplest possible case and you'll recognize it in everything past this phase, too.

Don't mistake simple for small-time. Linear regression runs in production for demand forecasting, A/B test analysis, financial modeling, and as the baseline every fancier regression model has to beat.

Warm-up0/3
1. A house-price model always predicts the same number, the average price in the training data, no matter what the house looks like. Common sense says a genuinely useful model should beat that. If a new model performs no better than this constant-average predictor, what does that tell you?Warm-up
A scatter plot of house size against price with a flat horizontal line at the average price, plus a note that the new model's error is unknown.
Illustrative made-up houses (seeded random), no units.
2. In the straight-line equation y = mx + c, what does the slope m tell you?Warm-up
A straight line plotted on axes from 0 to 4 horizontally and 0 to 10 vertically, labelled y = 2x + 1.
An illustrative line, not from the lesson's data.
3. The derivative of a function at some point is positive. To make the function's value smaller, which way should you move x?Warm-up
A number line with a point x0 marked, the note that the derivative of f at x0 is positive, and two arrows pointing toward smaller and larger x, each labelled with an unknown value of f.
The shape of f is not drawn.
Check: The Problem0/1
1. Linear regression is described as a first full pass through the ML training loop. What are that loop's steps?Explain
Captured output of the lesson's first model: a data generation message, three Epoch lines with falling Cost values, the learned line and its R-squared of 0.9566.
Captured from running the lesson's linear_regression.py; lines trimmed.

The Concept

The Model

Linear regression assumes a linear relationship between input (x) and output (y):

y = wx + b
  • w (weight/slope): how much y changes when x increases by 1
  • b (bias/intercept): the value of y when x = 0

With multiple inputs (features), the same idea extends to:

y = w1*x1 + w2*x2 + ... + wn*xn + b

Or in vector form: y = w^T * x + b

The goal: find the w and b that make predicted y land as close as possible to actual y, across every training example at once. Simple to state. Finding it is the rest of this lesson.

The Cost Function (Mean Squared Error)

"As close as possible" has to become a single number before you can optimize anything: one score that captures how wrong your predictions are, overall. The standard choice is Mean Squared Error (MSE):

MSE = (1/n) * sum((y_predicted - y_actual)^2)

Why square the error instead of just averaging it? Two reasons. Squaring punishes big misses far harder than small ones: an error of 10 is 100x worse than an error of 1, not 10x. And the squared function is smooth and differentiable everywhere, exactly what an optimizer needs to work with.

Plot the cost against every possible w and b and you get a surface. For a single weight and bias, that MSE surface is a bowl: a convex paraboloid with one lowest point. Training a linear regression model is nothing more than finding the bottom of that bowl.

Gradient Descent

Gradient descent finds the bottom of that bowl by taking steps downhill.

Mermaid diagram

The gradient at each point tells you two things: which direction to move each parameter, and how far.

For MSE with y_hat = wx + b:

dMSE/dw = (2/n) * sum((y_hat - y) * x)
dMSE/db = (2/n) * sum(y_hat - y)

The update rule:

w = w - learning_rate * dMSE/dw
b = b - learning_rate * dMSE/db

The learning rate controls step size, and it's unforgiving in both directions. Too large: you overshoot the minimum and diverge. Too small: training takes forever. Typical starting values: 0.01, 0.001, or 0.0001.

The Normal Equation (Closed-Form Solution)

Linear regression has something most ML models don't: a direct formula that hands you the optimal weights with no iteration at all.

w = (X^T * X)^(-1) * X^T * y

Invert one matrix and you're done. It works great on small datasets. It stops working great on large ones (millions of rows, thousands of features), because matrix inversion costs O(n^3) in the number of features, and gradient descent takes over instead.

Multiple Linear Regression

With multiple features, the model becomes:

y = w1*x1 + w2*x2 + ... + wn*xn + b

Nothing about the machinery changes: MSE is still the cost function, gradient descent still updates every weight at once. The only real difference is geometric: you're fitting a hyperplane instead of a line.

Feature scaling matters more here than it looks like it should. If one feature ranges from 0 to 1 and another ranges from 0 to 1,000,000, the cost surface stretches into a long, thin valley, and gradient descent struggles to navigate it. Standardize your features (subtract the mean, divide by the standard deviation) before training, and the problem disappears.

Polynomial Regression

What if the relationship isn't linear? You can still use linear regression, by creating polynomial features instead of raw ones:

y = w1*x + w2*x^2 + w3*x^3 + b

This is still "linear" regression, and the quotes matter: the model is linear in the weights (w1, w2, w3), even though x, x^2, and x^3 aren't. You've just handed it nonlinear features of x and let it fit those instead.

Higher-degree polynomials fit more complex curves, but that power comes at a cost. A degree-10 polynomial will pass through every point in a 10-point dataset and predict poorly on everything that isn't in that dataset.

R-Squared Score

MSE tells you how wrong you are, but the raw number means nothing without knowing the scale of y: is an MSE of 500 good or catastrophic? R-squared (R^2) sidesteps that question entirely with a scale-independent measure:

R^2 = 1 - (sum of squared residuals) / (sum of squared deviations from mean)
    = 1 - SS_res / SS_tot
  • R^2 = 1.0: perfect predictions
  • R^2 = 0.0: no better than predicting the mean every time
  • R^2 < 0.0: worse than predicting the mean

Regularization Preview (Ridge Regression)

When you have a lot of features, the model can overfit by pushing weights to extreme values. Ridge regression (L2 regularization) heads that off with a penalty added directly to the cost:

Cost = MSE + lambda * sum(w_i^2)

That extra term punishes large weights directly. The hyperparameter lambda sets how hard: higher lambda means smaller weights and more regularization. A later lesson covers this in full; for now, just know it exists and why it helps.

Check: The Concept0/29
1. What does the learning rate control in gradient descent?Define
A bowl-shaped cost curve over one weight w with the current weight marked on the left slope and the next weight shown as unknown.
Illustrative curve.
2. What is linear regression?Define
A scatter of 100 points rising from lower left to upper right with a straight fitted line through the cloud, labelled w = 3.08 and b = 6.99.
The lesson's seed-42 data; the line is the lesson's normal-equation fit.
3. What is a cost function?Define
Five printed lines of the lesson's gradient descent run, each with an epoch, a Cost value, w and b, above a curve of the Cost over 1000 epochs falling from about 263 to about 4.
Captured from running the lesson's linear_regression.py; the curve plots its cost_history.
4. What does mean squared error compute?Define
A table of four examples with predicted values 7, 7, 6, 12 and actual values 5, 8, 9, 14, next to a dot plot of the predicted and actual values for each example, and an unknown mean squared error.
Typed toy numbers, not from the lesson's data.
5. What is gradient descent?Define
Elongated equal-cost rings over the plane of w and b with an orange path starting at (0, 0), jumping to about w = 1.3 and then creeping up the narrow valley toward w = 3.1, b = 6.5 over 1000 epochs.
Rings computed exactly from the lesson's seed-42 data; the path is the lesson's gradient descent with learning rate 0.005.
6. What is the normal equation?Define
A 4 by 2 matrix X whose first column is all ones and second column is 1, 2, 3, 4, a target column y of 2, 4, 5, 9, and an unknown parameter column holding b and w.
Typed toy example.
7. What does R-squared measure?Define
Two scatter plots with the same x values and a fitted line each. Fit 1 has points hugging the line with R-squared 0.96. Fit 2 has points scattered widely around its line with R-squared 0.38.
Illustrative seeded data; R-squared computed with numpy.
8. What is feature scaling?Define
A table with columns size, bedrooms and age listing five generated house rows, with a last row giving the generation ranges 500 to 3000, 1 to 5 and 0 to 50.
First five rows of the lesson's seed-42 multi-feature data.
9. What is regularization?Define
Ten points near a parabola with a fitted degree-9 curve that passes through every point and swings between them.
Seeded illustrative points; fit computed with numpy.
10. What is Ridge regression?Define
Three weight curves for size, bedrooms and age against alpha from 0 to 5. At alpha 0 they start at 0.77, 0.28 and -0.27, and all three move smoothly toward 0 as alpha grows.
Closed-form Ridge fit computed with numpy on the lesson's standardized seed-42 data; it matches the lesson's gradient descent weights at alpha = 0 and 0.1.
11. What is polynomial regression?Define
Fifty scattered points that dip and then rise, with a smooth U-shaped curve fitted through them.
The lesson's seed-42 quadratic data; the curve is computed with numpy.
12. What is overfitting?Define
Ten blue points with an orange degree-9 curve passing through every one and swinging up and down between them.
Seeded illustrative points around a quadratic; fit computed with numpy.
13. Why does linear regression use squared error rather than absolute error in its cost function?Explain
A fitted line y = 2x + 1 with five blue training points, vertical red segments for each miss, and a table of the five signed errors -1, 1, -3, 1, -10 beside an unknown cost column.
Typed toy numbers.
14. What is the gradient of MSE with respect to the bias term b, given y_hat = wx + b?Explain
A U-shaped curve of MSE against the bias b for a small three-point data set with w fixed at 1, with the point at b = 0 marked on the falling side and a dashed tangent line through it. The slope there is shown as unknown.
Typed toy data; curve computed with numpy.
15. Why can gradient descent reliably reach the single best w and b for linear regression with MSE?Explain
A table of four gradient descent runs on the lesson's data from the starting points (0, 0), (4.5, 10), (0.5, 11) and (4, -1). All four end at about w = 3.076 and b = 6.985 with the same final Cost.
Computed with numpy: the lesson's update rule at learning rate 0.01 for 20000 epochs.
16. The normal equation gives optimal weights directly. Why would you prefer gradient descent instead?Explain
Two captured outputs from the lesson: gradient descent learned y = 3.1433x + 6.5432 with R-squared 0.9566, and the normal equation learned y = 3.0756x + 6.9853 with R-squared 0.9572.
Captured from running the lesson's linear_regression.py; the # lines are labels.
17. Why is feature scaling important for gradient descent in multiple linear regression?Explain
A strip chart on a shared axis from 0 to 3200: 100 size values spread between 500 and 3000, and 100 bedroom values packed into a tiny stack at the far left between 1 and 5.
The lesson's seed-42 multi-feature data.
18. Why is polynomial regression (y = w1*x + w2*x^2 + w3*x^3 + b) still considered 'linear' regression?Explain
Twenty-four points with a curved S-shaped fitted line, and the fitted values w1 = 4.04, w2 = -2.98, w3 = 0.49 and b = 2.92.
Seeded illustrative points; fit computed with numpy.
19. Why is R-squared easier to judge than a raw MSE value such as 500?Explain
A two-line evaluation printout reading MSE 500 and R-squared as an unknown, with the question whether 500 is a good value.
Typed example, not captured.
20. Using the R-squared formula from this lesson, what does a score of exactly 0 mean for a trained regression model?Explain
The lesson's R-squared formula with its two sum definitions above a number line with ticks at -1, 0 and 1, where the model's score is marked at 0.
Formula from the lesson text.
21. What does the Ridge regression cost function add to plain MSE, and what happens as lambda increases?Explain
Three bars for the plain fit's weights on size (+0.77), bedrooms (+0.28) and age (-0.27), beside a box reading Cost equals MSE plus an unknown term with lambda 0.
Weights from the lesson's gradient descent run on its standardized seed-42 data.
22. Moving from one feature to several, what changes about linear regression?Explain
Two tables of the lesson's house rows: one with a size column and a price column, and one with size, bedrooms, age and price, each with its model equation below.
First four rows of the lesson's seed-42 multi-feature data.
23. A model trained on unscaled features, one in the thousands and one between 0 and 1, sees its MSE explode to inf within a few epochs. What should you try first?Analyze
A printout of the Cost at epochs 0, 1, 2, 3, 5, 10, 20 and 50 for gradient descent on raw features: it grows from about 1e10 by many orders of magnitude to inf by epoch 50.
Seeded illustrative data (size 1000 to 3000, ratio 0 to 1); costs computed with numpy using the lesson's update rule.
24. The normal equation solves a 10-feature problem instantly, but the same code on 1,000 features is far slower. Which explanation fits?Analyze
A large square representing a 1,000 by 1,000 matrix with a tiny orange speck at its top left corner drawn to the same scale for a 10 by 10 matrix.
Schematic drawn to scale: 0.15 px per feature.
25. A degree-10 polynomial regression model fits training data perfectly (R^2 = 1.0) but has R^2 = 0.3 on test data. What should you do?Analyze
Eleven blue training points with an orange degree-10 curve passing through all of them and swinging between them, and eight green test points. The picture lists train R-squared 1.0 and test R-squared 0.3.
Seeded illustrative points around a quadratic; fit and R-squared computed with numpy.
26. You must fit a linear model on 2,000 rows and 5 features and want exact optimal weights with no learning rate to tune. Which approach fits?Design
A tall block labelled X, 2,000 by 5, next to a thin tall block labelled y, 2,000 by 1, with a note that the goal is exact optimal weights and bias with no learning rate to tune.
Schematic from the question's numbers.
27. You add annual revenue, measured in millions, next to features between 0 and 1, and want to rank features by their learned weights. What design makes that ranking meaningful?Design
A table of four example rows with annual revenue in millions between 5 and 900 and two other features between 0 and 1, and the stated goal of ranking features by their learned weights.
Made-up rows (seeded), not from the lesson.
28. Your model has 200 features and little training data. Training R-squared is high, validation R-squared is poor, and you want to keep all features. Which design fits?Design
Two bars of R-squared for a 200-feature linear model fitted on 230 rows: training 0.95 and validation -2.89.
Simulated data (seeded), 10 of the 200 features matter; lambda 0.001 least squares; numbers computed with numpy.
29. Your data follows roughly y = 0.5x^2 - 2x + 3 plus noise, and you only have a linear regression solver. Which approach fits?Design
Fifty scattered points that fall and then rise, with a note that the only tool available is a linear regression solver.
The lesson's seed-42 quadratic data.

Build It

Step 1: Generate sample data

python
import random
import math
 
random.seed(42)
 
TRUE_W = 3.0
TRUE_B = 7.0
N_SAMPLES = 100
 
X = [random.uniform(0, 10) for _ in range(N_SAMPLES)]
y = [TRUE_W * x + TRUE_B + random.gauss(0, 2.0) for x in X]
 
print(f"Generated {N_SAMPLES} samples")
print(f"True relationship: y = {TRUE_W}x + {TRUE_B} (+ noise)")
print(f"First 5 points: {[(round(X[i], 2), round(y[i], 2)) for i in range(5)]}")

Step 2: Linear regression from scratch with gradient descent

python
class LinearRegression:
    def __init__(self, learning_rate=0.01):
        self.w = 0.0
        self.b = 0.0
        self.lr = learning_rate
        self.cost_history = []
 
    def predict(self, X):
        return [self.w * x + self.b for x in X]
 
    def compute_cost(self, X, y):
        predictions = self.predict(X)
        n = len(y)
        cost = sum((pred - actual) ** 2 for pred, actual in zip(predictions, y)) / n
        return cost
 
    def compute_gradients(self, X, y):
        predictions = self.predict(X)
        n = len(y)
        dw = (2 / n) * sum((pred - actual) * x for pred, actual, x in zip(predictions, y, X))
        db = (2 / n) * sum(pred - actual for pred, actual in zip(predictions, y))
        return dw, db
 
    def fit(self, X, y, epochs=1000, print_every=200):
        for epoch in range(epochs):
            dw, db = self.compute_gradients(X, y)
            self.w -= self.lr * dw
            self.b -= self.lr * db
            cost = self.compute_cost(X, y)
            self.cost_history.append(cost)
            if epoch % print_every == 0:
                print(f"  Epoch {epoch:4d} | Cost: {cost:.4f} | w: {self.w:.4f} | b: {self.b:.4f}")
        return self
 
    def r_squared(self, X, y):
        predictions = self.predict(X)
        y_mean = sum(y) / len(y)
        ss_res = sum((actual - pred) ** 2 for actual, pred in zip(y, predictions))
        ss_tot = sum((actual - y_mean) ** 2 for actual in y)
        return 1 - (ss_res / ss_tot)
 
 
print("=== Training Linear Regression (Gradient Descent) ===")
model = LinearRegression(learning_rate=0.005)
model.fit(X, y, epochs=1000, print_every=200)
print(f"\nLearned: y = {model.w:.4f}x + {model.b:.4f}")
print(f"True:    y = {TRUE_W}x + {TRUE_B}")
print(f"R-squared: {model.r_squared(X, y):.4f}")

Step 3: Normal equation (closed-form solution)

python
class LinearRegressionNormal:
    def __init__(self):
        self.w = 0.0
        self.b = 0.0
 
    def fit(self, X, y):
        n = len(X)
        x_mean = sum(X) / n
        y_mean = sum(y) / n
        numerator = sum((X[i] - x_mean) * (y[i] - y_mean) for i in range(n))
        denominator = sum((X[i] - x_mean) ** 2 for i in range(n))
        self.w = numerator / denominator
        self.b = y_mean - self.w * x_mean
        return self
 
    def predict(self, X):
        return [self.w * x + self.b for x in X]
 
    def r_squared(self, X, y):
        predictions = self.predict(X)
        y_mean = sum(y) / len(y)
        ss_res = sum((actual - pred) ** 2 for actual, pred in zip(y, predictions))
        ss_tot = sum((actual - y_mean) ** 2 for actual in y)
        return 1 - (ss_res / ss_tot)
 
 
print("\n=== Normal Equation (Closed-Form) ===")
model_normal = LinearRegressionNormal()
model_normal.fit(X, y)
print(f"Learned: y = {model_normal.w:.4f}x + {model_normal.b:.4f}")
print(f"R-squared: {model_normal.r_squared(X, y):.4f}")

Step 4: Multiple linear regression

python
class MultipleLinearRegression:
    def __init__(self, n_features, learning_rate=0.01):
        self.weights = [0.0] * n_features
        self.bias = 0.0
        self.lr = learning_rate
        self.cost_history = []
 
    def predict_single(self, x):
        return sum(w * xi for w, xi in zip(self.weights, x)) + self.bias
 
    def predict(self, X):
        return [self.predict_single(x) for x in X]
 
    def compute_cost(self, X, y):
        predictions = self.predict(X)
        n = len(y)
        return sum((pred - actual) ** 2 for pred, actual in zip(predictions, y)) / n
 
    def fit(self, X, y, epochs=1000, print_every=200):
        n = len(y)
        n_features = len(X[0])
        for epoch in range(epochs):
            predictions = self.predict(X)
            errors = [pred - actual for pred, actual in zip(predictions, y)]
            for j in range(n_features):
                grad = (2 / n) * sum(errors[i] * X[i][j] for i in range(n))
                self.weights[j] -= self.lr * grad
            grad_b = (2 / n) * sum(errors)
            self.bias -= self.lr * grad_b
            cost = self.compute_cost(X, y)
            self.cost_history.append(cost)
            if epoch % print_every == 0:
                print(f"  Epoch {epoch:4d} | Cost: {cost:.4f}")
        return self
 
    def r_squared(self, X, y):
        predictions = self.predict(X)
        y_mean = sum(y) / len(y)
        ss_res = sum((actual - pred) ** 2 for actual, pred in zip(y, predictions))
        ss_tot = sum((actual - y_mean) ** 2 for actual in y)
        return 1 - (ss_res / ss_tot)
 
 
random.seed(42)
N = 100
X_multi = []
y_multi = []
for _ in range(N):
    size = random.uniform(500, 3000)
    bedrooms = random.randint(1, 5)
    age = random.uniform(0, 50)
    price = 50 * size + 10000 * bedrooms - 1000 * age + 50000 + random.gauss(0, 20000)
    X_multi.append([size, bedrooms, age])
    y_multi.append(price)
 
 
def standardize(X):
    n_features = len(X[0])
    means = [sum(X[i][j] for i in range(len(X))) / len(X) for j in range(n_features)]
    stds = []
    for j in range(n_features):
        variance = sum((X[i][j] - means[j]) ** 2 for i in range(len(X))) / len(X)
        stds.append(variance ** 0.5)
    X_scaled = []
    for i in range(len(X)):
        row = [(X[i][j] - means[j]) / stds[j] if stds[j] > 0 else 0 for j in range(n_features)]
        X_scaled.append(row)
    return X_scaled, means, stds
 
 
y_mean_val = sum(y_multi) / len(y_multi)
y_std_val = (sum((yi - y_mean_val) ** 2 for yi in y_multi) / len(y_multi)) ** 0.5
y_scaled = [(yi - y_mean_val) / y_std_val for yi in y_multi]
 
X_scaled, x_means, x_stds = standardize(X_multi)
 
print("\n=== Multiple Linear Regression (3 features) ===")
print("Features: house size, bedrooms, age")
multi_model = MultipleLinearRegression(n_features=3, learning_rate=0.01)
multi_model.fit(X_scaled, y_scaled, epochs=1000, print_every=200)
 
print(f"\nWeights (standardized): {[round(w, 4) for w in multi_model.weights]}")
print(f"Bias (standardized): {multi_model.bias:.4f}")
print(f"R-squared: {multi_model.r_squared(X_scaled, y_scaled):.4f}")

Because every feature was standardized before training, those weights are directly comparable: the largest-magnitude one belongs to whichever feature moves the predicted price the most for a one-standard-deviation change, and its sign gives you the direction. That comparison only works because of the scaling. On raw units, a bigger weight on age than on size wouldn't tell you which feature actually mattered more, a year and a square foot aren't the same unit to begin with.

Step 5: Polynomial regression

python
class PolynomialRegression:
    def __init__(self, degree, learning_rate=0.01):
        self.degree = degree
        self.weights = [0.0] * degree
        self.bias = 0.0
        self.lr = learning_rate
 
    def make_features(self, X):
        return [[x ** (d + 1) for d in range(self.degree)] for x in X]
 
    def predict(self, X):
        features = self.make_features(X)
        return [sum(w * f for w, f in zip(self.weights, row)) + self.bias for row in features]
 
    def fit(self, X, y, epochs=1000, print_every=200):
        features = self.make_features(X)
        n = len(y)
        for epoch in range(epochs):
            predictions = [sum(w * f for w, f in zip(self.weights, row)) + self.bias for row in features]
            errors = [pred - actual for pred, actual in zip(predictions, y)]
            for j in range(self.degree):
                grad = (2 / n) * sum(errors[i] * features[i][j] for i in range(n))
                self.weights[j] -= self.lr * grad
            grad_b = (2 / n) * sum(errors)
            self.bias -= self.lr * grad_b
            if epoch % print_every == 0:
                cost = sum(e ** 2 for e in errors) / n
                print(f"  Epoch {epoch:4d} | Cost: {cost:.6f}")
        return self
 
    def r_squared(self, X, y):
        predictions = self.predict(X)
        y_mean = sum(y) / len(y)
        ss_res = sum((actual - pred) ** 2 for actual, pred in zip(y, predictions))
        ss_tot = sum((actual - y_mean) ** 2 for actual in y)
        return 1 - (ss_res / ss_tot)
 
 
random.seed(42)
X_poly = [x / 10.0 for x in range(0, 50)]
y_poly = [0.5 * x ** 2 - 2 * x + 3 + random.gauss(0, 1.0) for x in X_poly]
 
x_max = max(abs(x) for x in X_poly)
X_poly_norm = [x / x_max for x in X_poly]
y_poly_mean = sum(y_poly) / len(y_poly)
y_poly_std = (sum((yi - y_poly_mean) ** 2 for yi in y_poly) / len(y_poly)) ** 0.5
y_poly_norm = [(yi - y_poly_mean) / y_poly_std for yi in y_poly]
 
print("\n=== Polynomial Regression (degree 2 vs degree 5) ===")
print("True relationship: y = 0.5x^2 - 2x + 3")
 
print("\nDegree 2:")
poly2 = PolynomialRegression(degree=2, learning_rate=0.1)
poly2.fit(X_poly_norm, y_poly_norm, epochs=2000, print_every=500)
print(f"  R-squared: {poly2.r_squared(X_poly_norm, y_poly_norm):.4f}")
 
print("\nDegree 5:")
poly5 = PolynomialRegression(degree=5, learning_rate=0.1)
poly5.fit(X_poly_norm, y_poly_norm, epochs=2000, print_every=500)
print(f"  R-squared: {poly5.r_squared(X_poly_norm, y_poly_norm):.4f}")
 
print("\nDegree 2 fits the true curve well. Degree 5 fits training data slightly better")
print("but risks overfitting on new data.")

Step 6: Ridge regression (L2 regularization)

python
class RidgeRegression:
    def __init__(self, n_features, learning_rate=0.01, alpha=1.0):
        self.weights = [0.0] * n_features
        self.bias = 0.0
        self.lr = learning_rate
        self.alpha = alpha
 
    def predict_single(self, x):
        return sum(w * xi for w, xi in zip(self.weights, x)) + self.bias
 
    def predict(self, X):
        return [self.predict_single(x) for x in X]
 
    def fit(self, X, y, epochs=1000, print_every=200):
        n = len(y)
        n_features = len(X[0])
        for epoch in range(epochs):
            predictions = self.predict(X)
            errors = [pred - actual for pred, actual in zip(predictions, y)]
            mse = sum(e ** 2 for e in errors) / n
            reg_term = self.alpha * sum(w ** 2 for w in self.weights)
            cost = mse + reg_term
            for j in range(n_features):
                grad = (2 / n) * sum(errors[i] * X[i][j] for i in range(n))
                grad += 2 * self.alpha * self.weights[j]
                self.weights[j] -= self.lr * grad
            grad_b = (2 / n) * sum(errors)
            self.bias -= self.lr * grad_b
            if epoch % print_every == 0:
                print(f"  Epoch {epoch:4d} | Cost: {cost:.4f} | L2 penalty: {reg_term:.4f}")
        return self
 
 
print("\n=== Ridge Regression (L2 Regularization) ===")
print("Same data as multiple regression, with alpha=0.1")
ridge = RidgeRegression(n_features=3, learning_rate=0.01, alpha=0.1)
ridge.fit(X_scaled, y_scaled, epochs=1000, print_every=200)
print(f"\nRidge weights: {[round(w, 4) for w in ridge.weights]}")
print(f"Plain weights: {[round(w, 4) for w in multi_model.weights]}")
print("Ridge weights are smaller (shrunk toward zero) due to the L2 penalty.")
Check: Build It0/10
1. With w = 0, b = 0 and the data X = [1, 2], y = [3, 5], what does compute_gradients return as (dw, db)?Apply
A table of two examples, x = 1 with y = 3 and x = 2 with y = 5, each with prediction 0, beside an unknown (dw, db), above the lesson's compute_gradients method.
Code copied from the lesson, one long line wrapped.
2. In fit, w = 0, b = 0, dw = -13, db = -8 and lr = 0.1. What are w and b after one update step?Apply
Two cards: before the step w = 0, b = 0, dw = -13, db = -8 and learning rate 0.1; after one update w and b are unknown.
Values from the question.
3. For predictions [2, 4] and actual values [3, 7], what does compute_cost return?Apply
A table of two examples with predictions 2 and 4 and actual values 3 and 7, beside an unknown compute_cost result, above the lesson's compute_cost method.
Code copied from the lesson.
4. In r_squared, ss_res is 10 and ss_tot is 40. What does the method return?Apply
Two horizontal bars: ss_res of length 10 and ss_tot of length 40, beside an unknown r_squared return value.
Values from the question.
5. LinearRegressionNormal.fit gets X = [1, 2, 3] and y = [3, 5, 7]. What are w and b?Apply
A table and a scatter plot of three points, (1, 3), (2, 5) and (3, 7), with the fitted w and b shown as unknown.
Values from the question.
6. With degree = 3, what does make_features([2]) return in PolynomialRegression?Apply
Two lines of code creating PolynomialRegression with degree 3 and calling make_features on the list [2], with the result shown as an unknown.
Typed call.
7. In RidgeRegression.fit, one weight is 2.0, its MSE gradient is 0.5 and alpha = 0.1. What gradient is used to update that weight?Apply
A card listing a weight of 2.0, an MSE gradient of 0.5 and alpha 0.1, with an arrow to an unknown gradient used for the update.
Values from the question.
8. In the Build It section's RidgeRegression class, what is added to the weight gradient compared to plain MultipleLinearRegression?Apply
Two code listings from the lesson: the weight loop of MultipleLinearRegression, and the same loop of RidgeRegression with one line hidden and marked with question marks.
Code copied from the lesson, one line hidden.
9. In the polynomial example, degree 5 gets a slightly higher training R-squared than degree 2 on data from a true quadratic. Why is that not evidence that degree 5 is better?Analyze
Fifty standardized training points with two fitted curves, degree 2 and degree 5, that nearly overlap. Training R-squared is 0.6408 for degree 2 and 0.6686 for degree 5.
Captured from the lesson's polynomial run (2000 epochs each).
10. In the multiple regression Build It step, size, bedrooms, and age were standardized before training, and the resulting weight on size ends up much larger in magnitude than the weight on age. What can you actually conclude from that?Analyze
Three bars for the standardized weights on size (+0.77), bedrooms (+0.28) and age (-0.27).
The lesson's printed weights.

Use It

Now the same thing with scikit-learn, which is what you'll actually reach for in production.

python
from sklearn.linear_model import LinearRegression as SklearnLR
from sklearn.linear_model import Ridge
from sklearn.preprocessing import PolynomialFeatures, StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error, r2_score
import numpy as np
 
np.random.seed(42)
X_sk = np.random.uniform(0, 10, (100, 1))
y_sk = 3.0 * X_sk.squeeze() + 7.0 + np.random.normal(0, 2.0, 100)
 
X_train, X_test, y_train, y_test = train_test_split(X_sk, y_sk, test_size=0.2, random_state=42)
 
lr = SklearnLR()
lr.fit(X_train, y_train)
y_pred = lr.predict(X_test)
 
print("=== Scikit-learn Linear Regression ===")
print(f"Coefficient (w): {lr.coef_[0]:.4f}")
print(f"Intercept (b): {lr.intercept_:.4f}")
print(f"R-squared (test): {r2_score(y_test, y_pred):.4f}")
print(f"MSE (test): {mean_squared_error(y_test, y_pred):.4f}")
 
poly = PolynomialFeatures(degree=2, include_bias=False)
X_poly_sk = poly.fit_transform(X_train)
X_poly_test = poly.transform(X_test)
 
lr_poly = SklearnLR()
lr_poly.fit(X_poly_sk, y_train)
print(f"\nPolynomial degree 2 R-squared: {r2_score(y_test, lr_poly.predict(X_poly_test)):.4f}")
 
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
 
ridge = Ridge(alpha=1.0)
ridge.fit(X_train_scaled, y_train)
print(f"Ridge R-squared: {r2_score(y_test, ridge.predict(X_test_scaled)):.4f}")
print(f"Ridge coefficient: {ridge.coef_[0]:.4f}")

Your from-scratch version and scikit-learn land on the same numbers. What scikit-learn adds is everything you didn't have to build yourself: edge-case handling, numerical stability, performance tuning. Reach for the library in production. Keep the from-scratch version around for the day you need to know exactly what it's doing under the hood.

Check: Use It0/1
1. In the Use It section's scikit-learn example, what does StandardScaler do before fitting Ridge?Apply
Six lines of the lesson's scikit-learn code creating a StandardScaler, transforming the train and test features, and fitting Ridge, followed by the first five raw feature values 3.75, 9.51, 7.32, 5.99 and 1.56.
Code copied from the lesson; values from numpy with seed 42 as in the lesson.

Ship It

This lesson produces:

  • outputs/skill-regression.md, a skill for choosing the right regression approach based on the problem
Check: Ship It0/1
1. What does the skill-regression output produced in Ship It help with?Define
A file listing with outputs/skill-regression.md and the top of that file: name skill-regression, version 1.0.0, phase 2, lesson 2 and tags regression, linear-regression, polynomial-regression, ridge, regularization.
Copied from the lesson's output file, with its description line left out.
Author's Note

The instinct once you know scikit-learn exists is to skip straight to LinearRegression().fit() and never touch gradient descent again. Resist that for one more lesson. The point of coding the update rule by hand isn't nostalgia. It's that gradient descent is the optimizer behind almost everything past this point: logistic regression, neural networks, transformers, all of it comes down to some version of "compute a gradient, take a step, repeat." Linear regression is the one place you get to watch that loop with training wheels on, printing w and b every 200 epochs and watching them converge in real time.

The bug I see most often here comes from the learning rate, not the math. Crank it up on unscaled features and MSE doesn't drift upward. It explodes to inf or nan within a few epochs, and the traceback won't tell you why. Nine times out of ten, standardizing your features fixes it faster than shrinking the learning rate ever will. A feature in the thousands sitting next to one between 0 and 1 stretches the cost surface so badly that no single learning rate works well in every direction at once. Scale first, tune the learning rate second, not the other way around.

Exercises

  1. Implement batch gradient descent, stochastic gradient descent (SGD), and mini-batch gradient descent. Compare convergence speed on the same dataset. Which converges fastest? Which has the smoothest cost curve?
  2. Generate data from a cubic function (y = ax^3 + bx^2 + cx + d + noise). Fit polynomials of degree 1, 3, and 10. Compare training R^2 and test R^2. At what degree does overfitting become obvious?
  3. Implement Lasso regression (L1 regularization: penalty = alpha * sum(|w_i|)). Train on the multi-feature housing data. Compare which weights go to zero vs Ridge. Why does L1 produce sparse solutions while L2 doesn't?

Key Terms

TermWhat people sayWhat it actually means
Linear regression"Draw a line through data"Find weight w and bias b that minimize the sum of squared differences between wx+b and actual y values
Cost function"How bad the model is"A function that maps model parameters to a single number measuring prediction error, which optimization minimizes
Mean squared error"Average of squared errors"(1/n) * sum of (predicted - actual)^2, penalizing large errors disproportionately
Gradient descent"Walk downhill"Iteratively adjust parameters in the direction that reduces the cost function, using partial derivatives
Learning rate"Step size"A scalar that controls how much parameters change per gradient descent step
Normal equation"Solve it directly"The closed-form solution w = (X^T X)^-1 X^T y that gives optimal weights without iteration
R-squared"How good the fit is"The fraction of variance in y explained by the model, ranging from negative infinity to 1.0
Feature scaling"Make features comparable"Transforming features to similar ranges (e.g., zero mean, unit variance) so gradient descent converges faster
Regularization"Penalize complexity"Adding a term to the cost function that shrinks weights, preventing overfitting
Ridge regression"L2 regularization"Linear regression with a penalty of lambda * sum(w_i^2) added to MSE
Polynomial regression"Fitting curves with linear math"Linear regression on polynomial features (x, x^2, x^3, ...), still linear in the weights
Overfitting"Memorizing training data"Using a model so complex that it fits noise in training data and fails on new data

Further Reading

Code

Ships With