Machine Learning · Module 4 of 26 · 24 concepts · about 47 min

Regression

Predict a number with a weighted sum: read what the weights mean, fit them with gradient descent (from scratch, then with libraries), measure the fit honestly, check the five assumptions that decide whether you can trust it, and rein it in with Ridge and Lasso — all on one flat-rental problem.

At work: Predicting a number and explaining it: a fair rent, next month's sales, a delivery time, a customer's lifetime value.

The business problem: a fair rent for every new listing

What it is

A flat-rental platform in Bengaluru wants to show owners a suggested monthly rent the moment they list, and to flag listings priced far above similar flats. Each past listing has its area (sq ft), bedrooms, bathrooms, building age, floor, distance to the nearest metro station, locality, furnishing, who listed it (owner, agent or builder), parking, the date it was listed, and the rent it actually went for. The target is that rent, in thousands of rupees a month.

Why it matters

This is a regression problem — the answer is a number, not a category — and the platform needs more than a number: owners ask *why* the suggestion is ₹42,000 and not ₹50,000. A linear model answers that directly, one feature at a time, which is why it is the first model to try and the one every fancier model has to beat.

In the real world: Rental and resale sites like NoBroker or 99acres suggest a price the moment an owner lists, and flag listings far above similar homes.

Visual example

Training data

Five past listings. **Everything but rent is an input; rent is what the model learns to predict.**

listings.csv (rent in ₹ thousands a month)
area_sqftbathroomsbuilding_agemetro_kmfurnishingrent
6501143.2Semi22.5
1180260.8Furnished48
14202215.1Unfurnished31.5
1750331.4Furnished62
9802112.6Semi34

12,000 listings like these; 70% train the model and 30% are held back to test it.

import pandas as pd
from sklearn.model_selection import train_test_split

df = pd.read_csv("listings.csv")          # 12,000 past listings
df["rent"].describe()                      # ₹ thousands a month

train, test = train_test_split(df, test_size=0.3, random_state=1)

# Locality has 30 values: replace each with its average rent — learned on TRAIN only.
loc_mean = train.groupby("locality")["rent"].mean()
train["locality_rent"] = train["locality"].map(loc_mean)
test["locality_rent"] = test["locality"].map(loc_mean)

Encoding a category by its average target over the whole dataset before splitting. The test rows' own rents then leak into a feature, and that feature looks far more important than it is. Learn the averages on the training rows only, and apply them to the test rows.

Linear regression and the squared-error cost

What it is

The model predicts rent_hat = w1*area + w2*bathrooms + ... + wd*xd + b: a weight for every feature plus an intercept b. Fitting chooses the weights that make the mean squared error smallest — the average of the squared vertical gaps between each listing's real rent and the model's prediction.

Why it matters

It is fast, gives the same answer every time, and every weight can be read in plain words. With one feature the model is a line through the points; with d features it is a flat surface (a hyperplane) in d + 1 dimensions, and fitting is finding the surface closest to all the points at once.

In the real world: Estimating a flat's price from its size and location, the classic starting point for property websites.

Visual example

Least squares

The fit minimises the squared vertical gaps. One far-off point pulls the line further than several near ones.

Observed dataFitted line y ≈ 2.5x + 1.2
0246810051015202530x (feature)y (target)residual +0.8residual +1.8

Cost at two candidate lines

y = 2.5x + 1.2 -> residuals 0.3, 0.8, -0.7, 0.8, -0.7, 0.8, -1.7, -0.2, -0.7, 1.8

MSE = mean of the squares = 9.65 / 10 = 0.965

y = 2.0x + 3.0 -> residuals -1, 0, -1, 1, 0, 2, 0, 2, 2, 5 -> MSE = 4.0: worse, so least squares rejects it

the exact least-squares line is y = 2.497x + 1.267 (MSE 0.962); 2.5x + 1.2 is it rounded

Why this matters

Seeing the cost as a sum over vertical gaps explains both why the fit is unique and why a single distant point can visibly tilt it — the gap enters squared.

Common misconception

Wrong idea: the fitted line minimises the distance from each point to the line.

Correct idea: it minimises the vertical gap, squared. Perpendicular distance is a different fit (total least squares) and gives different coefficients.

The line is the one choice of slope and intercept for which no small nudge reduces the average squared residual.

from sklearn.linear_model import LinearRegression
import numpy as np

features = ["area_sqft", "bedrooms", "bathrooms", "building_age", "floor", "metro_km",
            "locality_rent", "parking", "Semi", "Furnished", "Agent", "Builder"]
model = LinearRegression().fit(train[features], train["rent"])
model.coef_, model.intercept_

pred = model.predict(test[features])
mse = np.mean((test["rent"] - pred) ** 2)
rmse = np.sqrt(mse)                  # back in ₹ thousands

Reading a weight as a cause. w for furnished means furnished flats in this data rent for more, *holding the other features fixed* — not that furnishing a flat will raise its rent by exactly that much. Add or remove a correlated feature and a weight can change a lot, even its sign.

Reading the coefficients

What it is

Each weight is the change in the prediction when that one feature goes up by one unit and every other feature stays the same. In the rent model, area_sqft has weight 0.018: each extra square foot adds ₹18 a month. building_age has −0.305: each year older takes off ₹305. Furnished has 6.85: a fully furnished flat rents for ₹6,850 more than an unfurnished one like it.

Why it matters

This is what makes linear regression explainable to a customer: the suggested rent can be broken into '₹18 × 1,100 sq ft, minus ₹305 × 12 years…' The sign says the direction and the size says how much, per unit.

In the real world: A used-car site tells a seller their car loses about ₹40,000 of value for every extra year of age, straight from one weight.

Visual example

model.coef_

Each weight is per unit of its own feature — read it with the unit, or not at all.

