Index Ai Academy
Phase 3 · Machine learning · Models

Linear regression

Fit a line, and predict numbers from features.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Linear regression predicts a number, like a customer's lifetime value or next week's sales. It works in the simplest way there is. It multiplies each input by a weight, adds the results, and adds a starting amount. Because the model is so simple, you can read every weight: "each extra order adds about $127 of lifetime value, with everything else held fixed." It's also the first step inside every neuron of a neural network. So everything you learn here carries straight into neural networks.

What you'll be able to do

  • Write the linear regression model and read its coefficients
  • Fit it by least squares, by hand and with scikit-learn
  • Measure fit with R² and look at residuals
  • Know its limits: straight-line assumptions, outliers and extrapolation

What it covers

  1. A straight line that predicts
  2. Several features at once
  3. Finding the best line: least squares
  4. The exact answer
  5. Several features, with scikit-learn
  6. R²: better than guessing the average?
  7. Look at the residuals
  8. Worked example: fit a line by hand
  9. Limits to keep in mind

A straight line that predicts

A taxi ride costs $3 to start, plus $2 for every mile. So a 4-mile ride costs 2 × 4 + 3 = $11. You can predict any fare from one number: the miles.

Linear regression runs this idea backwards. You don't know the price list. You only have a pile of past rides and what each one cost. The model works out the "per mile" price and the "starting" price that fit those rides best. Then it uses them to predict new fares.

With one input, the model is a straight line:

y^=wx+b\hat{y} = w x + b

Take the symbols one at a time:

  • xx is the input, called a feature. Here, it's the miles.
  • ww is the weight: how much the prediction goes up for each extra 1 of the feature. Here, it's $2 per mile.
  • bb is the intercept: the prediction when the feature is 0. Here, it's the $3 start.
  • y^\hat{y}, said "y-hat", is the prediction. The little hat means "estimated".

A plain yy, with no hat, is the true value: what the ride really cost.

In plain words: the prediction is the input times a weight, plus a starting amount.

w, b = 2.0, 3.0      # $2 per mile, $3 to start
miles = 4
print(w * miles + b)
It runs right here in your browser, with nothing to install.

It prints 11.0, the same $11 you worked out by hand.

Quick check
A gym charges $15 a month plus a $20 joining fee. What does this print?
w, b = 15, 20      # $15 per month, $20 to join
months = 3
print(w * months + b)
  1. 35
  2. 65
  3. 45
  4. 75
Show the answer

65

The weight times the feature, plus the intercept: 15 × 3 + 20 = 65.

Several features at once

Most predictions depend on more than one thing. Picture a café owner predicting a day's sales. Warm days sell more iced drinks. Weekends bring more customers. Say each extra degree adds $5 of sales, a weekend adds $80, and the starting amount is $50.

The weekend feature is 1 on a weekend and 0 on a weekday. On a 20-degree Saturday, the prediction is 5 × 20 + 80 × 1 + 50 = $230.

With several features, each one gets its own weight. You multiply each feature by its weight, add the results, then add the intercept:

y^=w1x1+w2x2+⋯+wnxn+b=w⋅x+b\hat{y} = w_1 x_1 + w_2 x_2 + \cdots + w_n x_n + b = \mathbf{w} \cdot \mathbf{x} + b

The small numbers count the features. x1x_1 is the first feature. w1w_1 is its weight. The letter nn is how many features there are, and the dots in the middle mean "and so on".

On the right is a shorter way to write the same thing. The bold w\mathbf{w} is the list of all the weights. The bold x\mathbf{x} is the list of all the features. The dot between them is the dot product from Phase 2: multiply in pairs, then add up.

In plain words: multiply each feature by its own weight, add them all up, then add the starting amount.

weights = [5, 80]          # per degree, per weekend day
features = [20, 1]         # 20 degrees, and it's a weekend
b = 50                     # the intercept

prediction = sum(w * x for w, x in zip(weights, features)) + b
print(prediction)

It prints 230. The sum(... for ...) line is the dot product written as a loop.

Reading a weight

A weight says how much the prediction moves when its feature goes up by 1 and every other feature stays the same. A 21-degree Saturday predicts $5 more than a 20-degree Saturday. Both are weekends, so only the temperature changed. That "with the others held fixed" part matters. You'll see why with real data below.

The intercept bb is the prediction when every feature is 0. Sometimes that's meaningful, like a taxi ride of 0 miles. Often no real case has every feature at 0. Then the intercept is only the starting point of the sum, not a prediction you'd use.

Finding the best line: least squares

So far you knew the weights. In real work you have past data and need to find them. Picture the data as dots on a chart, and a ruler laid across them. Some positions of the ruler pass close to most dots. Others miss badly. Which position is best?

