Once a dataset has been prepared well enough (missing data replaced, suitably rescaled, new features defined), you can venture into a regression analysis. A regression describes the relationship between two variables. One of them is treated as depending on the other, and the dependence is made explicit as a functional relationship – a mathematical map that computes, approximately, the values of the one variable from the values of the other.
An example, before we fix the vocabulary. In the Iris dataset we suspect a relationship between the length and the width of a petal: longer petals ought to be wider as well. We look at the cloud of points and draw a straight line through it – at first entirely by eye.
import numpy as np
import matplotlib.pyplot as plt
from sklearn import datasets
iris_data = datasets.load_iris().data
petal_length, petal_width = iris_data[:, 2], iris_data[:, 3]
fig, ax = plt.subplots(figsize=(8, 5))
ax.plot(petal_length, petal_width, "+", label="Data")
line = np.linspace(petal_length.min(), petal_length.max(), 2)
ax.plot(line, 0.5 * line - 1, label="rule of thumb: width = length/2 - 1")
ax.set_xlabel("Petal length (cm)")
ax.set_ylabel("Petal width (cm)")
ax.legend()
plt.show()
What we read off is the rule of thumb
predicted_petal_width = petal_length * 0.5 - 1and with that, the example already contains every ingredient of a regression:
There is a function that computes the target variable from the explanatory variable – here .
The function has parameters, here the numbers 0.5 and -1, and we obtained them from the data, not from theory.
The prediction is not exact. For every single flower something is left over, and we have to keep track of it.
The third point is why a regression is not simply a function. And the second immediately raises the question of whether 0.5 and -1 really are the best numbers – the rest of this chapter answers it. (To anticipate: they are not. The method of least squares finds 0.416 and -0.363 and pushes the mean squared error down from 0.167 to 0.042.)
A regression model consists of:
regressors , that is, a (-variate) ‘independent’ variable, the explanatory variable (here is the number of regressors, not the number of clusters as in k-means)
a regressand , that is, a (univariate) ‘dependent’ variable, the target variable
parameters
error terms
For concrete data and a given regression model, is also called the -th residual.
Some fixed notation will help here. A sample consists of instances together with the associated values of the target variable , which in machine learning are called labels. If the regressor is multidimensional we write for the value of the -th feature at the -th instance – the first index counts the rows of the table, the second the columns. The model’s prediction is
so it depends on the estimated parameters and changes with them. The residuals are then exactly the differences , and so they are all we ever see of the error terms: the error term is a random variable we never observe; the residuals are its instances, once we have committed to a . That is why you check a regression model by looking at its residuals – they ought to look like a sample from the distribution you assumed for .
A regression analysis consists of setting up a regression model and then estimating the function that fits the data best – the one with the smallest residuals.
Particular regression methods differ in the kind of function they permit. In linear regression, is linear in the parameters .
Least squares¶
The least squares estimator is a maximum likelihood estimator if the error terms are assumed to be normally distributed (the model for this is at the end of the section Linear regression).
In general, can be computed with the formula
So to determine first in the general case, you shift the data by their means, replacing by and by . That yields the formula
Divide numerator and denominator by and you are looking at two quantities we already know: on top, the empirical covariance of and ; underneath, the empirical variance of (both in the -version, see the remark For data in the chapter Covariance). The slope of the line of best fit is therefore
This is the form that is quickest to compute by hand, and it also says something: the slope is the covariance, normalised by the spread of the regressor. Compare it with the correlation coefficient from the chapter Covariance and you get – correlation and regression slope differ only by the ratio of the two standard deviations.
import numpy as np
np.random.seed(1234)
# generate synthetic data:
x = np.linspace(-2, 8)
y = x**2 + 3*x - 7 + np.random.uniform(-3, 3, size=x.shape)
# compute estimate from before:
x_centered = x - np.mean(x)
y_centered = y - np.mean(y)
beta_1 = ( x_centered.T @ y_centered ) / sum(np.square(x_centered))
beta_0 = np.mean(y) - beta_1*np.mean(x)
# the same number as covariance over variance (ddof=0: the n-version, as in np.var):
assert np.isclose(beta_1, np.cov(x, y, ddof=0)[0, 1] / np.var(x))
print("line equation is y = %f*x + %f" % (beta_1, beta_0))
line equation is y = 8.912806*x + -6.929693
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(10,10))
ax.plot(x, y, "+", label="Data")
ax.plot(x, beta_0 + x*beta_1, label="Line of minimal distance")
ax.set_xlabel("Explanatory variable (regressor)")
ax.set_ylabel("Dependent target variable (regressand)")
ax.legend()
plt.show()
The method of least squares can also be run directly in Scipy (so there is no need to remember the formulas or the derivation):
from scipy.optimize import curve_fit
# define function class for regression (linear):
def f(x, beta_0, beta_1):
return beta_1*x + beta_0
# perform regression analysis:
parameters, covariance_matrix = curve_fit(f, x, y)
assert np.all(np.isclose((beta_0, beta_1), parameters))From the Scipy documentation:
scipy.optimize.curve_fit
Use non-linear least squares to fit a function, f, to data.
Assumes ydata = f(xdata, *params) + eps.
fig, ax = plt.subplots(figsize=(10,10))
ax.plot(x, y, "+", label="Data")
ax.plot(x, f(x, beta_0, beta_1), label="Line of minimal distance")
yerr = np.stack((np.maximum(np.zeros_like(y), y - f(x, beta_0, beta_1)),
-np.minimum(np.zeros_like(y), y - f(x, beta_0, beta_1))))
ax.errorbar(x, y,
yerr=yerr,
linewidth=0, elinewidth=1,
label="Residuals")
ax.set_xlabel("Explanatory variable (regressor)")
ax.set_ylabel("Dependent target variable (regressand)")
ax.legend()
plt.show()
Note that this is not the direction of maximal variance – that too minimises distances, but the distances of the data points to the line. Linear regression minimises not the distances of the data points to the line but the residuals, which are the distances along the -axis. The plot above shows this: the green bars run parallel to the -axis, not perpendicular to the line (which is what the distance to the line would be).
This also makes clear that regression is an asymmetric method: regressor and regressand cannot be swapped without changing the model. When searching for the line along the direction of maximal variance, by contrast, the - and -coordinates are on an equal footing and the solution is invariant under rotations.
Linear regression¶
The second definition looks more general, but it reduces to the first. Append a constant one to the regressors and the bias to the weights:
This is the usual tacit convention: for we then have . The term (denoted earlier) is also called the bias term (intercept). It has nothing to do with the bias of an estimator from the previous chapter; the word simply has two meanings in the machine learning literature.
A simple linear regression can be carried out with Scipy.stats:
from scipy.stats import linregress
linregress_result = linregress(x, y)
slope, intercept, r, p, se = linregress_result
print("Slope: %f" % slope)
assert np.isclose(slope, beta_1)
print("Intercept: %f" % intercept)
assert np.isclose(intercept, beta_0)
print("Correlation coefficient: %f" % r)
print("p-value: %.32f" % p)
print("Standard error of the slope: %f" % se)Slope: 8.912806
Intercept: -6.929693
Correlation coefficient: 0.959514
p-value: 0.00000000000000000000000000046030
Standard error of the slope: 0.377630
The five return values are worth a moment. slope is the slope and intercept is the intercept , that is, the bias – those are the two parameters we were after. r is the correlation coefficient between and (see Covariance and dependence); its square is the proportion of the variance of that the model explains, and it turns up again further down as the coefficient of determination . p belongs to a test that Scipy ran along the way, unasked (more on that in a moment). And se, despite the name, is not a squared error but the standard error of the slope estimate: the estimated standard deviation of slope if you were to repeat the whole experiment with a fresh sample. As a rule of thumb, the true slope lies within slope se with a probability of about .
Scipy has also run a test for us straight away: a two-sided test with the null hypothesis that the slope slope equals 0. Since the -value is considerably smaller than , we reject the hypothesis and assume a slope . (For now we simply take this as given, without going further into statistical tests – or we ignore this part of the Scipy.stats output.)
Before we write this down in general, here is an example in which the trick is immediately obvious. The Iris dataset gives us the petal length and the petal width , and we would like to predict the sepal length . Now presumably depends less on length or width individually than on the area of the petal – and that, up to a shape factor, is the product of the two. So we build a new regressor and use it to compute a perfectly ordinary linear regression .
from sklearn import datasets
iris = datasets.load_iris()
x_iris = iris.data
petal_area = x_iris[:, 2] * x_iris[:, 3] # petal length times petal width
sepal_length = x_iris[:, 0]
result = linregress(petal_area, sepal_length)
print("Slope: %f" % result.slope)
print("Intercept: %f" % result.intercept)
print("Correlation coefficient: %f" % result.rvalue)
print("Standard error of the slope: %f" % result.stderr)
fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(petal_area, sepal_length, "+", label="Data")
ax.plot(petal_area, result.intercept + result.slope * petal_area, label="Regression line")
ax.set_xlabel("Petal area (cm²)")
ax.set_ylabel("Sepal length (cm)")
ax.legend()
plt.show()Slope: 0.150646
Intercept: 4.970482
Correlation coefficient: 0.857300
Standard error of the slope: 0.007436