The rent model's weights (rent in ₹ thousands)
featureone unit isweightin rupees
area_sqft1 sq ft0.018+₹18 a month
bathrooms1 bathroom2.507+₹2,507
building_age1 year older-0.305−₹305
metro_km1 km further-1.327−₹1,327
Furnishedfurnished vs not6.846+₹6,846
import pandas as pd

coefs = pd.Series(model.coef_, index=features).round(3)
coefs
# area_sqft        0.018   ₹18 per extra sq ft
# bathrooms        2.507   ₹2,507 per extra bathroom
# building_age    -0.305   ₹305 less per year of age
# metro_km        -1.327   ₹1,327 less per km from the metro
# Furnished        6.846   ₹6,846 more than unfurnished

Comparing raw weights to decide which feature matters most. bathrooms (2.5) looks 140 times bigger than area_sqft (0.018) — but a bathroom and a square foot are not the same size of change. A weight is 'per unit', and the units differ; compare them only after scaling (next card).

Feature importance needs scaled features

What it is

Standardise every feature first (subtract its mean, divide by its standard deviation), then refit. Now each weight is 'the change in rent for a one-standard-deviation change in this feature', every feature is on the same footing, and the size of the weight (its absolute value) ranks importance.

Why it matters

On raw features the ranking is an accident of units. In the rent model, raw weights put area_sqft last of 12 — yet a typical difference in area (one standard deviation, 346 sq ft) moves rent by ₹6,300, more than almost anything else. After scaling, area ranks second, behind locality.

In the real world: An insurer ranking what drives claim size gets nonsense until age in years and income in rupees are put on the same scale.

Visual example

StandardScaler

Same model, two rankings. Raw weights put area last; standardised weights put it second.

Raw weights: ranked by |weight|
rankfeature|weight|
1Furnished6.85
2parking2.63
3bathrooms2.51
4Semi2.24
5Builder1.78
12area_sqft0.018
standardise, refit
Standardised weights: ranked by |weight|
rankfeature|weight|
1locality_rent7.7
2area_sqft6.27
3Furnished2.94
4metro_km2.8
5building_age2.64
12bedrooms0.04

Why area moves so far

raw weight 0.018 per sq ft × a typical spread of 346 sq ft ≈ 6.3

that is the standardised weight: ₹6,300 a month per standard deviation of area

import numpy as np
from sklearn.preprocessing import StandardScaler

scaler = StandardScaler().fit(train[features])
model_s = LinearRegression().fit(scaler.transform(train[features]), train["rent"])

imp = pd.Series(np.abs(model_s.coef_), index=features).sort_values(ascending=False)
imp.plot.bar()
features[np.argmax(np.abs(model_s.coef_))]   # 'locality_rent': most important
features[np.argmin(np.abs(model_s.coef_))]   # least important

Trusting the ranking when features are strongly correlated. With bedrooms and area_sqft both in the model, bedrooms gets a weight of 0.04 — 'unimportant' — only because area already says almost the same thing. Check for multicollinearity (the VIF card) before ranking.

Practise it: Permutation Importance, Averaged Honestly

Gradient descent, revisited

What it is

A way to find the lowest point of a function by walking downhill. Take f(x) = x².

  1. Step 1: Start anywhere, say x = 3.
  2. Step 2: Measure the slope there: f'(3) = 6, positive, so the function rises to the right.
  3. Step 3: Step the other way: x_new = x − η·f'(x), where η (eta) is the learning rate. With η = 0.1, x goes 3 → 2.4 → 1.92 → … towards the minimum at 0.

Why it matters

Fitting linear regression means finding the weights where the loss is lowest, and the loss is a bowl-shaped function of the weights. Gradient descent is how that bowl is searched — and the same idea trains logistic regression, neural networks and gradient-boosted trees.

In the real world: The same downhill walk trains the recommendation, ranking and language models behind most apps you use.

Visual example

f(x) = x²

Drag the learning rate: small steps crawl, larger ones overshoot and bounce, and past 1 they fly off.

Learning rate η

η = 0.20

steady convergence

Minimise L(w) = (w − 0)². The optimum is w = 0.

Loss after each update

w = 3
lossupdate

gradient = 2(w − 0) = 6 → next w = 1.80

def f_prime(x):
    return 2 * x                # slope of x²

x, eta = 3.0, 0.1
for step in range(20):
    x = x - eta * f_prime(x)    # minus: step against the slope
x                               # ≈ 0.035, close to the minimum at 0

Adding the gradient instead of subtracting it. The gradient points uphill, towards the steepest *increase*; adding it walks away from the minimum and the loss grows every step. The minus sign in the update is the whole point.

Why squared error and not absolute error

What it is

Both measure how wrong the predictions are. MAE, the mean absolute error, averages |y − y_hat|. MSE averages (y − y_hat)². For fitting with gradient descent, MSE wins: its slope is 2·(y_hat − y), which is large when a prediction is far off and shrinks to zero as it gets close. MAE's slope is only ever +1 or −1, and at exactly zero error it has no slope at all (a sharp corner).

Why it matters

A gradient that shrinks as you approach the answer lets gradient descent slow down and settle. MAE's constant ±1 keeps taking the same size step and jitters around the minimum. MSE is also smooth everywhere, so the calculus works and, for linear regression, there is even an exact solution.

In the real world: A food-delivery ETA model trained on squared error chases the rare two-hour delay; one trained on absolute error shrugs it off.

Visual example

d(loss)/d(y_hat)

MAE's slope is always ±1; MSE's slope grows with the error and fades to zero near the answer.

Slope of each loss for one prediction
error (y_hat − y)MAE slopeMSE slope
-2-1-4
-0.5-1-1
0.511
214
import numpy as np

err = np.array([-2.0, -0.5, 0.5, 2.0])    # y_hat - y for four listings
mae_grad = np.sign(err)                    # [-1, -1,  1, 1]
mse_grad = 2 * err                         # [-4, -1,  1, 4]