For each dot, measure the miss: the true value minus the prediction, y−y^y - \hat{y}. This miss is called a residual. Some misses are positive and some are negative. If you added them up as they are, they'd cancel out. So you square each miss, which makes it positive, and add up the squares. The total is the sum of squared errors, or SSE:

SSE=∑i(yi−y^i)2\text{SSE} = \sum_i (y_i - \hat{y}_i)^2

The symbol ∑\sum (the Greek capital letter sigma) means "add up". The little ii counts through the data points, one at a time. So yiy_i is the true value of point number ii. Its prediction is y^i\hat{y}_i.

In plain words: for every data point, take the miss and square it. Then add all the squares up. A smaller total means the line fits better.

Quick check
Two points miss by +1 and −2. What is the sum of squared errors?
print(1 ** 2 + (-2) ** 2)
  1. 3
  2. 9
  3. -1
  4. 5
Show the answer

5

1 + 4 = 5. Squaring removes the signs, and the bigger miss counts for more.

Least squares means picking the weights that make this total as small as possible.

Try it with three points: (1, 2), (2, 4) and (3, 5). The first number is xx and the second is the true yy. Compare two lines:

  • Line A, y^=2x\hat{y} = 2x, predicts 2, 4 and 6. The misses are 0, 0 and −1. Square and add: 0 + 0 + 1 = 1.
  • Line B, y^=1.5x+1\hat{y} = 1.5x + 1, predicts 2.5, 4 and 5.5. The misses are −0.5, 0 and −0.5. Square and add: 0.25 + 0 + 0.25 = 0.5.

Line B has the smaller total, so it fits better. In Python, ∑\sum is sum over a loop:

x = [1, 2, 3]
y = [2, 4, 5]

def sse(w, b):
    misses = [yi - (w * xi + b) for xi, yi in zip(x, y)]
    return sum(m ** 2 for m in misses)

print(sse(2.0, 0.0), sse(1.5, 1.0))

It prints 1.0 and 0.5, the same as by hand.

Squaring has a side effect: big misses count far more than small ones. A miss of 4 adds 16, while a miss of 1 adds only 1. That's a choice, and the next lesson looks at other ways to score misses.

Try it now

Drag the sliders to fit the line by eye, then press Best fit to see what least squares finds.

Quick check
A line misses one point by 1 and another by −2. What does this print?
misses = [1, -2]
print(sum(m ** 2 for m in misses))
  1. -1
  2. 3
  3. 5
  4. 1
Show the answer

5

Square each miss, then add: 1² + (−2)² = 1 + 4 = 5. Adding the misses without squaring gives −1, which hides how far off the line is.

The exact answer

You don't have to try lines one by one. For linear regression there's an exact recipe that finds the best weights in one go. NumPy's np.linalg.lstsq runs it for you (the name is short for "least squares"). On the same three points it finds the best line, y^=1.5x+0.667\hat{y} = 1.5x + 0.667:

import numpy as np

x = np.array([1.0, 2.0, 3.0])
y = np.array([2.0, 4.0, 5.0])
A = np.column_stack([x, np.ones_like(x)])
(w, b), *_ = np.linalg.lstsq(A, y, rcond=None)
print(round(w, 3), round(b, 3))
print(round(np.sum((y - (w * x + b)) ** 2), 3))

It prints 1.5 and 0.667, then the total of 0.167. That's smaller than line B's 0.5. No other straight line gets a smaller total for these three points.

The np.column_stack line builds a table with two columns: the feature, and a column of ones. lstsq finds one weight per column. The weight on the column of ones is multiplied by 1 every time, so it acts as the intercept.

Go deeper The formula behind lstsq

Put the features in a table XX, including the column of ones. Put the true values in a list y\mathbf{y}. The best weights are:

w=(X⊤X)−1X⊤y\mathbf{w} = (X^\top X)^{-1} X^\top \mathbf{y}

Read it as: flip X and multiply it by X, invert that, then multiply by flipped X and by y.

The last weight in w\mathbf{w} belongs to the column of ones, so it's the intercept. X⊤X^\top is X transposed, the table flipped so its columns become rows. The raised −1 means the inverse. Both come from the Phase 2 lesson on transpose and inverse.

import numpy as np
X = np.array([[1.0, 1.0], [2.0, 1.0], [3.0, 1.0]])
y = np.array([2.0, 4.0, 5.0])
print((np.linalg.inv(X.T @ X) @ X.T @ y).round(3))

It prints 1.5 and 0.667 again. In practice you use lstsq. It reaches the same answer in a way that stays accurate when the numbers are awkward.

Real data: orders and lifetime value

Now the same recipe on the practice shop's 600 customers. A customer's lifetime value is everything they've spent at the shop so far, in dollars. The code predicts it from one feature: how many orders the customer placed last year. Then it draws the customers as dots and the line through them.