All we have done here is apply the function to the regressors before the regression. The model stays linear – in the weights , and that is what counts.
For comparison: the petal length alone gives , a little more than the area does. A basis function expansion is not automatically the better regressor just because it is more complicated – it is a hypothesis about the data, and whether it helps is an empirical question. Such preprocessing steps have a name of their own.
So in a linear regression only one thing is linear: the function , in the variables . This also lets us read the earlier tacit replacement of by as a basis function expansion, with .
Since a linear regression analysis is very easy to carry out, the central question in linear regression becomes how to set up the model – and in particular the basis function – from the data.
The case is called a simple linear regression, the more general case a multiple linear regression, because there the regressor is a multivariate independent variable . Scipy.stats’s linear regression can only do the simple kind!
So we now turn to multiple linear regression with Scikit-learn:
from sklearn.linear_model import LinearRegression
# first look at linear, to compare with the above:
linear_regressor = LinearRegression()
linear_regressor.fit(x.reshape(-1, 1), y)
Y_pred = linear_regressor.predict(x.reshape(-1, 1))fig, ax = plt.subplots(figsize=(10,10))
ax.plot(x, y, "+", label="Data")
ax.plot(x, Y_pred, label="Scikit-learn fit")
ax.plot(x, f(x, beta_0, beta_1), "o", label="Scipy.stats fit")
ax.legend()
plt.show()
From the Scikit-learn documentation:
sklearn.linear_model.LinearRegression
From the implementation point of view, this is just plain Ordinary Least Squares (scipy.linalg.lstsq) or Non Negative Least Squares (scipy.optimize.nnls) wrapped as a predictor object.
Seen this way, Scikit-learn is not ‘better’ than Scipy, just a layer on top of it.
One problem in choosing a basis function for a regression model is overfitting. With too many parameters available, the data can certainly be fitted very well, but the model does not generalise well. In practice this is checked by fitting on a training dataset and then evaluating on a test dataset.
# define basis function class
def phi(x, d):
return np.dstack([np.power(x, i) for i in range(d+1)]).squeeze()
def plot_regression(x, y, Y_pred, ax, x_test=None, y_test=None):
ax.plot(x, y, "+", label="Data")
if(x_test is None):
x_test = x
ax.plot(x_test, Y_pred, label="Model")
if(y_test is None):
y_test = y
yerr = np.stack(( np.maximum(np.zeros_like(y_test), y_test - Y_pred),
-np.minimum(np.zeros_like(y_test), y_test - Y_pred)))
ax.errorbar(x_test, y_test,
yerr=yerr,
linewidth=0, elinewidth=1,
label="Residuals")
ax.set_xlabel("Explanatory variable (regressor)")
ax.set_ylabel("Dependent target variable (regressand)")
ax.legend()
return ax
def poly_regression_plot(x, y, d):
# perform regression analysis:
transformed_x = phi(x, d)
linear_regressor = LinearRegression()
linear_regressor.fit(transformed_x, y)
Y_pred = linear_regressor.predict(transformed_x)
fig, ax = plt.subplots(figsize=(10,10))
plot_regression(x, y, Y_pred, ax).\
set_title("Polynomial regression of degree %d" % d)
plt.show()poly_regression_plot(x, y, 1)
poly_regression_plot(x, y, 2)
poly_regression_plot(x, y, 30)
A linear regression model can also be formulated as
or, with a basis function expansion,
Here the are the parameters of the normally distributed errors .
An example application¶
We look at the Melbourne Housing Market dataset by Anthony Pino. It is licensed CC BY-NC-SA 4.0 and is therefore exempt from the licence of these notes: re-use only non-commercially and under the same terms.
To get started, we clean the data a little.
import pandas as pd
df = pd.read_csv("Melbourne_housing_FULL.csv")
print(df.shape)
print(df.columns)(34857, 21)
Index(['Suburb', 'Address', 'Rooms', 'Type', 'Price', 'Method', 'SellerG',
'Date', 'Distance', 'Postcode', 'Bedroom2', 'Bathroom', 'Car',
'Landsize', 'BuildingArea', 'YearBuilt', 'CouncilArea', 'Lattitude',
'Longtitude', 'Regionname', 'Propertycount'],
dtype='str')
# don't care about lat, lon here:
df = df.drop(columns=['Lattitude','Longtitude'])
# make Date a date:
df['Date'] = pd.to_datetime(df['Date'], format="%d/%m/%Y")
# Postcode is not really numeric:
df['Postcode'] = df['Postcode'].astype('category')
# make string variables categorical:
string_columns = df.select_dtypes(include=['object', 'string']).columns # 'string' for pandas >= 3
df[string_columns] = df[string_columns].astype('category')
# Transform year built to age:
df['Age'] = 2017 - df['YearBuilt']
df = df.drop(columns='YearBuilt')
# kill faulty data:
df = df[df['BuildingArea'] != 0]
df = df[df['Age'] < 500]
# remove all suspects with missing data
df = df.dropna()
# remaining numerical variables:
numerical_columns = df.select_dtypes(['float']).columns
print(numerical_columns)Index(['Price', 'Distance', 'Bedroom2', 'Bathroom', 'Car', 'Landsize',
'BuildingArea', 'Propertycount', 'Age'],
dtype='str')
regressor_columns = numerical_columns.drop('Price')
x = df[regressor_columns]
y = df['Price']
from sklearn.model_selection import train_test_split
x_train, x_test, y_train, y_test \
= train_test_split(x, y, test_size = .20, random_state=0)
regressor = LinearRegression()
regressor.fit(x_train, y_train)
y_pred = regressor.predict(x_test)
from sklearn.metrics import r2_score
print("R^2 = %f%% of the variance is explained by the linear model"
% (100*r2_score(y_test, y_pred)))R^2 = 50.355458% of the variance is explained by the linear model
Before we interpret the coefficients, we check the model assumption that the error terms scatter around 0 independently of : the residuals , plotted against the prediction, ought to lie around 0 with no discernible pattern. A funnel means that the spread depends on the price; curvature, that a linear relationship is not enough.
from sklearn.metrics import PredictionErrorDisplay
PredictionErrorDisplay.from_predictions(y_test, y_pred, kind="residual_vs_predicted",
scatter_kwargs={"alpha": 0.3})
plt.show()
cdf = pd.DataFrame(data = regressor.coef_,
index = regressor_columns,
columns = ['Coefficients'])
cdfInterpretation:
If one house is a year older than another, the associated price difference is about 6081 (Australian) dollars. If one house is a kilometre closer to the centre (which Melbourne calls the CBD) – that is, its Distance variable is smaller by 1 – the associated price difference is about 27377 dollars.
Categorical regressors: one-hot encoding¶
While cleaning the data we turned every text column into a category – and then quietly
left them out of the model, since numerical_columns holds only the float columns. Yet
Type (h = house, u = unit, that is a flat, t = townhouse) ought to have something to say about the price.
Simply setting , , would be wrong: Type is on a nominal scale, with no order
and no equal spacings, and the regression would compute with both (see
Descriptive statistics, section Level of measurement). Instead, every
level gets a column of its own, the one-hot encoding; pandas calls the columns dummies.
One of the columns is left out (drop_first=True): whatever is neither u nor t is h, and the
omitted level becomes the reference category against which the other coefficients are measured.
If all three columns were kept, their sum would always be 1 – the same information as the
intercept – and the parameters would no longer be uniquely determined (the dummy variable trap).
dummies = pd.get_dummies(df['Type'], prefix='Type', drop_first=True, dtype=float)
print(dummies.head())
x_onehot = pd.concat([df[regressor_columns], dummies], axis=1)
x_train_oh, x_test_oh, y_train_oh, y_test_oh \
= train_test_split(x_onehot, y, test_size=.20, random_state=0)
regressor_onehot = LinearRegression().fit(x_train_oh, y_train_oh)
print("R^2 without Type: %.1f%%" % (100 * r2_score(y_test, y_pred)))
print("R^2 with Type: %.1f%%" % (100 * r2_score(y_test_oh, regressor_onehot.predict(x_test_oh))))
pd.DataFrame(data=regressor_onehot.coef_, index=x_onehot.columns, columns=['Coefficients']).tail(2)
Type_t Type_u
2 0.0 0.0
4 0.0 0.0
6 0.0 0.0
11 0.0 0.0
14 0.0 0.0
R^2 without Type: 50.4%
R^2 with Type: 50.7%
The two new coefficients read as a price difference relative to a house with otherwise equal
features: in the model, a flat with the same area, the same age and the same distance to the
centre is around dollars cheaper, a townhouse around dollars.
That barely rises is, on reflection, no surprise: most of what
Type reveals about the price already sits in BuildingArea, Landsize and Bedroom2 – flats
are, above all, smaller. So the encoding did not invent the feature’s value; it only made
it readable.
An ordinal feature (a condition “poor / medium / good”, say) could, by contrast, fairly have been
encoded as – there the order is real, and only the equal spacings are an assumption. For
nominal features with very many levels (Suburb, say, with several hundred suburbs)
one-hot encoding becomes unwieldily wide; then other ideas are needed, which go beyond this
course.
To close this example application, two more pictures of linear regression in the variables distance and age – first one of them, then both:
regressor_variable = x_train[["Distance"]]
regressor.fit(regressor_variable, y_train)
y_pred = regressor.predict(regressor_variable)
fig, ax = plt.subplots()
ax.scatter(x_test["Distance"], y_test,
marker='.', alpha=0.1,
label="Data")
xs = np.arange(np.max(x_test["Distance"]))
ys = regressor.coef_[0]*xs + regressor.intercept_
ax.plot(xs, ys, color='red', label="Regression line")
ax.set_xlabel("Distance to the centre")
ax.set_ylabel("Price")
ax.legend()
plt.show()
regressor_variable = x_train[["Distance", "Age"]]
regressor.fit(regressor_variable, y_train)
y_pred = regressor.predict(regressor_variable)
from mpl_toolkits.mplot3d import Axes3D
xs = np.arange(30)
ys = np.arange(10, np.max(x_test["Age"]))
xs, ys = np.meshgrid(xs, ys)
zs = xs*regressor.coef_[0] \
+ ys*regressor.coef_[1] \
+ regressor.intercept_
fig = plt.figure(figsize=(10,10))
ax = fig.add_subplot(111,projection='3d')
ax.plot_surface(xs, ys, zs,
alpha=0.8, color='red')
ax.scatter(x_test["Distance"], x_test["Age"], y_test,
marker='.', alpha=0.3)
ax.set_zlim3d(zmax=4000000)
ax.set_xlim3d(xmax=30)
ax.set_ylim3d(ymin=10)
ax.set_xlabel("Distance")
ax.set_ylabel("Age")
ax.set_zlabel("Price")
ax.set_yticks([20,60,100,140])
ax.view_init(elev=2., azim=-75)
plt.show()
Given how these data look, a polynomial regression would help here as well. We put together a pipeline to compute it.
from sklearn.preprocessing import PolynomialFeatures
from sklearn.pipeline import make_pipeline
degree = 5
polyreg = make_pipeline(PolynomialFeatures(degree), LinearRegression())
polyreg.fit(x_train[["Distance"]], y_train)
# for plotting purposes, sort:
x_test = x_test.sort_values("Distance")
y_pred = polyreg.predict(x_test[["Distance"]])
fig, ax = plt.subplots(figsize=(10, 10))
ax.scatter(x_test["Distance"], y_test,
marker='.', alpha=0.5,
label="Data")
ax.plot(x_test["Distance"], y_pred,
color='red', label="Regression curve")
ax.set_xlabel("Distance to the centre")
ax.set_ylabel("Price")
ax.legend()
plt.show()
Regularisation¶
With only a few support points in the training data, overfitting becomes all the more likely. We look at this in a few examples:
# generate new synthetic data:
np.random.seed(123)
x = np.linspace(-6, 4)
y = -x**2 + 4*x - 5 + np.random.uniform(-4, 4, size=x.shape)
def plot_from_small_training(ax, random_state, model=LinearRegression()):
# simulate having very few training data:
x_train, x_test, y_train, y_test \
= train_test_split(x, y, test_size = .95,
random_state=random_state)
regressor = model
regressor.fit(x_train.reshape(-1, 1), y_train)
y_pred = regressor.predict(x_test.reshape(-1, 1))
plot_regression(x, y, y_pred, ax, x_test, y_test)
ax.plot(x_train, y_train, "o")
fig, axs = plt.subplots(3, 1, figsize=(10,10))
plot_from_small_training(axs[0], 3)
plot_from_small_training(axs[1], 4)
plot_from_small_training(axs[2], 5)
plt.show()
The trick of regularisation is not to fit the training data too well.
L2 regularisation, also known as ridge regression, minimises a penalty term as an additional optimisation objective while searching for the best fit to the regression problem. Here is a free parameter that sets the strength of the penalty.
The name L2 comes from being the square of the L2 norm.
The same idea of penalising large model weights goes by the name weight decay in deep learning (the course Introduction to Deep Learning).
There is also L1 regularisation, which uses the summand instead of and so corresponds to the L1 norm; it is also known as lasso regularisation.

