Warwick AI
Workshop 1
workshop-1

Follow along with the slides, then work through the lab below.

Lab 1: Predict house prices

Getting started (2 minutes)

  • Open the lab notebook in Colab and save your own copy.
  • Click the play button beside a code cell to run it. Work from top to bottom.
  • Double-click text to edit an answer. Click outside when done.

The first cell loads four libraries:

LibraryWhat it does
pandas, shortened to pdStores data as a table called a DataFrame, like a spreadsheet
numpy, shortened to npDoes maths on a whole column of numbers at once
matplotlib.pyplot, shortened to pltDraws graphs
sklearn (scikit-learn)Ready-made machine learning models and tools

It then loads 20,640 neighbourhoods from the 1990 California census into a table called housing.

ColumnMeaning
MedIncMedian household income, in tens of thousands of dollars (3 means $30,000)
MedHouseValMedian house value, in hundreds of thousands of dollars (2 means $200,000)
Other columnsHouse age, rooms, population and location
import matplotlib.pyplot as plt # Draws graphs.
import numpy as np # Does maths on many numbers at once.
import pandas as pd # Stores data in tables.
from sklearn.datasets import fetch_california_housing
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error, mean_squared_error
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeRegressor
housing = fetch_california_housing(as_frame=True).frame # Download the data as a table.
housing.head() # Show the first five rows.

1. Look before you fit (3 minutes)

Predict: do richer neighbourhoods have more expensive houses?

CodeWhat it does
housing.sample(2000, random_state=0)Picks 2,000 random rows. random_state makes it pick the same rows every time
sample["MedInc"]Selects one column of a table
plt.scatter(x, y, s=5)Draws one dot for each pair of values. s sets the dot size

Replace each ... to plot income against house value.

sample = housing.sample(2000, random_state=0)
incomes = sample["MedInc"]
values = ... # Select the MedHouseVal column.
plt.scatter(incomes, values, s=5)
plt.xlabel("Median income ($10,000s)")
plt.ylabel(...) # Label the y-axis.
plt.show()
Answer
sample = housing.sample(2000, random_state=0)
incomes = sample["MedInc"]
values = sample["MedHouseVal"]
plt.scatter(incomes, values, s=5)
plt.xlabel("Median income ($10,000s)")
plt.ylabel("Median house value ($100,000s)")
plt.show()

Was your prediction right? Why is there a flat line of points at the top of the graph?

Answer

Higher incomes go with higher house values, but the points are widely spread.

The flat line at 5 appears because the census recorded every value above $500,000 as $500,000. Real data often has quirks like this.

2. Fit a model and predict (5 minutes)

Train the model as in the flat-price demo. The LinearRegression documentation lists its methods: fit trains the model, and predict uses it on new inputs.

CodeWhat it does
housing[["MedInc"]]Selects a column as a one-column table. Models take their inputs as a table, because there can be several input columns
housing["MedHouseVal"]Selects a single column. Outputs are one column
pd.DataFrame({"MedInc": [3, 8]})Makes a new table with a MedInc column holding 3 and 8

Replace each .... Expect predictions of about 1.70 and 3.79 ($170,000 and $379,000).

inputs = housing[["MedInc"]]
outputs = housing["MedHouseVal"]
model = LinearRegression()
# Fit the model here.
new_neighbourhoods = pd.DataFrame({"MedInc": [3, 8]})
model.predict(...) # Predict values for the new neighbourhoods.
Answer
inputs = housing[["MedInc"]]
outputs = housing["MedHouseVal"]
model = LinearRegression()
model.fit(inputs, outputs)
new_neighbourhoods = pd.DataFrame({"MedInc": [3, 8]})
model.predict(new_neighbourhoods)

Run the cell below to draw the model’s line over your sample. The model learnt two parameters: a slope and an intercept. What do they mean in words?

line_ends = pd.DataFrame({"MedInc": [0, 15]}) # Predict at both ends of the x-axis.
plt.scatter(incomes, values, s=5)
plt.plot(line_ends["MedInc"], model.predict(line_ends), color="black") # Join the two predictions.
plt.xlabel("Median income ($10,000s)")
plt.ylabel("Median house value ($100,000s)")
plt.show()
print("slope:", model.coef_[0], "intercept:", model.intercept_)
Answer

The slope is about 0.42: each extra $10,000 of income adds about $42,000 to the predicted house value. The intercept, about 0.45, is the prediction for an income of zero.

3. Measure the loss (4 minutes)

NumPy and pandas do arithmetic on whole columns at once.

CodeWhat it does
actual - predictedSubtracts each pair of values, giving one error per neighbourhood
errors ** 2Squares every value
np.mean(x)Averages all the values
actual.mean()Averages a column
np.full(n, value)Makes n copies of value
  1. Complete the MSE calculation. Expect about 0.70. The next line checks it with scikit-learn.
  2. A baseline model always predicts the average house value. Calculate its MSE. Expect about 1.33.