import numpy as np
import pandas as pd
import requests
import matplotlib.pyplot as plt

df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"])
x = df["orders_last_year"].to_numpy(dtype=float)
y = df["lifetime_value"].to_numpy()

A = np.column_stack([x, np.ones_like(x)])          # a column of ones gives the intercept
(w, b), *_ = np.linalg.lstsq(A, y, rcond=None)
print(f"lifetime value ≈ {w:.1f} × orders + {b:.1f}")

plt.scatter(x, y, s=10, alpha=0.5)
xs = np.linspace(0, x.max(), 50)
plt.plot(xs, w * xs + b, color="C1")
plt.xlabel("orders last year")
plt.ylabel("lifetime value ($)")

It prints lifetime value ≈ 77.0 × orders + 302.5. Each extra order goes with about $77 more lifetime value. A customer with no orders last year is predicted at about $302.

Several features, with scikit-learn

scikit-learn is Python's standard machine-learning library. All its models work the same way:

  • fit(X, y) learns the weights from data;
  • predict(X) makes predictions;
  • score(X, y) measures how good they are.

For linear regression, the learned weights end up in coef_, short for coefficients (another word for weights). The intercept ends up in intercept_.

The code below uses four features. train_test_split sets aside a quarter of the customers as a test set. The model never sees them while it learns, so they give an honest check. Setting random_state=0 makes the shuffle the same on every run.

import pandas as pd
import requests
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split

df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"])
features = ["orders_last_year", "avg_order_value", "tenure_months", "trail_club"]
X_train, X_test, y_train, y_test = train_test_split(df[features], df["lifetime_value"], test_size=0.25, random_state=0)

model = LinearRegression().fit(X_train, y_train)
for name, coef in zip(features, model.coef_):
    print(f"{name:<18} {coef:8.2f}")
print(f"intercept          {model.intercept_:8.2f}")
print("R² on held-out customers:", round(model.score(X_test, y_test), 3))

Here's how to read the weights. Each one assumes the other three features stay fixed.

  • orders_last_year, about 127. Each extra order adds roughly $127 of predicted lifetime value.
  • avg_order_value, about 13.5. Each extra dollar on the average order adds about $13. That extra dollar is paid on every order, which is why it's worth more than $1.
  • tenure_months, about 21. Each extra month as a customer adds about $21.
  • trail_club, about −7. This is 1 for members of the shop's loyalty program and 0 for everyone else. Its weight comes out small and slightly negative. Once you know a customer's orders and order value, membership adds little extra information.

Notice that an order was worth about $77 in the one-feature model, and about $127 here. A weight always depends on which other features are in the model, because it's measured with them held fixed.

The intercept is about −1,496. That's the prediction for a customer with every feature at 0, and no real customer looks like that: nobody has a $0 average order. So it's only the starting point of the sum. The last line, R², scores the model on the test customers. It comes out around 0.82. The next section explains what that number means.

Quick check
A model predicting weekly signups has a coefficient of 0.6 for 'ad spend in $100s'. What does that mean?
  1. Each extra $100 of spend is associated with 0.6 more signups, with the other features held fixed
  2. Ad spend causes 0.6 signups per dollar
  3. The model is 60% accurate
  4. 60% of signups come from ads
Show the answer

Each extra $100 of spend is associated with 0.6 more signups, with the other features held fixed

A coefficient is a per-unit change in the prediction, other features fixed; it's an association, not proof of cause.

R²: better than guessing the average?

Before you trust a model, compare it with the laziest guess there is: predict the average every time. The shop's customers average about $776 of lifetime value. So the lazy guess says $776 for everyone. A useful model should miss by much less than the lazy guess does.

R² (said "R squared") measures how much less:

R2=1−∑i(yi−y^i)2∑i(yi−yˉ)2R^2 = 1 - \frac{\sum_i (y_i - \hat{y}_i)^2}{\sum_i (y_i - \bar{y})^2}

The top of the fraction is the model's sum of squared errors, from earlier. The bottom is the same total for the lazy guess. In it, yˉ\bar{y}, said "y-bar", is the average of the true values.

In plain words: R² is the share of the lazy guess's squared error that the model gets rid of. An R² of 0.8 means the model removes 80% of it.

A worked example. The true values are 2, 4 and 6, so the average is 4. The model predicts 3, 4 and 5.

  • The model misses by −1, 0 and 1. Square and add: 1 + 0 + 1 = 2.
  • The lazy guess of 4 misses by −2, 0 and 2. Square and add: 4 + 0 + 4 = 8.
  • R² = 1 − 2/8 = 0.75. The model removes three quarters of the lazy guess's error.
