Python 6.3Written lesson11 minLesson 3 of 3 in Module 6
The Estimator Pattern
Write a tiny model class with `fit` and `predict`, the shape every scikit-learn model has.
Video in production.
Read the lesson: The Estimator Pattern
01The estimator interface: fit and predict
Here are six used cars. Their ages are 1, 2, 4, 6, 8 and 9 years. Their prices, in thousands of dollars, are 23.2, 19.6, 15.2, 11.2, 7.6 and 7.2. The older the car, the lower its price.
Store them as two lists, one for the ages and one for the prices:
ages = [1, 2, 4, 6, 8, 9]
prices = [23.2, 19.6, 15.2, 11.2, 7.6, 7.2]
len(ages), len(prices)
(6, 6)
Six ages and six prices, one of each per car.
Three steps
Every prediction model in scikit-learn is used in the same three steps:
- First, you create the model, with any settings it needs.
- Then you call its fitKey termThe method that learns from examples. It stores what it learned in attributes ending in
_, and returnsself. method with examples, which here are the ages and their prices. That is where the model learns. - And then you call its predictKey termThe method that gives answers for new inputs, using what
fitlearned. method with new ages, and it returns a price for each one.
scikit-learn's name for an object that is used in these three steps is an estimatorKey termAn object that learns from data in fit and answers in predict. Every scikit-learn model is one., and that is the word this lesson uses too.
So what does a class need so it can be used in those three steps? And what goes into each method? You will answer both questions by writing two models of your own.
02fit and learned state: a mean predictor
Start with the simplest model there is. It does not use the age at all. It just predicts the average price for every car.
It is called MeanPredictor, and it has two methods. fit takes the ages, called X, and the prices, called y, and computes the mean of the prices. It stores that as self.mean_, and then returns self. And predict takes a list of ages, and returns the stored mean once for each of them:
class MeanPredictor:
def fit(self, X, y):
self.mean_ = sum(y) / len(y)
return self
def predict(self, X):
return [self.mean_ for x in X]
The value that fit stores on the object is the model's learned state: what it worked out from the examples, kept so that predict can use it later.
Fitting the model
Create a MeanPredictor and fit it on the ages and prices. fit returns the model itself, so model.fit(ages, prices) is model gives True:
model = MeanPredictor()
model.fit(ages, prices) is model
True
And model.mean_ is the average price:
model.mean_
14.0
Predicting
So what do you think model.predict gives for a three-year-old car and a ten-year-old car? Think it through before reading on.
I have a guess. Show meHide the answer
model.predict([3, 10])
[14.0, 14.0]
It gives 14.0 for both, because this model gives the same answer whatever the age is. It is the baseline that machine learning starts from, and any useful model has to do better than it.
Chaining fit and predict
Because fit returns self, you can chain the calls on one line. Create the model, fit it, and predict for a five-year-old car:
MeanPredictor().fit(ages, prices).predict([5])
[14.0]
MeanPredictor() creates the object, .fit(ages, prices) returns that same object, and .predict([5]) is called on it.
03Learned attributes and the trailing underscore convention
Now, why does mean_ end in an underscore?
In scikit-learn, and in any class written in its style, an attribute that ends in an underscore was learned from data, in fit. So vars of the fitted model shows one attribute, mean_, and it was fit that put it there:
vars(model)
{'mean_': 14.0}
Predicting before fitting
That convention also makes a common mistake easy to read. Call predict on a new MeanPredictor, before it has been fitted:
MeanPredictor().predict([3])
AttributeError: 'MeanPredictor' object has no attribute 'mean_'
Python raises an AttributeError, because fit never ran, so self.mean_ was never created. The underscore shows you that the missing value is one that fit was supposed to learn.
scikit-learn gives a clearer message for the same mistake. It raises a NotFittedError, which says the estimator is not fitted yet, and to call fit before using it. You will see it once scikit-learn is in the project, later in this lesson.
So far
Here is what you have so far:
- An estimator learns in
fit, and stores what it learned in attributes that end in an underscore. fitreturns the estimator itself.- And
predictuses the stored values to give an answer for each input.
04Hyperparameters: k nearest neighbours
The mean predictor ignores the age. A model that does use it is called k nearest neighbors, and here is the idea. To predict the price of a car, you find the cars in the data whose ages are nearest to its age, and take the average of their prices. k is how many of those nearest cars you use.
So k is a setting of the model, and it is chosen before the model is fitted on any data. A setting like that is called a hyperparameterKey termA setting of a model chosen before training, not learned in fit: k in KNNRegressor(k=2)..
KNNRegressor
KNNRegressor takes k in __init__, and stores it as self.k, with no underscore, because it was not learned:
class KNNRegressor:
def __init__(self, k=3):
self.k = k
def fit(self, X, y):
self.X_ = list(X)
self.y_ = list(y)
return self
def predict(self, X):
predictions = []
for x in X:
pairs = sorted(zip(self.X_, self.y_), key=lambda pair: abs(pair[0] - x))
nearest = pairs[:self.k]
predictions.append(round(sum(price for age, price in nearest) / self.k, 2))
return predictions
vars of a new KNNRegressor with k equal to 2 shows only k:
vars(KNNRegressor(k=2))
{'k': 2}
What fit and predict do
Its fit stores copies of the ages and the prices, as X_ and y_, and returns self. That is everything this model learns: the data itself.
It is predict that does the work. For each age it is given, it sorts the stored cars by how far their age is from that age, using sorted with a key, the way you did with lambdas. Then it keeps the first k of them, and averages their prices.
One prediction by hand
Work out one prediction by hand. For a three-year-old car, with k equal to 2, which two cars are nearest in age? Look at the six ages. Think it through before reading on.
I have a guess. Show meHide the answer
The two-year-old car and the four-year-old car, both one year away. Here is each car's distance from 3:
[abs(age - 3) for age in ages]
[2, 1, 1, 3, 5, 6]
Sorting the cars by their distance from 3 puts those two first, and then the one-year-old car, two years away:
sorted(zip(ages, prices), key=lambda pair: abs(pair[0] - 3))
[(2, 19.6), (4, 15.2), (1, 23.2), (6, 11.2), (8, 7.6), (9, 7.2)]
So the prediction is 19.6 plus 15.2, divided by 2:
round((19.6 + 15.2) / 2, 2)
17.4
The model agrees
And that is exactly what the model gives. KNNRegressor with k equal to 2, fitted on the cars, predicts 17.4 for age 3, 13.2 for age 5, and 9.4 for age 7:
knn = KNNRegressor(k=2).fit(ages, prices)
knn.predict([3, 5, 7])
[17.4, 13.2, 9.4]
vars of the fitted model now shows k, and also X_ and y_, holding the six ages and the six prices:
vars(knn)
{'k': 2, 'X_': [1, 2, 4, 6, 8, 9], 'y_': [23.2, 19.6, 15.2, 11.2, 7.6, 7.2]}
05Changing the hyperparameter k
Changing k changes the model. With k equal to 3, the predictions at ages 3 and 7 are 19.33 and 8.67, because each one now averages three prices:
KNNRegressor(k=3).fit(ages, prices).predict([3, 7])
[19.33, 8.67]
k equal to six
So what happens when k is 6, and there are only six cars? What do you think the model predicts at ages 3, 5 and 7? Think it through before reading on.
I have a guess. Show meHide the answer
KNNRegressor(k=6).fit(ages, prices).predict([3, 5, 7])
[14.0, 14.0, 14.0]
It predicts 14.0, all three times. With k equal to 6, every car is a neighbor of every age, so every prediction is the average of all six prices. So the mean predictor you started with is really k nearest neighbors with the largest k there can be.
A straight line
Machine learning also fits a straight line to these same cars: 24 minus 2 times the age. At ages 3, 5 and 7, that gives 18, 14 and 10:
[24 - 2 * age for age in [3, 5, 7]]
[18, 14, 10]
That is the next kind of model you will meet, and it is used in exactly the same three steps.
06Comparing with scikit-learn's KNeighborsRegressor
So how does your model compare with scikit-learn's own? Add scikit-learn to the project the way you added packages when you set up an environment:
uv add scikit-learn
scikit-learn's k nearest neighbors model for prices is called KNeighborsRegressor, and its k is a parameter called n_neighbors. Create one with n_neighbors equal to 2, fit it, and predict at ages 3, 5 and 7, rounding each prediction to two decimal places the same way KNNRegressor does:
from sklearn.neighbors import KNeighborsRegressor
X = [[age] for age in ages]
sk = KNeighborsRegressor(n_neighbors=2).fit(X, prices)
[round(float(p), 2) for p in sk.predict([[3], [5], [7]])]
[17.4, 13.2, 9.4]
You get 17.4, 13.2 and 9.4, the same numbers as yours. And with n_neighbors equal to 3, at ages 3 and 7, it gives 19.33 and 8.67, matching yours again:
sk3 = KNeighborsRegressor(n_neighbors=3).fit(X, prices)
[round(float(p), 2) for p in sk3.predict([[3], [7]])]
[19.33, 8.67]
X is a list of rows
There is one difference in how you call it. scikit-learn's X is a list of rows, with one list per car, so the ages are written as [1], [2], and so on:
X
[[1], [2], [4], [6], [8], [9]]
That is because a model can use several features for each car, like its age and its mileage, and each row holds all of them. A feature is one input value the model uses for each example.
Learned attributes in scikit-learn
Its learned attributes end in an underscore too. Here are the attributes fit added whose names end in an underscore:
sorted(name for name in vars(sk) if name.endswith("_") and not name.startswith("_"))
['effective_metric_',
'effective_metric_params_',
'n_features_in_',
'n_samples_fit_']
They include n_features_in_ and n_samples_fit_. Here, one feature per car, and six cars:
sk.n_features_in_, sk.n_samples_fit_
(1, 6)
DummyRegressor and NotFittedError
scikit-learn even has its own mean predictor, called DummyRegressor, and it predicts 14 for a three-year-old car:
from sklearn.dummy import DummyRegressor
DummyRegressor().fit(X, prices).predict([[3]])
array([14.])
scikit-learn returns its predictions as a NumPy array rather than a list, which is why the result shows array([14.]).
And here is the clearer message from earlier. Calling predict before fit raises a NotFittedError:
KNeighborsRegressor(n_neighbors=2).predict([[3]])
NotFittedError: This KNeighborsRegressor instance is not fitted yet. Call 'fit' with appropriate arguments before using this estimator.
07Common estimator errors: aliased input data and refitting
There is one mistake in fit that is easy to make, and KNNRegressor avoids it on purpose. Its fit stores list(X), which is a copy.
Here is a version that stores X itself instead. It inherits everything else from KNNRegressor and replaces only fit:
class KNNReference(KNNRegressor):
def fit(self, X, y):
self.X_ = X
self.y_ = y
return self
Storing the list itself
Fit both versions on the same two lists. Then append one more car to those lists, a three-year-old car at 40 thousand dollars. What do you think each model predicts now, for age 3? Think about what each version stored. Think it through before reading on.
I have a guess. Show meHide the answer
our_ages = list(ages)
our_prices = list(prices)
by_reference = KNNReference(k=2).fit(our_ages, our_prices)
by_copy = KNNRegressor(k=2).fit(our_ages, our_prices)
our_ages.append(3)
our_prices.append(40.0)
by_reference.predict([3]), by_copy.predict([3])
([29.8], [17.4])
The one that stored the lists themselves now predicts 29.8, and the one that stored copies still predicts 17.4.
Storing X itself bound a second name to the caller's list, the same thing backup = cart did when you worked with lists. So when the caller appended the new car, the model's data changed too, and the new car is now one of its nearest neighbors. Its two nearest cars for age 3 are now the new car at 40.0 and the two-year-old car at 19.6.
That is why fit should store its own copy of anything it keeps.
Refitting replaces
Two more things to keep in mind. First, calling fit again replaces what the model learned, and does not add to it. So a model fitted on two cars, and then fitted on the six, has only the six ages, and predicts 17.4:
knn = KNNRegressor(k=2).fit([1, 2], [10.0, 20.0])
knn.fit(ages, prices)
knn.X_, knn.predict([3])
([1, 2, 4, 6, 8, 9], [17.4])
And second, anything learned belongs in fit, not in __init__, because __init__ runs before fit has been called with any data.
08Recap and challenge: the estimator pattern
So, back to the question. What does a class need so it can be used in scikit-learn's three steps? You wrote two estimators to find out.
You create an estimator with its hyperparameters, which __init__ stores, and which are never learned. fit learns from the examples, stores what it learned in attributes that end in an underscore, and returns the estimator itself, which is why you can chain fit and predict. And predict uses what fit stored to give an answer for each new input.
The mean predictor is the simplest estimator there is, and k nearest neighbors averages the prices of the k nearest cars, which gives the mean predictor again when k is every car. Your numbers matched scikit-learn's on these examples because both models average the targets of the nearest neighbors, and both use the same three calling steps. And whatever fit keeps, it should store as its own copy.
Your challenge
Here is a final challenge for this module. Write a MedianPredictor, with the same methods as the MeanPredictor.
- Its
fitshould store the median price asmedian_, and returnself. - Its
predictshould give that median for every input. - On the six cars,
median_should be 13.2, halfway between the two middle prices, 15.2 and 11.2.
The end of the module
That is the end of this module. You started with a class that holds a review's data and the methods that use it, and saw that every value in Python is an object of some class. Then a dataclass generated the lines you used to write by hand, from a list of fields. And now you have written two models with the same methods as every scikit-learn model, so every scikit-learn model you use from here on will work the way these two do.
But look at what your k nearest neighbors model does when it predicts. For every new car, it computes the distance to every stored car, and then sorts them all. With six cars, that is quick. But with six million cars, the time that takes matters a lot. Working out how that time grows, before you run anything, is what the next module explores.
Key terms: The Estimator Pattern
The words this lesson introduces, each in one line. The module’s glossary collects them all, and the AI glossary has every term across the courses.
- estimator
- An object that learns from data in
fitand answers inpredict. Every scikit-learn model is one. - fit
- The method that learns from examples. It stores what it learned in attributes ending in
_, and returnsself. - hyperparameter
- A setting of a model chosen before training, not learned in
fit:kinKNNRegressor(k=2). - predict
- The method that gives answers for new inputs, using what
fitlearned.
Quiz: The Estimator Pattern
Your first pick on each question is the one that counts. Getting one wrong here is how the lesson sticks.