from sklearn.linear_model import Ridge
fig, axs = plt.subplots(5, 1, figsize=(10,10))
plot_from_small_training(axs[0], 3, model=Ridge(alpha=0.5))
plot_from_small_training(axs[1], 3, model=Ridge(alpha=2.5))
plot_from_small_training(axs[2], 5, model=Ridge(alpha=0.5))
plot_from_small_training(axs[3], 5, model=Ridge(alpha=2.5))
plot_from_small_training(axs[4], 10, model=Ridge(alpha=7))
plt.show()
The practical difference between L1 and L2: the L1 penalty sets coefficients to exactly 0 (those are the vertices of the L1 ball in the figure), so it selects regressors. With the polynomial terms as regressors for the synthetic data above, you can see this directly:
from sklearn.linear_model import Lasso
from sklearn.preprocessing import PolynomialFeatures, StandardScaler
from sklearn.pipeline import make_pipeline
def poly_fit(model):
pipe = make_pipeline(PolynomialFeatures(degree=8, include_bias=False),
StandardScaler(), model)
return pipe.fit(x.reshape(-1, 1), y)[-1] # the fitted model at the end of the pipeline
np.set_printoptions(precision=2, suppress=True)
ridge = poly_fit(Ridge(alpha=1.0))
lasso = poly_fit(Lasso(alpha=0.5))
print("Ridge coefficients:", ridge.coef_)
print("Lasso coefficients:", lasso.coef_)
print("Exactly 0 under Lasso:", np.sum(lasso.coef_ == 0), "of", len(lasso.coef_), "coefficients")Ridge coefficients: [10.6 -7.56 2.39 -1.92 -0.35 0.04 -0.72 0.36]
Lasso coefficients: [11.34 -8.76 0.71 -0. 0. -0. 0. -0. ]
Exactly 0 under Lasso: 5 of 8 coefficients
Summary¶
A regression is a model ; linear means linear in the parameters, which is why basis functions and one-hot columns do not take us out of the model. The method of least squares yields , and it is maximum likelihood when the error terms are normally distributed; residual plots check that assumption. Regularisation (ridge, lasso) penalises large coefficients and is the remedy for overfitting when there are too many basis functions.