Thinking MSE is always the better loss. Squaring makes big errors dominate, so a few extreme rows — a rent typed with an extra zero — pull the whole fit towards them. When outliers are genuine and you want to ignore them, MAE or a Huber loss is the better choice.

Working out the gradient

What it is

For one listing the loss is L = (y − (w1x1 + w2x2 + b))². Differentiate with the chain rule: ∂L/∂b = −2(y − y_hat) and ∂L/∂w1 = −2(y − y_hat)·x1. The pattern is always −2 × error × the feature that weight multiplies. Over m listings, average it: ∂L/∂wj = (1/m) Σ −2(y − y_hat)·xj.

Why it matters

It turns an abstract idea into two lines of arithmetic. A weight moves a lot when its feature is large on the rows the model gets most wrong, and not at all when the error is zero. That is also why the gradient for w10 is −2(y − y_hat)·x10, not ·w10.

In the real world: When a model's training goes wrong, an engineer who can do one gradient step by hand can tell a bug from a bad setting.

Visual example

one step

One step on three flats: each weight's gradient is −2 × error × its input, averaged.

Start: w = 0, b = 0
x (100 sq ft)y (rent)y_haterror y − y_haterror × x
618018108
925025225
1233033396

The update, learning rate 0.005

∂L/∂w = −(2/3) × (108 + 225 + 396) = −486 -> w = 0 + 0.005 × 486 = 2.43

∂L/∂b = −(2/3) × (18 + 25 + 33) = −50.7 -> b = 0.25

MSE 679.3 -> 10.4 after a single step

Three flats, one feature: area in hundreds of sq ft, x = [6, 9, 12], rent y = [18, 25, 33]. Start at w = 0, b = 0, learning rate 0.005.

  • errors y − y_hat = [18, 25, 33]; MSE = 679.3
  • ∂L/∂w = −(2/3)(18×6 + 25×9 + 33×12) = −486
  • ∂L/∂b = −(2/3)(18 + 25 + 33) = −50.7
  • w = 0 − 0.005 × (−486) = 2.43; b = 0 − 0.005 × (−50.7) = 0.25
  • new predictions [14.8, 22.1, 29.4]; MSE = 10.4 after one step

Forgetting to average over the rows. Summing instead of averaging makes the gradient grow with the size of the data, so a learning rate that worked on 1,000 rows diverges on 100,000.

Gradient descent and the learning rate

What it is

Every weight is updated together: wj = wj − α·∂L/∂wj and b = b − α·∂L/∂b, where α is the learning rate — how big a step to take. Too small (a walk) and it takes forever; right (a jog) and it settles quickly; too large (a run) and every step overshoots the bottom, so the loss grows instead of shrinking.

Why it matters

It is the one setting that most often decides whether training works. On the standardised rent data, α = 0.1 is within a whisker of the best possible MSE (15.2) after 25 steps, α = 0.01 is still at 42.8 after 100, and α = 0.5 blows up — the loss triples every step.

In the real world: The step-by-step fitting that trains most models; a bad step size means training that never settles, wasting compute.

Visual example

learning rate

Walk, jog, run on the rent data: too small crawls, 0.1 settles, 0.5 explodes.

lr = 0.01 (walk)lr = 0.1 (jog)lr = 0.5 (run)
02040608010002004006008001,0001,2001,4001,600IterationTraining MSEbest possible (least squares)then 5,037, 15,125…

Why 0.5 is too big here

along its steepest direction this loss bends with strength 2 × 2.73 = 5.46 (2.73 comes from how the features move together)

steps stay stable only while lr < 2 / 5.46 ≈ 0.37

0.5 is past that, so every step lands higher up the other side

Common misconception

Wrong idea: if the loss is not going down, the model or the features are wrong.

Correct idea: far more often the step size is wrong. A loss that climbs is almost always too large a learning rate; one that barely moves, too small.

for lr in [0.01, 0.1, 0.5]:
    history = fit_gd(X_train_scaled, y_train, lr=lr, iterations=100)
    plt.plot(history, label=f"lr={lr}")
plt.yscale("log"); plt.legend()        # the shape tells you which is which

Keeping a fixed large learning rate and hoping. If the loss oscillates, lower it; a common trick is to shrink the learning rate as the iterations go on, so early steps are big and later ones are careful near the minimum.

Linear regression from scratch in NumPy

What it is

Four small functions: predict returns X·W + b; score returns R²; update_weights computes the gradients for all the weights at once and takes one step; fit starts the weights at zero and repeats the update for a fixed number of iterations.

Why it matters

Writing it once shows there is no magic in LinearRegression: on the rent data this class reaches R² = 0.899, the same as scikit-learn. It also shows why the matrix shapes work: X is (m rows, d features), the error is (m,), so X.T @ error gives one number per weight, (d,).

In the real world: A favourite data-science interview task: write the fit loop without scikit-learn, and explain every line.

import numpy as np

class LinearRegressionGD:
    def __init__(self, learning_rate=0.1, iterations=1000):
        self.lr, self.iterations = learning_rate, iterations

    def predict(self, X):
        return X @ self.W + self.b

    def score(self, X, y):                   # R²
        ss_res = np.sum((y - self.predict(X)) ** 2)
        ss_tot = np.sum((y - y.mean()) ** 2)
        return 1 - ss_res / ss_tot

    def update_weights(self):
        error = self.y - self.predict(self.X)            # shape (m,)
        dW = -2 * (self.X.T @ error) / self.m            # shape (d,)
        db = -2 * error.sum() / self.m
        self.W -= self.lr * dW
        self.b -= self.lr * db

    def fit(self, X, y):
        self.m, self.d = X.shape
        self.W, self.b = np.zeros(self.d), 0.0
        self.X, self.y = X, y
        for _ in range(self.iterations):
            self.update_weights()
        return self