y = [2, 4, 6]            # what really happened
y_hat = [3, 4, 5]        # the model's predictions
mean = sum(y) / len(y)   # 4.0

model_error = sum((a - p) ** 2 for a, p in zip(y, y_hat))
baseline_error = sum((a - mean) ** 2 for a in y)
print(model_error, baseline_error, 1 - model_error / baseline_error)

It prints 2, 8.0 and 0.75. Here's how to read any R²:

  • 1 is perfect: the model never misses.
  • 0 means the model is no better than guessing the average.
  • Below 0 can happen on test data. It means the model does worse than the average would.

The customer model scored about 0.82 on its test customers. It removes about 82% of the lazy guess's squared error.

Go deeper Why R² can only go negative on new data

On the data a model learned from, least squares can always match the lazy guess. It could set every weight to 0 and the intercept to the average. Since it picks the best line, it does at least that well, so R² is 0 or more. On new data there's no such promise. A model can do worse than the average there, and then R² drops below 0.

Quick check
What does this R² calculation print?
y = [1, 2, 3]
y_hat = [1, 2, 4]
mean = sum(y) / len(y)
top = sum((a - p) ** 2 for a, p in zip(y, y_hat))
bottom = sum((a - mean) ** 2 for a in y)
print(1 - top / bottom)
  1. 0.75
  2. -0.5
  3. 0.5
  4. 1.0
Show the answer

0.5

The model misses only the last point, by 1, so the top is 1. The average is 2, so the lazy guess misses by −1, 0 and 1: the bottom is 2. That gives 1 − 1/2 = 0.5.

Look at the residuals

R² is a single number, and a single number can hide a pattern. So also look at the residuals: the misses y−y^y - \hat{y} from earlier. Plot each residual against its prediction.

  • If the dots form a shapeless cloud around zero, the straight line caught the pattern. What's left is noise.
  • If they form a curve or a funnel, the model missed something.

Think of a tailor checking a suit. Small, random gaps here and there are fine. If the sleeves are too short on every customer, that pattern is the real problem.

For the shop's customers, a likely culprit is that lifetime value behaves like a product: roughly orders × order value. A linear model only adds things up, so it can't make a product on its own. Adding that product as an extra feature, or taking logs, could fit better.

Worked example

Worked example: fit a line by hand

A shop tracks weekly ad spend (in $100s) and signups for five weeks. Spend of 1, 2, 3, 4 and 5 brought 2, 5, 6, 8 and 11 signups. Fit a line with least squares, check the residuals and R², then predict signups for a week with $600 of spend.

import numpy as np

spend = np.array([1.0, 2.0, 3.0, 4.0, 5.0])      # weekly ad spend, in $100s
signups = np.array([2.0, 5.0, 6.0, 8.0, 11.0])   # signups that week

A = np.column_stack([spend, np.ones_like(spend)])
(w, b), *_ = np.linalg.lstsq(A, signups, rcond=None)
predicted = w * spend + b
residuals = signups - predicted
r2 = 1 - np.sum(residuals ** 2) / np.sum((signups - signups.mean()) ** 2)

print(f"signups ≈ {w:.2f} × spend + {b:.2f}")
print("residuals:", residuals.round(2))
print("R²:", round(r2, 3))
print("prediction for $600:", round(w * 6 + b, 2))
In the lesson you can change this and run it.

The best line is signups ≈ 2.10 × spend + 0.10. Each extra $100 goes with about 2.1 more signups. The residuals are small and swing between plus and minus with no clear pattern. R² is 0.976, so the line removes almost 98% of the squared error of guessing the average, 6.4 signups. The prediction for $600 is 2.1 × 6 + 0.1 = 12.7. That's a little past the data, which is a mild stretch. A prediction for $5,000 of spend would be far outside it, and not worth trusting.

Limits to keep in mind

  • It only draws straight lines. It can't bend unless you give it bent features, like squares, logs or products. The other option is a more flexible model.
  • Outliers pull hard. Misses are squared, so one far-off point can drag the whole line toward it. The next lesson looks at this.
  • Don't extrapolate. That means predicting far outside the data you learned from. The shop's customers placed 0 to 18 orders last year. The line says nothing reliable about one with 200.
  • Weights aren't causes. "Members are worth more" doesn't mean membership makes them worth more.
  • Compare weights only after scaling. A weight per dollar and a weight per order use different units. You can't compare them until the features are put on the same scale (standardized, from the data preparation lesson).

Practice what you just read

  • 9 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

On held-out data, a regression model has R² = 0.05. What does that mean?

  1. It barely beats always predicting the average
  2. It's accurate on 5% of the predictions it makes
  3. It explains 95% of the variation in the target
  4. It's overfitting, as it only fits 5% of new data

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson