Skip to content

Simple Linear Regression

What you’ll learn

  • the model y^=wx+b\hat{y} = wx + b, and what each parameter means in the units of your data
  • why we square the errors instead of taking absolute values or plain sums
  • how to derive the least-squares slope and intercept by setting two derivatives to zero
  • how to compute the whole fit by hand on five points, then check it against scikit-learn
  • how to read a residual plot, and what a bad one looks like
  • the four assumptions the model makes, and which pitfalls follow when they break

Intuition

You spend money on advertising and you record sales. Plot the pairs and they trend upward, but not perfectly — a straight line through the cloud would miss every point by a little.

Simple linear regression asks one question: of all the straight lines you could draw, which one misses by the least? Once you have that line, two numbers summarise the whole relationship — how steeply sales rise per dollar spent, and where the line starts.

diagram Diagram mermaid

The loop at the bottom is the whole of supervised learning in miniature: predict, measure the miss, adjust. Simple linear regression is special only because the adjustment has a formula — you can jump straight to the answer instead of iterating.

The math

The model

One feature, two parameters:

y^=wx+b\hat{y} = wx + b
  • ww is the slope — the change in y^\hat{y} for a one-unit increase in xx. Its units are units of y per unit of x.
  • bb is the intercept — the prediction when x=0x = 0. Sometimes meaningful, often not.

The loss

For a dataset of nn pairs (xi,yi)(x_i, y_i), the residual of point ii is ei=yiy^ie_i = y_i - \hat{y}_i. We minimise the sum of squared errors:

J(w,b)=i=1n(yi(wxi+b))2J(w, b) = \sum_{i=1}^{n} \left(y_i - (w x_i + b)\right)^2

Why squared? Three reasons, in order of importance:

  1. Signs would cancel. Plain ei\sum e_i is zero for infinitely many terrible lines.
  2. It is differentiable everywhere. ei\sum |e_i| has a corner at zero, so there is no clean closed-form solution.
  3. Big misses hurt more. Squaring penalises a residual of 4 sixteen times as hard as a residual of 1, which is usually what you want — and occasionally exactly what you don’t (see Pitfalls).

Deriving the solution

JJ is a convex paraboloid in (w,b)(w, b), so its single minimum is where both partial derivatives vanish. Start with the intercept:

Jb=2i=1n(yiwxib)=0i=1nyiwi=1nxinb=0\frac{\partial J}{\partial b} = -2\sum_{i=1}^{n}\left(y_i - w x_i - b\right) = 0 \quad\Longrightarrow\quad \sum_{i=1}^{n} y_i - w \sum_{i=1}^{n} x_i - nb = 0

Divide by nn and rearrange:

b=yˉwxˉb = \bar{y} - w\bar{x}

That is a useful fact on its own: the least-squares line always passes through the centroid (xˉ,yˉ)(\bar{x}, \bar{y}) of the data. Now the slope:

Jw=2i=1nxi(yiwxib)=0\frac{\partial J}{\partial w} = -2\sum_{i=1}^{n} x_i\left(y_i - w x_i - b\right) = 0

Substitute b=yˉwxˉb = \bar{y} - w\bar{x} and solve for ww:

w=i=1n(xixˉ)(yiyˉ)i=1n(xixˉ)2=Cov(x,y)Var(x)w = \frac{\sum_{i=1}^{n}(x_i - \bar{x})(y_i - \bar{y})}{\sum_{i=1}^{n}(x_i - \bar{x})^2} = \frac{\operatorname{Cov}(x, y)}{\operatorname{Var}(x)}

The slope is the covariance of the two variables divided by the variance of the input. Everything below is arithmetic on that one formula.

Worked example by hand

Five months of ad spend against sales, in thousands:

iixix_i (spend)yiy_i (sales)xixˉx_i - \bar{x}yiyˉy_i - \bar{y}(xixˉ)(yiyˉ)(x_i-\bar{x})(y_i-\bar{y})(xixˉ)2(x_i-\bar{x})^2
112−2−244
224−1001
3350100
4441001
5552124
xˉ=3\bar{x}=3yˉ=4\bar{y}=4610

Step 1 — the means. xˉ=15/5=3\bar{x} = 15/5 = 3, yˉ=20/5=4\bar{y} = 20/5 = 4.

Step 2 — the slope. Divide the two column totals:

w=610=0.6w = \frac{6}{10} = 0.6

Step 3 — the intercept. Push the line through the centroid:

b=yˉwxˉ=40.6×3=2.2b = \bar{y} - w\bar{x} = 4 - 0.6 \times 3 = 2.2

So the fitted line is y^=0.6x+2.2\hat{y} = 0.6x + 2.2. Every extra thousand dollars of spend buys 600 more units of sales.

Step 4 — predictions and residuals.

xix_iyiy_iy^i=0.6xi+2.2\hat{y}_i = 0.6x_i + 2.2ei=yiy^ie_i = y_i - \hat{y}_iei2e_i^2
122.8−0.80.64
243.4+0.60.36
354.0+1.01.00
444.6−0.60.36
555.2−0.20.04
0.02.40

The residuals sum to exactly zero. That is not luck — it is the first normal equation, ei=0\sum e_i = 0, which is what fixing b=yˉwxˉb = \bar{y} - w\bar{x} guarantees.

Step 5 — the error numbers.

SSE=2.40,MSE=2.405=0.48,RMSE=0.480.69\text{SSE} = 2.40, \qquad \text{MSE} = \frac{2.40}{5} = 0.48, \qquad \text{RMSE} = \sqrt{0.48} \approx 0.69

Step 6 — how much did we explain? Compare against the dumbest possible model, which always predicts yˉ=4\bar{y} = 4:

SST=(yiyˉ)2=4+0+1+0+1=6\text{SST} = \sum (y_i - \bar{y})^2 = 4 + 0 + 1 + 0 + 1 = 6
R2=1SSESST=12.406=0.60R^2 = 1 - \frac{\text{SSE}}{\text{SST}} = 1 - \frac{2.40}{6} = 0.60

The line removes 60% of the variance that predicting the mean would have left. Metrics R-Squared and Adjusted R-Squared takes that number apart properly.

figureThe fitted line and its five residualsmatplotlib
Scatter plot of five points with the fitted line y = 0.6x + 2.2, and a red vertical stem from each point to the line marking its residual.Scatter plot of five points with the fitted line y = 0.6x + 2.2, and a red vertical stem from each point to the line marking its residual.
Squares are predictions, circles are observations, red stems are residuals. Least squares makes the total squared stem length as small as it can be — 2.40 here.

Why no other line beats it

Two plausible alternatives, scored on the same five points:

figureThree candidate lines, three SSE scoresmatplotlib
The same five points with three candidate lines: the least-squares fit with SSE 2.4, a steeper line with SSE 4.0, and a flatter line with SSE 4.0.The same five points with three candidate lines: the least-squares fit with SSE 2.4, a steeper line with SSE 4.0, and a flatter line with SSE 4.0.
Both alternatives pass through the centroid, so both have zero-sum residuals — and both still lose. Only one slope minimises the squared total.

See it move

Below, the line starts flat and walks toward the least-squares solution by repeatedly nudging ww and bb downhill. The closed-form answer and the iterative one land in the same place; the iterative route is the one that keeps working when there are a million features.

sketch Fitting the line of best fit p5.js
Points are sampled around a hidden true line. The amber line starts flat and adjusts its slope and intercept step by step to reduce the total squared distance. Click to draw a fresh dataset.

From scratch

The two formulas, transcribed directly. No optimiser, no library model:

ols_from_scratch.py
import numpy as np
 
 
def fit_simple_linear(x, y):
    """Return (w, b) for the least-squares line through 1-D data."""
    x, y = np.asarray(x, dtype=float), np.asarray(y, dtype=float)
    x_mean, y_mean = x.mean(), y.mean()
 
    # w = Cov(x, y) / Var(x), written out as the two sums from the derivation
    numerator = ((x - x_mean) * (y - y_mean)).sum()
    denominator = ((x - x_mean) ** 2).sum()
    if denominator == 0:
        raise ValueError("x has zero variance — no line is determined")
 
    w = numerator / denominator
    b = y_mean - w * x_mean
    return w, b
 
 
x = [1, 2, 3, 4, 5]
y = [2, 4, 5, 4, 5]
 
w, b = fit_simple_linear(x, y)
pred = w * np.array(x) + b
resid = np.array(y) - pred
 
print(f"w = {w}")                    # w = 0.6
print(f"b = {b}")                    # b = 2.2
print(f"residuals = {resid}")        # residuals = [-0.8  0.6  1.  -0.6 -0.2]
print(f"SSE = {(resid ** 2).sum()}") # SSE = 2.4
ols_from_scratch.py
import numpy as np
 
 
def fit_simple_linear(x, y):
    """Return (w, b) for the least-squares line through 1-D data."""
    x, y = np.asarray(x, dtype=float), np.asarray(y, dtype=float)
    x_mean, y_mean = x.mean(), y.mean()
 
    # w = Cov(x, y) / Var(x), written out as the two sums from the derivation
    numerator = ((x - x_mean) * (y - y_mean)).sum()
    denominator = ((x - x_mean) ** 2).sum()
    if denominator == 0:
        raise ValueError("x has zero variance — no line is determined")
 
    w = numerator / denominator
    b = y_mean - w * x_mean
    return w, b
 
 
x = [1, 2, 3, 4, 5]
y = [2, 4, 5, 4, 5]
 
w, b = fit_simple_linear(x, y)
pred = w * np.array(x) + b
resid = np.array(y) - pred
 
print(f"w = {w}")                    # w = 0.6
print(f"b = {b}")                    # b = 2.2
print(f"residuals = {resid}")        # residuals = [-0.8  0.6  1.  -0.6 -0.2]
print(f"SSE = {(resid ** 2).sum()}") # SSE = 2.4

Every number matches the hand calculation, which is the point of doing both.

With scikit-learn

The same fit through the estimator API. Note the reshape(-1, 1)reshape(-1, 1) — scikit-learn always wants XX two-dimensional, one row per sample and one column per feature, even when there is only one feature:

ols_sklearn.py
import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error, r2_score
 
X = np.array([1, 2, 3, 4, 5]).reshape(-1, 1)   # (5, 1) — 5 samples, 1 feature
y = np.array([2, 4, 5, 4, 5])
 
model = LinearRegression()
model.fit(X, y)
 
print(f"w = {model.coef_[0]:.4f}")        # w = 0.6000
print(f"b = {model.intercept_:.4f}")      # b = 2.2000
 
pred = model.predict(X)
print(f"MSE = {mean_squared_error(y, pred):.4f}")  # MSE = 0.4800
print(f"R^2 = {r2_score(y, pred):.4f}")            # R^2 = 0.6000
 
# Predicting for a new spend level
print(f"x=6 -> {model.predict([[6]])[0]:.2f}")     # x=6 -> 5.80
ols_sklearn.py
import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error, r2_score
 
X = np.array([1, 2, 3, 4, 5]).reshape(-1, 1)   # (5, 1) — 5 samples, 1 feature
y = np.array([2, 4, 5, 4, 5])
 
model = LinearRegression()
model.fit(X, y)
 
print(f"w = {model.coef_[0]:.4f}")        # w = 0.6000
print(f"b = {model.intercept_:.4f}")      # b = 2.2000
 
pred = model.predict(X)
print(f"MSE = {mean_squared_error(y, pred):.4f}")  # MSE = 0.4800
print(f"R^2 = {r2_score(y, pred):.4f}")            # R^2 = 0.6000
 