LinearRegressionGD().fit(X_train_scaled, y_train).score(X_train_scaled, y_train)   # 0.899

Looping over rows to compute the gradient. X.T @ error does it for every weight in one vectorised line; a Python loop gives the same numbers hundreds of times slower. And this is batch gradient descent — every step uses all m rows. Updating after each single row is stochastic gradient descent; after small groups, mini-batch.

Practise it: Precision, Recall and F1 by Hand

Why scaling makes gradient descent faster

What it is

When features have very different ranges — area in the hundreds or thousands, bathrooms between 1 and 4 — the loss surface is a long, narrow valley. A learning rate small enough not to blow up along the steep direction is far too small along the gentle one, so gradient descent zig-zags and crawls. Scaled features make the valley round, and each step points more directly at the bottom.

Why it matters

On the rent data the difference is stark. Unscaled, a learning rate of 0.000001 already blows up, and 0.0000001 leaves the MSE stuck near 93 after 300 steps; scaled, a learning rate of 0.1 reaches 15.25 — essentially the best possible, 15.24. Same data, same model; only the scaling changed.

In the real world: A demand model mixing prices in rupees with ratings from 1 to 5 trains in minutes once scaled, and barely moves before.

Visual example

scaling

Same model, same data. Unscaled, it stalls near 93; scaled, it reaches the best possible fit.

Unscaled, lr = 0.0000001Scaled, lr = 0.1
05010015020025030002004006008001,000IterationTraining MSEbest possible

Unscaled with a slightly larger learning rate (0.000001) the loss explodes instead: 9,530 → 60,000 → 380,000.

from sklearn.preprocessing import StandardScaler

scaler = StandardScaler().fit(X_train)          # learned on training rows only
X_train_scaled = scaler.transform(X_train)
X_test_scaled = scaler.transform(X_test)        # the same mean and sd, reused

LinearRegressionGD(learning_rate=0.1, iterations=300).fit(X_train_scaled, y_train)

Scaling the test set with its own mean and standard deviation. The model learned weights for features scaled with the *training* statistics; test data scaled differently is shifted, and every prediction is off. Fit the scaler once, on training data.

The closed form versus the iterative fit

What it is

For squared error the optimum has an exact solution, the normal equation w = (XᵀX)⁻¹Xᵀy, which scikit-learn computes via a stable least-squares routine rather than an explicit inverse. SGDRegressor solves the same problem iteratively.

Why it matters

The closed form is exact and needs no learning rate, but its cost grows with the cube of the feature count and it needs the whole matrix in memory. Knowing where the crossover lies tells you when to stop reaching for LinearRegression.

In the real world: A small pricing model can be solved instantly; a model on 100 million rows is trained step by step instead.

import numpy as np
from sklearn.linear_model import LinearRegression, SGDRegressor

Xb = np.c_[np.ones(len(X_train)), X_train]
w_closed = np.linalg.lstsq(Xb, y_train, rcond=None)[0]    # stable; not inv()

LinearRegression().fit(X_train, y_train).coef_             # the same answer
SGDRegressor(max_iter=2000, tol=1e-4).fit(X_train, y_train)  # for very large n

Writing np.linalg.inv(X.T @ X) @ X.T @ y literally. With collinear columns XᵀX is near-singular, the inverse amplifies floating-point noise, and the coefficients come out enormous and unstable. Use lstsq or the library.

R², and why it never falls when you add a feature

What it is

R² compares the model with the dumbest possible one — always predicting the average rent: R² = 1 − SS_res / SS_tot, where SS_res is the sum of squared errors of the model and SS_tot that of the average. 1 means perfect, 0 means no better than the average, and on new data it can even be negative.

Why it matters

Add any feature to the training data — even a column of random numbers — and training R² can only go up or stay the same, because the fit can always set that feature's weight to zero and lose nothing. By chance the noise lines up a little with the rent, so R² creeps up. It rewards piling on features whether they help or not.

In the real world: An analyst adds 40 columns to a sales model and proudly reports a higher R², though the model predicts next month no better.

import numpy as np

X_noisy = np.c_[X_train_scaled, np.random.normal(size=(len(X_train_scaled), 5))]
LinearRegression().fit(X_train_scaled, y_train).score(X_train_scaled, y_train)   # 0.89916
LinearRegression().fit(X_noisy, y_train).score(X_noisy, y_train)
# 0.89922 — up, from noise

Choosing between models by training R². The one with more features always looks at least as good. Compare on held-out data, or use adjusted R² (next card), which charges for every extra feature.

Adjusted R²

What it is

Adjusted R² = 1 − (1 − R²)·(m − 1)/(m − d − 1), with m rows and d features. Adding a feature raises d, which makes the fraction bigger and pulls the score down; the feature has to raise R² by enough to pay for itself. It is at most 1 and can go below 0 (when the model is worse than the average).

Why it matters

It is a quick honest check when deciding whether a feature is worth keeping. With 50 listings and 3 features, R² = 0.850 gives adjusted R² = 0.840. Adding a fourth, useless feature nudges R² to 0.852 — but adjusted R² falls to 0.839. The extra feature did not earn its place.

In the real world: Deciding whether a new weather feed is worth paying for: if adjusted R² does not rise, the demand model did not need it.

Visual example

m = 50 listings

A useless 4th feature raises R² but lowers adjusted R².

Adding a feature that carries no information
features (d)R²adjusted R²
30.850.8402
40.8520.8388

The penalty, worked

d = 3: 1 − 0.150 × 49/46 = 1 − 0.1598 = 0.8402

d = 4: 1 − 0.148 × 49/45 = 1 − 0.1612 = 0.8388

def adjusted_r2(r2, m, d):
    return 1 - (1 - r2) * (m - 1) / (m - d - 1)

adjusted_r2(0.850, m=50, d=3)   # 0.8402
adjusted_r2(0.852, m=50, d=4)   # 0.8388 — lower: the 4th feature was not worth it

r2 = model.score(X_test, y_test)
adjusted_r2(r2, m=len(y_test), d=X_test.shape[1])

Misplacing a bracket. 1 - (1 - r2*(m-1)/(m-d-1)) is a common slip; it simplifies to r2*(m-1)/(m-d-1), which comes out *higher* than R² — the opposite of a penalty. If your adjusted R² is above your R², the formula is wrong.

statsmodels: the regression summary

What it is

statsmodels fits the same ordinary least squares (OLS) model as scikit-learn but reports far more: for every weight its standard error, a t-statistic, a p-value and a 95% confidence interval, plus R², adjusted R², a condition number and a Durbin–Watson statistic. Unlike scikit-learn it does not add an intercept on its own — sm.add_constant does that.

Why it matters

scikit-learn is built for predicting; statsmodels is built for explaining. When the platform needs to say 'a typical step up in area (one standard deviation, about 350 sq ft) adds between ₹6,090 and ₹6,370 a month, and we are confident it is not zero', that comes from the summary. The warnings at the bottom also catch problems — a huge condition number means multicollinearity.

In the real world: Economists and pricing teams quote a weight with its confidence range, like “₹6,100 to ₹6,400”, in reports to management.

Visual example

results.summary()

Part of the summary for the rent model (standardised features). A small p-value says the weight is very unlikely to be zero.

OLS Regression Results — R² 0.899, adj. R² 0.899, 8,382 rows
coefstd errtP>|t|95% interval
const37.820.043886.2037.74 to 37.90
area_sqft6.230.07187.206.09 to 6.37
building_age-2.350.043-550−2.43 to −2.26
metro_km-2.770.043-64.80−2.85 to −2.69
Furnished2.560.04952.702.47 to 2.66
Agent0.390.0448.800.30 to 0.48

This is the model after the checks further down: the typo rows removed and bedrooms dropped. Fit with year_built and building_age both included, the summary instead warns of a condition number of 2.8 × 10¹⁸ — the two columns carry the same information (see multicollinearity).

import statsmodels.api as sm

X_sm = sm.add_constant(X_train_scaled)          # adds the intercept column
results = sm.OLS(y_train, X_sm).fit()
print(results.summary())

results.rsquared, results.rsquared_adj           # 0.899, 0.899
results.params, results.pvalues, results.conf_int()
results.predict(X_sm)                            # same predictions as scikit-learn

Forgetting add_constant. Without it the model is forced through the origin — zero rent for a flat with all features at zero — and every weight and statistic is distorted, silently.

The assumptions behind ordinary least squares

What it is

Linear regression is the best linear model when five things hold:

  1. Step 1: the relationship between the features and the target is linear;
  2. Step 2: no feature is (nearly) a combination of the others;
  3. Step 3: the errors are roughly normally distributed;
  4. Step 4: their spread is the same everywhere (no heteroskedasticity);
  5. Step 5: they are independent of each other (no autocorrelation). Each has its own check, in the cards that follow.

Why it matters

Prediction survives mild violations; explanation does not. A p-value or confidence interval computed when these fail is wrong by an unknown amount — which matters the moment someone quotes a weight in a business decision.

In the real world: A delivery-time model whose errors grow for long routes needs a different approach before its confidence ranges can be trusted.

Visual example

OLS assumptions

Five assumptions, five checks. All of them are read from the residuals or the features — never from y alone.

What each assumption needs, and how to see it fail
checkif it fails
Linearityresiduals vs each featurebiased predictions: add terms or transform
No multicollinearityVIF under 5weights unstable and unreadable: drop or combine
Normal errorshistogram, Shapiro-Wilkunreliable intervals; look for outliers
Equal spreadresiduals vs predictionwrong intervals: log(y) or robust errors
IndependenceDurbin-Watson ≈ 2far too confident: add time features
resid = y_train - model.predict(X_train)

# 1. linearity         -> plot resid against each feature: no curve
# 2. multicollinearity -> VIF for each feature: below 5
# 3. normality         -> histogram / Q-Q plot / Shapiro-Wilk of resid
# 4. equal spread      -> plot resid against the prediction: no funnel
# 5. independence      -> Durbin-Watson ≈ 2; plot resid in time order

Checking whether the *target* or the *features* are normally distributed. The assumption is about the errors (residuals). A skewed rent is fine if the residuals behave.

The linearity assumption

What it is

The model can only draw straight-line relationships: every extra square foot adds the same ₹18, whether the flat is 500 or 2,500 sq ft. If the real effect curves — rent rising steeply near the metro and flattening beyond 5 km, say — a straight line is wrong in a predictable way: too high in some ranges and too low in others.

Why it matters

It is the one assumption whose failure hurts the predictions themselves. You see it by plotting the residuals against a feature: a healthy plot is a shapeless band around zero; a U or an arch says the relationship bends.

In the real world: Ad spend has diminishing returns; a straight line overpromises on the biggest budgets, and the residuals show it.

import numpy as np
import matplotlib.pyplot as plt

resid = y_train - model.predict(X_train)
plt.scatter(X_train["metro_km"], resid, s=4)
plt.axhline(0)                 # a curve here means 'not linear in metro_km'

# If it curves: give the model the curve.
X_train["log_metro_km"] = np.log1p(X_train["metro_km"])

Deciding linearity from a scatter of the target against one feature. With several features, the target's relationship with any one of them is mixed up with all the others. Check the residuals, which have the other features' effects already taken out.

Multiple regression and multicollinearity

What it is

