FirstHack Learn
Log in Sign up free
Lessons in this course 0/6 All courses Machine Learning Foundations

AI & DS

Progress0 / 6 lessons
  1. 1. What learning from data means, and when ML is the wrong tool
  2. 2. Supervised vs unsupervised learning
  3. 3. Linear regression from scratch, then with scikit-learn
  4. 4. Classification, logistic regression and the confusion matrix
  5. 5. Overfitting, train/test split and cross-validation
  6. 6. Why accuracy is a bad metric on imbalanced data

Courses › Machine Learning Foundations

Linear regression from scratch, then with scikit-learn

Derive the line yourself in ten lines of NumPy, then get the same answer in three lines.

13 min read · Lesson 3 of 6 · Free

The problem

Ten students. Hours studied per day, and the marks they scored.

Python 3
import numpy as np

hours = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], dtype=float)
marks = np.array([35, 42, 48, 51, 60, 63, 70, 74, 80, 86], dtype=float)

We want a line marks = m * hours + b that sits as close to these points as possible.

What "close" means

For each student, the error is the real mark minus the predicted mark. Some errors are positive and some negative, so adding them lets them cancel. Square them first, then take the mean. That is mean squared error:

Python 3
def mse(y_true, y_pred):
    return np.mean((y_true - y_pred) ** 2)

Squaring also punishes a big miss much more than a small one. An error of 10 contributes 100; an error of 2 contributes 4.

Training means finding the m and b that make this number smallest.

The closed-form solution

For a straight line you do not need to search. Calculus gives the answer directly:

Code
m = sum((x - x_mean) * (y - y_mean)) / sum((x - x_mean) ** 2)
b = y_mean - m * x_mean

In code:

Python 3
import numpy as np

hours = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], dtype=float)
marks = np.array([35, 42, 48, 51, 60, 63, 70, 74, 80, 86], dtype=float)

x_mean = hours.mean()
y_mean = marks.mean()

m = np.sum((hours - x_mean) * (marks - y_mean)) / np.sum((hours - x_mean) ** 2)
b = y_mean - m * x_mean

print("slope     :", round(m, 4))      # 5.5455
print("intercept :", round(b, 4))      # 30.4

pred = m * hours + b
print("MSE       :", round(np.mean((marks - pred) ** 2), 4))

You get a slope of about 5.5455 and an intercept of 30.4.

Read the parameters, do not just print them

  • Slope 5.55: in this data, one extra hour of study per day goes with

about 5.5 more marks.

  • Intercept 30.4: the line's value at zero hours.

The intercept deserves care. Nobody in this dataset studied zero hours. The smallest value is 1. So 30.4 is the line extended past the data, and the data says nothing about whether it is true. Predicting outside your observed range is called extrapolation, and it is where models embarrass you.

Feed in 40 hours a day and the formula happily returns 252 marks out of 100. The maths is fine. The prediction is nonsense.

Measuring the fit with R squared

Python 3
ss_res = np.sum((marks - pred) ** 2)
ss_tot = np.sum((marks - marks.mean()) ** 2)
r2 = 1 - ss_res / ss_tot
print("R squared:", round(r2, 4))      # about 0.996

R squared is the fraction of the variation in marks that the line accounts for. 0.996 is very high, and honestly, that is because this small dataset was chosen to be nearly a straight line. Real student data gives something like 0.3 to 0.6, and that is normal. Marks depend on many things, and hours studied is only one of them.

The same thing with scikit-learn

Python 3
import numpy as np
from sklearn.linear_model import LinearRegression

hours = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], dtype=float).reshape(-1, 1)
marks = np.array([35, 42, 48, 51, 60, 63, 70, 74, 80, 86], dtype=float)

model = LinearRegression()
model.fit(hours, marks)

print("slope     :", round(model.coef_[0], 4))       # 5.5455
print("intercept :", round(model.intercept_, 4))     # 30.4
print("R squared :", round(model.score(hours, marks), 4))
print("predicted for 5.5 hours:", model.predict([[5.5]]).round(2))

Identical numbers. scikit-learn is not doing anything you did not just do by hand; it is doing it with better numerical stability and with more features.

⚠️

fit expects X to be two-dimensional, shape (n_samples, n_features), and y to be one-dimensional. A plain 1D array of hours gives ValueError: Expected 2D array, got 1D array instead. That is what .reshape(-1, 1) fixes: -1 means "work out this dimension", 1 means "one feature". This error appears in every beginner's first scikit-learn script.

More than one feature

Python 3
import numpy as np
from sklearn.linear_model import LinearRegression

X = np.array([
    [1, 55], [2, 60], [3, 65], [4, 70], [5, 75],
    [6, 80], [7, 85], [8, 88], [9, 92], [10, 95],
], dtype=float)                      # hours, attendance
y = np.array([35, 42, 48, 51, 60, 63, 70, 74, 80, 86], dtype=float)

model = LinearRegression().fit(X, y)
print(dict(zip(["hours", "attendance"], model.coef_.round(3))))
print("intercept:", round(model.intercept_, 3))

Here the two features rise together, which is called multicollinearity. The coefficients become unstable: change one data point and they can swing wildly, or one can even turn negative. The predictions stay reasonable, but you must not read the individual coefficients as "the effect of attendance".

What linear regression cannot do

  • It fits straight lines only. A curved relationship needs transformed

features or a different model.

  • One extreme outlier pulls the whole line, because errors are squared.
  • It finds association, not cause.
💡

Always plot the points and your fitted line together. If the points curve and your line is straight, R squared can still look respectable while every prediction at the ends is wrong. Your eyes catch that in one second; the number does not tell you.