# Predicting for a new spend level
print(f"x=6 -> {model.predict([[6]])[0]:.2f}")     # x=6 -> 5.80

On real data

Five tidy points are a teaching device. Here is the same estimator on 442 real patients from the load_diabetesload_diabetes dataset, predicting one-year disease progression from body mass index alone:

diabetes_bmi.py
from sklearn.datasets import load_diabetes
from sklearn.linear_model import LinearRegression
 
data = load_diabetes()
bmi = data.data[:, 2].reshape(-1, 1)   # column 2 is the standardised BMI
y = data.target
 
model = LinearRegression().fit(bmi, y)
 
print(f"w   = {model.coef_[0]:.2f}")        # w   = 949.44
print(f"b   = {model.intercept_:.2f}")      # b   = 152.13
print(f"R^2 = {model.score(bmi, y):.4f}")   # R^2 = 0.3439
diabetes_bmi.py
from sklearn.datasets import load_diabetes
from sklearn.linear_model import LinearRegression
 
data = load_diabetes()
bmi = data.data[:, 2].reshape(-1, 1)   # column 2 is the standardised BMI
y = data.target
 
model = LinearRegression().fit(bmi, y)
 
print(f"w   = {model.coef_[0]:.2f}")        # w   = 949.44
print(f"b   = {model.intercept_:.2f}")      # b   = 152.13
print(f"R^2 = {model.score(bmi, y):.4f}")   # R^2 = 0.3439
figureOne feature on 442 real patientsmatplotlib
Scatter of 442 patients, standardised BMI on the x-axis against disease progression on the y-axis, with an upward-sloping fitted line. Points scatter widely around the line.Scatter of 442 patients, standardised BMI on the x-axis against disease progression on the y-axis, with an upward-sloping fitted line. Points scatter widely around the line.
The trend is unmistakable and the slope is meaningful — but R-squared of 0.34 says two thirds of the variation lives somewhere other than BMI.

Reading the plot

Three things to take from that figure, in the order you should check them:

  1. The direction is real. The cloud tilts upward consistently, not just at the edges. A positive slope here is a genuine signal, not an artefact of two outliers.
  2. The spread grows with xx. Points on the right scatter further from the line than points on the left. That is heteroscedasticity — it does not bias the slope, but it does make the usual confidence intervals too optimistic.
  3. R2=0.34R^2 = 0.34 is not a failure. For a single biological predictor it is a strong result. It is a signal that more features would help, which is exactly what the next page does.

The residual plot is where you confirm all three at a glance. For the five-point example it looks like this — no curve, no fan, no runaway point:

figureResiduals versus fitted valuesmatplotlib
Residuals plotted against fitted values for the five-point example. Values are -0.8, +0.6, +1.0, -0.6, -0.2 scattered around a dashed zero line with no visible pattern.Residuals plotted against fitted values for the five-point example. Values are -0.8, +0.6, +1.0, -0.6, -0.2 scattered around a dashed zero line with no visible pattern.
What you want: a shapeless band around zero. A U-shape means the relationship is curved; a widening fan means non-constant variance; a lone spike means an outlier.

The four assumptions

Least squares is always computable. It is only trustworthy when these hold:

AssumptionWhat it meansHow to checkIf it breaks
LinearityThe true relationship is a straight lineResidual plot shows no curveAdd polynomial terms, or transform xx
IndependenceObservations do not influence each otherKnow your sampling; check time orderingUse time-series or mixed models
NormalityResiduals are roughly normalQ-Q plot of residualsSlope is still fine; intervals are not
Equal varianceResidual spread is constant across xxResidual plot shows no fanTransform yy, or use weighted least squares

Only linearity affects the point predictions. The other three affect how much you should believe the confidence intervals around them.

algorithmSimple Linear Regression (OLS)Supervised · Regression

APIsklearn.linear_model.LinearRegression

Assumes

  • The relationship between x and y is linear
  • Observations are independent of one another
  • Residual variance is constant across the range of x
  • Residuals are approximately normal (needed for intervals, not for the fit)

Cost

train
O(n)
predict
O(1)
memory
O(1)

n = samples; the one-feature case needs only running sums

Hyperparameters that matter

  • fit_interceptdefault TrueSet False only when the data is already centred, or theory forces the line through the origin.
  • positivedefault FalseConstrains the slope to be non-negative — useful when a negative coefficient would be physically impossible.

Reach for it when

  • You need a baseline before trying anything complicated
  • Interpretability matters more than the last point of accuracy
  • You must explain the effect of one variable in its own units
  • The scatter plot genuinely looks like a line

Look elsewhere when

  • The scatter plot curves — polynomial or tree models will beat it easily
  • Outliers dominate and cannot be removed on principle
  • You have many correlated features (use Ridge or Lasso instead)
  • You need a prediction outside the observed range of x

Pitfalls

Compare

ModelHandles curvesInterpretableRobust to outliersNeeds scalingFirst reach for
Simple linear (OLS)NoVeryNoNoA baseline, and one-variable stories
Polynomial regressionYesModerateNoYes (high degree)Visible curvature
Ridge / LassoNoGoodNoYesMany correlated features
Huber regressionNoVeryYesNoData with genuine outliers
KNN regressorYesPoorModerateYesLocal structure, no global shape
Decision treeYesModerateYesNoInteractions and thresholds
quizCheck yourself
  1. The least-squares line is guaranteed to pass through which point?

    Show answer

    B — The centroid (mean of x, mean of y) — Setting the derivative with respect to b to zero gives b = y-bar minus w times x-bar, which is exactly the statement that the line passes through the centroid.

  2. Your residual plot shows a clear U-shape. What has gone wrong?

    Show answer

    C — The true relationship is curved, so a straight line underfits — A systematic curve in the residuals means the model missed structure in the data. Add polynomial terms or transform the feature.

  3. Why square the residuals rather than sum them directly?

    Show answer

    B — Positive and negative residuals would cancel, so the plain sum is zero for many bad lines — Signs cancel. Squaring also keeps the loss differentiable everywhere, which is what makes a closed-form solution possible — but it makes the fit less robust to outliers, not more.

  4. A model fit on ad spend from 1 to 5 thousand is asked to predict for 50 thousand. What should you do?

    Show answer

    B — Refuse: the prediction is extrapolation far outside the observed range — The model has no evidence about that region. Real relationships usually saturate, while a straight line keeps climbing forever.

🧪 Try It Yourself

Exercise 1 – Compute the slope by hand

Exercise 2 – Push the line through the centroid

Exercise 3 – Residuals must sum to zero

Exercise 4 – Reproduce it with scikit-learn

Exercise 5 – Watch one outlier wreck the fit

Recap

  • The model is y^=wx+b\hat{y} = wx + b; the loss is (yiy^i)2\sum (y_i - \hat{y}_i)^2.
  • Setting both partial derivatives to zero gives w=Cov(x,y)/Var(x)w = \operatorname{Cov}(x,y)/\operatorname{Var}(x) and b=yˉwxˉb = \bar{y} - w\bar{x} — no iteration required.
  • The fitted line always passes through (xˉ,yˉ)(\bar{x}, \bar{y}), and residuals always sum to zero.
  • R2R^2 compares your SSE against the SSE of predicting the mean; 0.60 on the worked example, 0.34 on real diabetes data.
  • Squaring the errors buys differentiability and a closed form, and costs robustness to outliers.
  • Check linearity and equal variance in the residual plot before believing anything else.

Exercise 6 – Check the three identities a least-squares fit must satisfy

Next

Continue to Multiple Linear Regression — the same idea with many features at once, where the two scalar formulas become one matrix equation and interpreting a coefficient gets considerably subtler.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did