Two features are collinear when one is a straight-line function of the other: f1 = a·f2 + b. Multicollinearity is the same with several: one feature can be written as a combination of others. In rent data it is everywhere: building_age is exactly 2026 − year_built, bedrooms is largely predicted by area_sqft, and a listing's super built-up area is its carpet area plus a near-fixed 25% share of walls, stairs and lobby.

Why it matters

The predictions survive, but the weights do not. Suppose rent = 1·x1 + 2·x2 + 3·x3 + 5 and x2 is always 1.5·x1. Then 4·x1 + 0·x2 + 3·x3 + 5 gives exactly the same predictions — so do endless other combinations — and the data cannot choose. The weights become arbitrary, and feature importance means nothing.

In the real world: Predicting sales from TV and online ad spend, when both campaigns always run together, makes it hard to tell which one worked.

Visual example

Multicollinearity

Carpet area and super built-up area are near twins: predictions stay put, coefficients explode — one even turns negative.

Unstable, not wrong

(RMSE barely moves)

Rent of 40 flats in one locality; RMSE on 2,000 more (₹ thousands)
per sq ft of carpetper sq ft of super built-upvalidation RMSE
carpet area only₹27—3.12
carpet + super built-up (r = 0.9996)₹155−₹1023.33
both, with Ridge₹16₹93.16

Ridge shares the credit between the twins — which is why it stabilises

Why ₹155 and −₹102 are possible

super built-up ≈ 1.25 × carpet, so 155 × carpet − 102 × 1.25 × carpet ≈ 28 × carpet: the one-feature answer

fit 40 fresh flats and the pair becomes ₹74 and −₹33, then −₹67 and +₹77: carpet area's sign flips

endless pairs give almost the same predictions, so the noise in 40 rows picks one

Common misconception

Wrong idea: a huge coefficient means the feature is very important.

Correct idea: under collinearity it can mean the fit is cancelling two near-duplicate columns against each other. Importance cannot be read off unstable coefficients at all.

import numpy as np
from sklearn.linear_model import LinearRegression

X2 = X_train.assign(year_built=2026 - X_train["building_age"])
np.corrcoef(X2["year_built"], X2["building_age"])[0, 1]   # -1.0: perfectly collinear

X_train[["area_sqft", "bedrooms"]].corr()                    # 0.95: nearly the same information

# 40 flats in one locality: carpet area and super built-up area (r = 0.9996)
LinearRegression().fit(flats[["carpet"]], flats["rent"]).coef_                # [0.027]
LinearRegression().fit(flats[["carpet", "super_built_up"]], flats["rent"]).coef_
# [0.155, -0.102]

Dropping features because their weights look small or their p-values large, when features are collinear. Two near-duplicates can each look negligible while together they carry most of the signal; drop one and the other's weight jumps. Measure collinearity first (VIF, next card).

VIF: finding and removing multicollinearity

What it is

The variance inflation factor of a feature measures how well the *other* features predict it. Treat that feature as the target, fit a linear regression on the rest, take its R² (call it R²ⱼ), and compute VIF = 1 / (1 − R²ⱼ). If the others explain nothing, R²ⱼ = 0 and VIF = 1. If they explain it perfectly, R²ⱼ = 1 and VIF is infinite.

Why it matters

It gives a number to act on. Rule of thumb: above 10 very high — drop it; 5 to 10 high; below 5 fine. Then repeat: drop the feature with the highest VIF, refit, recompute, until every VIF is under 5 — or until adjusted R² starts to fall, which means you are now removing real information.

In the real world: A bank's credit model drops “total debt” once it already has card, loan and mortgage debt, so each weight can be explained to a regulator.

Visual example

VIF

Drop year_built, then bedrooms: every VIF falls below 3, and adjusted R² stays at 0.899.

All features
featureVIF
building_ageinf
year_builtinf
bedrooms12.63
area_sqft10.14
bathrooms3.49
Furnished1.3
drop year_built, then bedrooms
After two removals
featureVIF
area_sqft2.81
bathrooms2.8
Furnished1.3
Semi1.3
building_age1
metro_km1

Reading one VIF

bedrooms regressed on the other features: R²ⱼ = 0.921

VIF = 1 / (1 − 0.921) = 12.6: area already tells the model how many bedrooms

import pandas as pd
from statsmodels.stats.outliers_influence import variance_inflation_factor

def vif_table(X):
    return pd.Series([variance_inflation_factor(X.values, i) for i in range(X.shape[1])],
                     index=X.columns).round(2).sort_values(ascending=False)

cols = list(X_train_scaled.columns)
while True:
    vif = vif_table(X_train_scaled[cols])
    if vif.iloc[0] < 5:
        break
    cols.remove(vif.index[0])       # drop the worst, then look again
vif_table(X_train_scaled[cols])

Dropping every high-VIF feature in one go. Removing one often brings the others down with it: in the rent data, dropping year_built alone takes building_age from infinite to 1.0. Remove one at a time and recompute.

Normal residuals and outliers

What it is

The residuals — real rent minus predicted rent — should look like a bell curve centred on zero. Check with a histogram, a Q-Q plot, or the Shapiro–Wilk test (a statistic W near 1 means close to normal). A long tail on one side, or a few residuals far from the rest, usually means outliers: rows the model gets wildly wrong.

Why it matters

Squared error lets outliers drag the fit towards them. In the rent data, 25 listings had an extra zero typed into the rent (₹4,00,000 instead of ₹40,000). Their residuals ran from 150 to 730, while 99.9% of the other rows sat within ±15. Flagging residuals more than 5 standard deviations out caught 18 rows in the training set — all of them typos — and W went from 0.14 to 0.995.

In the real world: One salary typed as ₹8 crore instead of ₹8 lakh can drag an HR pay model, until the residuals point straight at it.

Visual example

outliers

18 typo rows removed: the residuals become a bell curve, and the model fits real flats better.