actual = outputs
predicted = model.predict(inputs)
errors = actual - predicted
mse = ... # Square the errors, then take the mean.
print(mse)
print(mean_squared_error(actual, predicted))
baseline = np.full(len(actual), actual.mean()) # The average, once per neighbourhood.
mean_squared_error(..., ...) # Compare the actual values with the baseline.
Answer
actual = outputs
predicted = model.predict(inputs)
errors = actual - predicted
mse = np.mean(errors ** 2)
print(mse)
print(mean_squared_error(actual, predicted))
baseline = np.full(len(actual), actual.mean()) # The average, once per neighbourhood.
mean_squared_error(actual, baseline)

4. Give the model more inputs (3 minutes)

housing.drop(columns="MedHouseVal") makes a copy of the table without the MedHouseVal column. That leaves all 8 input columns.

Fit a new model on every input and calculate its MSE. Expect about 0.52.

all_inputs = housing.drop(columns="MedHouseVal")
big_model = LinearRegression()
... # Fit big_model on all_inputs and actual.
mean_squared_error(actual, ...) # Predict with big_model.
Answer
all_inputs = housing.drop(columns="MedHouseVal")
big_model = LinearRegression()
big_model.fit(all_inputs, actual)
mean_squared_error(actual, big_model.predict(all_inputs))

The loss fell. Does that prove the new model will predict better for neighbourhoods it has not seen?

Answer

Not yet. We measured the loss on the same data the model trained on. A more flexible model can lower this loss by fitting noise, so we need data the model has not seen. The second half covers this.

Lab 1 check (1 minute)

Explain in one sentence each:

  • What a loss function measures.
  • Why we compare a model against a simple baseline.
Answer
  • A loss function turns a model’s errors into one number: lower means more accurate predictions.
  • A baseline shows whether the model learnt anything useful. An MSE of 0.70 only means something next to the baseline’s 1.33.

Extension A: Errors in dollars

MSE is in squared units, which are hard to picture. Calculate the mean absolute error with mean_absolute_error, which works like mean_squared_error. Expect about 0.63. What is that in dollars?

mean_absolute_error(..., ...)
Answer
mean_absolute_error(actual, predicted)

The MAE is about 0.63, so a typical prediction is about $63,000 away from the true value. The model halves the baseline’s MSE, but it still makes large errors.

Extension B: Train the model yourself

LinearRegression solves for the best slope and intercept directly. Neural networks cannot do this, so they use gradient descent: start with a guess, then take many small downhill steps.

.to_numpy() turns a column into a NumPy array: a plain list of numbers that supports fast maths.

Fill in the two update lines. After 1,000 steps, expect a slope of about 0.42, an intercept of about 0.44 and an MSE of about 0.70, close to LinearRegression.

Then try a learning rate of 0.1. What happens, and why?

x = housing["MedInc"].to_numpy()
y = housing["MedHouseVal"].to_numpy()
slope, intercept = 0.0, 0.0
learning_rate = 0.01
for step in range(1000):
error = slope * x + intercept - y # Forward pass, then compare with the answers.
slope_gradient = 2 * np.mean(error * x)
intercept_gradient = 2 * np.mean(error)
slope = ... # Step slope downhill.
intercept = ... # Step intercept downhill.
print(slope, intercept, np.mean((slope * x + intercept - y) ** 2))
Answer
x = housing["MedInc"].to_numpy()
y = housing["MedHouseVal"].to_numpy()
slope, intercept = 0.0, 0.0
learning_rate = 0.01
for step in range(1000):
error = slope * x + intercept - y # Forward pass, then compare with the answers.
slope_gradient = 2 * np.mean(error * x)
intercept_gradient = 2 * np.mean(error)
slope = slope - learning_rate * slope_gradient
intercept = intercept - learning_rate * intercept_gradient
print(slope, intercept, np.mean((slope * x + intercept - y) ** 2))

With a learning rate of 0.1, each step overshoots the minimum by more than the last. The parameters and the loss grow until they overflow to nan.

Take-home: Find the sweet spot (about 15 minutes)

You will choose how flexible a model should be, using validation data.

A decision tree predicts by asking a series of yes-or-no questions, such as “Is MedInc above 5?”. max_depth limits how many questions it can ask in a row. A deeper tree is more flexible.

T1. Split the data three ways (3 minutes)

train_test_split(table, test_size=0.3, random_state=0) shuffles the rows, then splits them into two tables. test_size=0.3 puts 30% of the rows in the second table.

Split rest in half to make the validation and test sets. Expect 14,448, 3,096 and 3,096 rows.

train, rest = train_test_split(housing, test_size=0.3, random_state=0)
val, test = ... # Split rest in half.
input_columns = housing.columns.drop("MedHouseVal") # The names of the 8 input columns.
print(len(train), len(val), len(test)) # len counts the rows.
Answer
train, rest = train_test_split(housing, test_size=0.3, random_state=0)
val, test = train_test_split(rest, test_size=0.5, random_state=0)
input_columns = housing.columns.drop("MedHouseVal") # The names of the 8 input columns.
print(len(train), len(val), len(test)) # len counts the rows.

T2. Train trees of increasing depth (5 minutes)

train[input_columns] selects the input columns from the training table.

The loop trains one tree for each depth from 1 to 20 and records its training MSE. Complete the line that records the validation MSE, then run the cell to plot both.

Predict before you run: what will each line look like?

depths = range(1, 21) # 1, 2, ..., 20
train_losses = []
val_losses = []
for depth in depths:
tree = DecisionTreeRegressor(max_depth=depth, random_state=0)
tree.fit(train[input_columns], train["MedHouseVal"])
train_losses.append(mean_squared_error(train["MedHouseVal"], tree.predict(train[input_columns])))
val_losses.append(...) # The same, for the validation set.
plt.plot(depths, train_losses, marker="o", label="Training")
plt.plot(depths, val_losses, marker="o", label="Validation")
plt.xlabel("Tree depth")
plt.ylabel("MSE")
plt.legend()
plt.show()
Answer
depths = range(1, 21) # 1, 2, ..., 20
train_losses = []
val_losses = []
for depth in depths:
tree = DecisionTreeRegressor(max_depth=depth, random_state=0)
tree.fit(train[input_columns], train["MedHouseVal"])
train_losses.append(mean_squared_error(train["MedHouseVal"], tree.predict(train[input_columns])))
val_losses.append(mean_squared_error(val["MedHouseVal"], tree.predict(val[input_columns])))
plt.plot(depths, train_losses, marker="o", label="Training")
plt.plot(depths, val_losses, marker="o", label="Validation")
plt.xlabel("Tree depth")
plt.ylabel("MSE")
plt.legend()
plt.show()

Which depths underfit? Which overfit? How can you tell from the two lines?

Answer

Shallow trees (depth 1 to about 5) underfit: both losses are high. Deep trees (from about 12) overfit: the training loss falls towards 0, but the validation loss rises again. A depth-20 tree almost memorises the training set.

T3. Choose a depth, then test it once (3 minutes)

np.argmin(val_losses) gives the position of the smallest value in the list.

The cell picks the depth with the lowest validation MSE. Expect depth 9, with a validation MSE of about 0.43. Complete the last line to calculate its MSE on the test set. Do this only once, at the end.

best_position = np.argmin(val_losses)
best_depth = depths[best_position]
print("best depth:", best_depth, "validation MSE:", val_losses[best_position])
best_tree = DecisionTreeRegressor(max_depth=best_depth, random_state=0)
best_tree.fit(train[input_columns], train["MedHouseVal"])
... # Calculate the MSE on the test set.
Answer
best_position = np.argmin(val_losses)
best_depth = depths[best_position]
print("best depth:", best_depth, "validation MSE:", val_losses[best_position])
best_tree = DecisionTreeRegressor(max_depth=best_depth, random_state=0)
best_tree.fit(train[input_columns], train["MedHouseVal"])
mean_squared_error(test["MedHouseVal"], best_tree.predict(test[input_columns]))

The test MSE is about 0.43, close to the validation MSE. That suggests our choice holds up on new data.

Why should you not use the test set to choose the depth?

Answer

If we chose the depth using the test set, we would pick the depth that happens to suit those particular examples. The test score would then look better than the model really is.

Open-ended: Beat 0.40

Can you get the validation MSE below 0.40? Keep the test set for one final check.

Start from the cell below, and change the model or its settings. Ideas to try:

  • Other tree settings, such as min_samples_leaf, the smallest number of examples allowed in each final group.
  • Other models, such as RandomForestRegressor from sklearn.ensemble. Read its documentation first.

Record what you tried:

Model and settingsTraining MSEValidation MSE
DecisionTreeRegressor(max_depth=9)
from sklearn.ensemble import RandomForestRegressor
candidate = DecisionTreeRegressor(max_depth=9, random_state=0) # Change the model or its settings.
candidate.fit(train[input_columns], train["MedHouseVal"])
print("training MSE:", mean_squared_error(train["MedHouseVal"], candidate.predict(train[input_columns])))
print("validation MSE:", mean_squared_error(val["MedHouseVal"], candidate.predict(val[input_columns])))
Answer

There are many good answers. Two models from sklearn.ensemble combine many trees and beat 0.40 with their default settings. A random forest reaches about 0.28, and gradient boosting reaches about 0.23. A single tree with no depth limit and min_samples_leaf=10 also reaches about 0.39: each leaf must average at least 10 neighbourhoods, which stops it memorising single examples.

from sklearn.ensemble import HistGradientBoostingRegressor, RandomForestRegressor
for candidate in [RandomForestRegressor(random_state=0, n_jobs=-1), HistGradientBoostingRegressor(random_state=0)]:
candidate.fit(train[input_columns], train["MedHouseVal"])
print(type(candidate).__name__)
print("training MSE:", mean_squared_error(train["MedHouseVal"], candidate.predict(train[input_columns])))
print("validation MSE:", mean_squared_error(val["MedHouseVal"], candidate.predict(val[input_columns])))

The random forest’s training MSE (0.04) is far below its validation MSE. That gap shows some overfitting, but we still choose by the lowest validation loss: no other model here does better on new data.