With the typos
measurevalue
residual sd18.91
skew23.3
kurtosis (normal = 3)624
Shapiro-Wilk W0.14
R² on clean test data0.894
remove rows with |residual| > 5 sd
Typos removed
measurevalue
residual sd3.9
skew0.06
kurtosis (normal = 3)3.7
Shapiro-Wilk W0.995
R² on clean test data0.903

Scored against test data that still contains typos, the original model's R² is only 0.35: bad rows wreck the score as well as the fit.

import numpy as np
from scipy import stats
import seaborn as sns

resid = y_train - model.predict(X_train)
sns.histplot(resid)                                  # bell-shaped?
stats.shapiro(resid.sample(4000, random_state=0))    # W near 1 = close to normal

suspect = np.abs(resid) > 5 * resid.std()           # far from the rest
train.loc[suspect]                                   # look at them before removing anything
model = LinearRegression().fit(X_train[~suspect], y_train[~suspect])

Deleting every high-residual row automatically. Some are typos and should go; others are genuine — a penthouse really does rent for ₹4 lakh — and removing them teaches the model that such flats do not exist. Look at the rows, fix or drop the mistakes, and keep the real ones (or use a loss less sensitive to them).

Heteroskedasticity: residuals versus fitted values

What it is

The spread of the errors should be the same everywhere — the model should be about as wrong on a ₹20,000 flat as on a ₹60,000 one. Heteroskedasticity is when it is not: plot the residuals against the predictions and the band widens into a funnel.

Why it matters

The weights stay reasonable, but the standard errors, p-values and confidence intervals in the statsmodels summary assume one spread for all rows, and become wrong. It also often signals that effects are proportional — a furnished flat adds a *percentage*, not a fixed amount — which modelling log(rent) captures.

In the real world: A house-price model is off by lakhs on luxury homes and by thousands on small flats, so one confidence range misleads both.

Visual example

residuals vs fitted

A funnel, not a band. The model is twice as wrong on ₹53,000 flats as on ₹23,000 ones.

Residuals (40 listings)+2 × spread−2 × spread
102030405060-10-50510Predicted rent (₹ thousands)Residual (actual − predicted)no error

Residual spread (sd) by quarter of the predictions

cheapest quarter (avg ₹23k): 2.55

next: 3.39, then 4.15

priciest quarter (avg ₹53k): 5.07 — twice the first

Common misconception

Wrong idea: a good R² means the residuals are fine.

Correct idea: R² is one number and cannot show shape. This model has R² 0.90 and a clear funnel.

import numpy as np
import pandas as pd
import seaborn as sns
import statsmodels.api as sm

fitted = model.predict(X_train)
resid = y_train - fitted
sns.scatterplot(x=fitted, y=resid)                     # a funnel = heteroskedasticity

resid.groupby(pd.qcut(fitted, 4)).std()               # 2.55, 3.39, 4.15, 5.07: doubling

# Fixes: model the log of the target, or keep the model and use robust errors.
sm.OLS(y_train, X_sm).fit(cov_type="HC3")

Plotting residuals against the real rent instead of the predicted rent. Residuals are built from the real rent, so that plot always slopes and always looks alarming; the check is against the predictions.

Autocorrelation and the Durbin–Watson test

What it is

Each row's error should be unrelated to the next row's. It fails when rows have an order — usually time — and something that changes over time is missing from the model. The Durbin–Watson statistic summarises it: about 2 means no autocorrelation; well below 2, errors next to each other tend to have the same sign.

Why it matters

Bengaluru rents rose through 2025, but the model has no listing-date feature. Sorted by listing month, its residuals drift from −₹2,200 in January to +₹1,700 in December: it underprices recent listings and overprices old ones. Adding the month as a feature removes the drift. Autocorrelated errors also make the model far too confident — its intervals assume more independent information than the data has.

In the real world: A sales model with no date feature underpredicts every recent month while prices rise, a drift that only shows in time order.

Visual example

residuals in time order

Average residual by listing month: a steady climb — the model is missing the rise in rents over the year.

24681012-3-2-10123Listing month, 2025 (1 = January)Average residual (₹ thousands)no bias

Durbin–Watson

rows in random order: 1.98 — looks fine, but hides the trend

sorted by month, no month feature: 1.83

sorted by month, month added: 2.00 — the drift is gone

import pandas as pd
from statsmodels.stats.stattools import durbin_watson

ordered = train.sort_values("listed_on")
resid = ordered["rent"] - model.predict(ordered[features])
durbin_watson(resid)                                     # 1.83: below 2
resid.groupby(ordered["listed_on"].dt.month).mean()     # a steady upward drift

# Fix: give the model time.
train["month"] = train["listed_on"].dt.month

Computing Durbin–Watson on rows in shuffled order. With a random order, errors next to each other are unrelated by construction and the statistic is ≈ 2 even when a time trend is hiding in the data (1.98 here). Sort by time first, then check.

Polynomial regression, underfitting and overfitting

What it is

Adding x**2, x**3 … as extra columns keeps the model linear in its parameters while letting it curve. The polynomial degree is a capacity dial: degree 1 is a straight line, degree 9 can pass through ten points exactly.

Why it matters

It is the cleanest demonstration of the capacity tradeoff in the whole syllabus. Nothing changes but one integer, and the model moves from too rigid to capture the shape to flexible enough to interpolate the noise.

In the real world: Ad spend has diminishing returns; a curve fits it, but too wiggly a curve predicts nonsense for next quarter's budget.

Visual example

Model capacity

Same ten points, three fits. Degree 1 cannot bend; degree 9 bends through the noise; degree 3 recovers the shape.

Training pointsDegree 1 — underfitsDegree 3 — about rightDegree 9 — overfits
0246810-4-2024681012xy

Train versus validation MSE by degree (200 fresh points from the same curve)

degree 1: train 6.76, val 5.36 -> both high: underfitting (bias)

degree 3: train 0.35, val 1.03 -> both low, close to the noise level of 1.0: about right

degree 9: train 0.00, val 9.16 -> a huge gap: overfitting (variance)

Why this matters

It is the picture behind every later diagnosis: low train and high validation error means the model is fitting noise, and the remedy is less capacity or more data, not more tuning.

Common misconception

Wrong idea: the wiggly curve is a better model because it passes through every point.

Correct idea: passing through every point is proof of memorisation, not of fit. Between the points it is inventing structure, and validation rows land between the points.

The points come from the curve y = 0.05x³ − 1.05x² + 5.4x + 1 plus random noise. The dashed curve passes through every one of them and swings between them: between x = 0.5 and 1.5 it dives to −4, and near x = 9 it shoots up to 7.7 before dropping to the last point.

from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures, StandardScaler
from sklearn.linear_model import LinearRegression
import numpy as np

for degree in [1, 3, 9]:
    model = make_pipeline(PolynomialFeatures(degree), StandardScaler(),
                          LinearRegression()).fit(X_train, y_train)
    train = np.mean((y_train - model.predict(X_train)) ** 2)
    val = np.mean((y_val - model.predict(X_val)) ** 2)
    print(degree, round(train, 3), round(val, 3))

Choosing the degree by training error. It falls monotonically with degree, so the criterion always picks the largest degree offered — the one model guaranteed to generalise worst. Choose on validation error, which is U-shaped.

Ridge regression (L2)

What it is

Adds alpha * sum(w**2) to the squared-error cost. Large coefficients now carry a price, so the fit trades a little training error for smaller weights. Coefficients shrink smoothly toward zero as alpha rises but never reach it.

Why it matters

It is the standard remedy for collinearity and for the high-variance regime where the feature count approaches the sample count. It also keeps every feature in the model, which matters when the features are genuinely all weakly informative.

In the real world: A demand model with many similar weather features stays stable instead of swinging wildly with each new month of data.

Visual example

L2 shrinkage

As alpha grows, every Ridge coefficient shrinks smoothly toward zero — and none of them ever arrives.

w1 (strong)w2 (moderate)w3 (noise)
almost unpenalisedunderfitting-2-101234-101234log10(alpha)Coefficient valuezeroalpha chosen by CV (3.2)

One feature, one number

least squares weight w = 3.6, n = 50 rows

Ridge (scikit-learn), one standardised feature: w_ridge = w × n / (n + alpha)

alpha 10 -> 3.0, alpha 100 -> 1.2, alpha 1,000 -> 0.17: never exactly 0

Why this matters

The path picture is how alpha is chosen: enough shrinkage that noise stops moving the predictions, not so much that the strong weights are gone too. Cross-validation finds that point.

Common misconception

Wrong idea: Ridge removes useless features.

Correct idea: it shrinks them. Every feature keeps a small non-zero coefficient, which is Lasso's job instead — and the reason to prefer one over the other.

50 rows, three standardised features that do not overlap, least-squares weights 3.6, 1.8 and −0.7. At the far right every coefficient is near zero, so the model predicts the mean of y — maximum bias, zero variance.

from sklearn.linear_model import RidgeCV
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
import numpy as np

alphas = np.logspace(-2, 4, 25)
ridge = make_pipeline(StandardScaler(), RidgeCV(alphas=alphas)).fit(X_train, y_train)
ridge[-1].alpha_        # the alpha chosen by cross-validation
ridge[-1].coef_

Penalising unscaled features. The penalty is on the coefficient, and a feature measured in pounds has a coefficient a thousand times smaller than the same feature in thousands of pounds — so the penalty silently punishes whichever features happen to use small units.

Lasso regression (L1)

What it is

Adds alpha * sum(abs(w)) instead. The absolute-value penalty has a corner at zero, so the optimum lands exactly on zero for weak features: Lasso performs selection as well as shrinkage.

Why it matters

When you believe only a handful of features matter, Lasso returns a sparse model you can read and deploy cheaply. The set of features it keeps, as alpha varies, is also a genuinely useful ranking.

In the real world: Picking the handful of the 300 marketing signals that actually drive sales, and setting the rest to zero.

Visual example

L1 selection

L1 coefficients hit exactly zero, one after another, at feature-specific values of alpha.

w1 (strong)w2 (moderate)w3 (noise)
only w1 survivesnone left-2-1.5-1-0.500.51-101234log10(alpha)Coefficient valueexactly zero3 features -> 2w3 eliminated

Soft thresholding, one standardised feature

w_lasso = sign(w) * max(|w| - alpha, 0)

w = -0.7, alpha = 0.7 -> max(0.7 - 0.7, 0) = 0: gone

w = 3.6, alpha = 0.7 -> 2.9: shrunk but kept

Why this matters

The exact-zero behaviour is the entire practical difference between L1 and L2, and soft thresholding is the one line of algebra that explains it.

Common misconception

Wrong idea: Lasso zeroes a coefficient because the feature is useless.

Correct idea: it zeroes it because the feature's contribution is smaller than alpha. Raise alpha enough and every coefficient goes, including the strong one.

The same three features as the Ridge chart. There the curves approach zero and never arrive; here each one meets it — at alpha 0.7, 1.8 and 3.6 — and stays.

from sklearn.linear_model import LassoCV
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
import numpy as np

lasso = make_pipeline(StandardScaler(), LassoCV(cv=5, random_state=0)).fit(X_train, y_train)
coef = lasso[-1].coef_
int(np.sum(coef != 0)), len(coef)      # kept vs total

Treating the features Lasso keeps as 'the important ones' when features are correlated. Among a group of near-duplicates it keeps one, arbitrarily, and zeroes the rest — resample the data and a different member of the group survives.