In the previous lesson you trained your first model, @name, and judged it with a single line:
model.score(X_test, y_test). Imagine it printed 95%. That sounds like a number worth writing into the expedition log - right up until you count how many camera-trap frames actually show a poacher. If a poacher appears in five percent of the footage, then a model that detects nobody and answers "all clear" to every single frame also scores 95%. And it is completely useless, because it never caught a single poacher.That is the real problem of supervised learning: not "how do I train a model", but "how do I know whether it learned anything at all". This lesson answers two questions. First: what kind of answer you are even allowed to demand from a model. Second: how to measure whether those answers are any good, and what on earth to compare them against.
Supervised learning you have already met from a bird's eye view. The model receives pairs: a set of measurements together with the correct answer for those measurements. The word "supervised" describes no supervision of the algorithm whatsoever - it means that somebody, some naturalist with a notebook, labelled every specimen beforehand. Without those labels there is no supervised learning, because the model has nothing to hold itself against.
Two names have taken hold, and we will use them until the end of the module.
X is the features, a table of measurements: one row per animal, one column per measured parameter. y is the label, the column of correct answers - exactly as many values as there are rows in X. Capital X and lowercase y come from a mathematical convention that scikit-learn adopted, and you will meet it in every example on the internet.The whole distinction this lesson turns on sits inside the
y column. Below are the same four animals, described by the same two measurements, but with two different answers: once a species name, once the weight predicted for next year.1# Same measurements, two different answers
2X = [[190, 58], [55, 110], [175, 60], [48, 115]]
3
4y_species = ["lion", "cheetah", "lion", "cheetah"] # answer: a name
5y_weight_next_year = [195, 57, 178, 50] # answer: a numberNotice that
X did not change - it is the same field notebook in both cases. Neither the number of rows nor the way they are written changed either: a list of lists, where every inner list is one animal. The only thing that changed is the type of content inside y. In the first case the answer is one of a few names known in advance, in the second - any number at all. That single detail decides everything that follows: the name of the task, the model you will reach for, and the metrics you will judge it by.When
y holds names from a closed list, the task is called classification, and the possible answers are classes. "Lion or cheetah" is classification. "All clear or poacher" is classification. "Endangered, vulnerable or least concern" is too, just with three classes. Models do not understand text, so we write classes as numbers: 0, 1, 2. Those are identifiers only, not quantities - class number 2 is not "twice as big" as class number 1.The first classifier we will use is logistic regression, in code
LogisticRegression. The name is misleading and it is worth knowing that immediately: despite the word "regression" this is a classifier, because it returns a class and not an arbitrary number. The model hands you two methods. fit(X, y) teaches it on measurement-answer pairs, and predict(X) returns predicted classes for new measurements. In the next lesson you will see that every scikit-learn model speaks with these same verbs - which is exactly why it pays to meet them now, on the simplest dataset imaginable.So let us gather eight animals: four lions and four cheetahs, each described by weight in kilograms and top speed in kilometres per hour.
1from sklearn.linear_model import LogisticRegression
2
3# weight in kg, speed in km/h
4X_train = [
5 [190, 58], [175, 60], [205, 55], [160, 62],
6 [55, 110], [48, 115], [62, 105], [50, 112],
7]
8y_train = [0, 0, 0, 0, 1, 1, 1, 1] # 0 = lion, 1 = cheetah
9
10model = LogisticRegression()
11model.fit(X_train, y_train)
12
13print(model.predict([[52, 108]])) # [1]The model answered
[1], meaning "cheetah" - and rightly so, because 52 kilograms at 108 km/h is a cheetah's silhouette. Notice two things that did not change. First, fit did not hand back a new model for you to assign to a variable: it modified the model object in place, which is why we call it as a statement of its own. Second, X_train and y_train were left untouched - the model kept the coefficients it computed inside itself, and your data sits exactly where it sat. Notice as well that predict takes a list of lists even for a single animal, and gives back an array of answers rather than one value. It always predicts for many specimens at once, even when that "many" happens to be one.The class name alone is often not enough. A tracker would rather know whether the model is confident or merely guessing. That is what
predict_proba is for: instead of a class it returns probabilities - one number per possible class.1proba = model.predict_proba([[52, 108]])
2
3print(proba.shape) # (1, 2)
4print(round(float(proba.sum()), 2)) # 1.0The result has shape
(1, 2): one row, because we asked about one animal, and two columns, because there are two classes. Columns follow increasing class numbers, so the first one is the confidence for lion and the second one for cheetah. They add up to one, and that is precisely what the second print checked. For our cheetah the model is almost completely certain, but I am not declaring the exact number here, because it depends on the library version - run the code and see for yourself. The practical use is this: when the highest probability fails to pass, say, 0.7, it is better to flag the frame for a human to look at than to pretend the model knows. Keep in mind, though, that this is the confidence of the model, not the truth about the world - a model can be confident and wrong at the same time.When
y holds numbers that cannot be written out as a closed list, the task is called regression. Herd size, the weight of an animal, yearly rainfall in the reserve, time until the next migration - all of that is regression. The key word is continuous: the answer may land anywhere on the scale, not only on a few points listed in advance. A weight of 187.4 kg is just as sensible an answer as 187.5 kg, and infinitely many others fit between them. In classification nothing in between exists - there is no animal that is half lion and half cheetah.The simplest regression model is
LinearRegression, linear regression. It looks for a straight relationship of the "the more of this, the more of that" kind and writes it down as a line. You operate it with the same verbs as the classifier: fit teaches, predict forecasts. Let us try it on a question that comes up constantly on Safari: how many animals will a reserve of a given size support?1from sklearn.linear_model import LinearRegression
2
3X = [[100], [200], [300], [400]] # reserve area in km2
4y = [150, 280, 420, 550] # counted herd
5
6model = LinearRegression()
7model.fit(X, y)
8
9prediction = model.predict([[250]])
10print(f"Predicted herd size: {prediction[0]:.0f}") # 350The model answered 350, even though no such value appeared in the data - it read it off the line fitted to four observations. That is the whole essence of regression: the answer does not have to come from the training set. Notice two details of the notation. Area is a single measurement, yet every reserve is still its own list -
[[100], [200]] and not [100, 200] - because X is always a table with rows, even with a single column. And once again predict returned an array, so we reach for prediction[0]. The :.0f format rounds the result to whole individuals, because half an animal does not walk the savannah.Before we move on, let us knock down three ideas that circulate around the classification-regression pair, sound reasonable, and are simply untrue.
The first: "classification is faster than regression". It is not - and the reverse is not true either. Computation time depends on the model you chose and the size of the data, not on whether the answer is a name or a number. The linear regression on four reserves that you just ran finished instantly, while a classifier on a million photographs can train for hours. The split into classification and regression is about the kind of answer, not about performance.
The second: "regression requires more data". Also no. How much data you need follows from the number of features and the complexity of the phenomenon, not from the type of task. Our regression coped with four rows because the relationship was simple and linear, while recognising a species from photographs takes thousands of examples even though it is classification.
The third, and the most dangerous: "there is no difference". The difference is fundamental, because it seeps into everything that comes after. Different models, different metrics, and even the question "is the answer correct" means something else. In classification an answer either hits or misses, with no shades in between. In regression you will practically never hit exactly - what counts is by how much you were off. Memorise this sentence, because you are about to see it inside the metrics: classification predicts categories, regression predicts continuous values.
Let us go back to the camera trap from the start of the lesson. To judge a model you need two sequences of the same length:
y_true, what was really on the frames, and y_pred, what the model said. Those names are an unwritten standard in scikit-learn and we will stick to them. Every metric works the same way: it takes those two sequences and returns a single number.The simplest of them is accuracy. It measures exactly one thing: the percentage of correct predictions, the number of hits divided by the number of all attempts. It is computed by the
accuracy_score function, which you bring in from the sklearn.metrics module - that is where every metric of this library lives.1from sklearn.metrics import accuracy_score
2
3# 12 camera-trap frames: 0 = all clear, 1 = poacher
4y_true = [0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0]
5y_pred = [0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0]
6
7print(f"Accuracy: {accuracy_score(y_true, y_pred):.2%}") # 75.00%Nine of the twelve frames were described by the model the same way the naturalist described them, so accuracy comes out at 75%. The
:.2% format multiplies the value by a hundred and glues on a percent sign, because the function itself returns the fraction 0.75 - that is formatting, not a different metric. And straight away an important warning: accuracy measures nothing else. It says nothing about model training speed, because time is measured with a stopwatch, not with a metric. It does not tell you how much memory the model used. Nor is it the mean squared error - that one, as you will see in a moment, belongs to the family of regression metrics and measures distance from the truth rather than a count of hits. Accuracy is one specific thing: the percentage of correct predictions.Those 75% look respectable, but recall the hypothetical model from the start of the lesson, the one that always said "all clear". On our twelve frames the poacher showed up four times, so that lazy model would hit eight out of twelve, that is 66.67%. Our model is better by not quite nine points - and it is the one meant to guard the reserve. Accuracy will not reveal this, because it blends two completely different kinds of mistake. Let us name them.
The model raised the alarm although all was quiet: that is a false alarm, a false positive. Someone will drive out to inspect an empty square of savannah. The model stayed silent although a poacher was there: that is a miss, a false negative, and it costs far more. A justified alarm is a true positive, and correct silence is a true negative.
Two metrics ask two different questions about those mistakes. Precision asks: how many of the alarms the model raised were real? That is a verdict on how trustworthy an alarm is. Recall asks: how many of the real intrusions did the model detect at all? That is a verdict on how watertight the system is. They are computed by
precision_score and recall_score from the same sklearn.metrics module.1from sklearn.metrics import precision_score, recall_score
2
3print(f"Precision: {precision_score(y_true, y_pred):.2%}") # 66.67%
4print(f"Recall: {recall_score(y_true, y_pred):.2%}") # 50.00%Let us count it by hand, because here it is worth seeing where the numbers come from. The model raised the alarm three times, and twice it was right: 2 divided by 3 gives 66.67% precision. There were four real intrusions and the model detected two: 2 over 4, that is 50% recall. Half the poachers walked past unnoticed. The most important part is what did not change: the predictions are exactly the same as for accuracy, and so is the data. Only the question we asked changed - and that is why one set of results produced three different numbers. My advice, @name: for every classifier compute precision and recall alongside accuracy. On a rare phenomenon, accuracy alone can look magnificent and mean nothing.
Precision and recall pull in opposite directions. A model that raises the alarm at every rustle of grass will have high recall and dreadful precision. A model that only alarms when it is a hundred percent sure - the other way round. To compare models with a single number, the two get combined into the F1 metric, the harmonic mean of both.
1from sklearn.metrics import f1_score
2
3print(f"F1: {f1_score(y_true, y_pred):.2%}") # 57.14%Why harmonic and not ordinary? The ordinary mean of 66.67% and 50% is 58.33%, while F1 came out at 57.14% - a touch lower, closer to the weaker of the two ingredients. That is deliberate. The harmonic mean punishes imbalance: a model with 100% precision and 1% recall gets an F1 near zero, although the ordinary mean would award it a respectable 50%. Thanks to that, a high F1 can only be reached when both ingredients are decent at the same time.
Every metric so far squeezed the whole result into a single number, and in passing lost the information about which kind of mistake was more common. The confusion matrix squeezes nothing: it shows all four cases at once. Rows are the truth, columns are the model's prediction, and classes are laid out in increasing order, so with labels 0 and 1 it goes "all clear" first, then "poacher". You bring the
confusion_matrix function in - like the previous ones - from the sklearn.metrics module.1from sklearn.metrics import confusion_matrix
2
3print(confusion_matrix(y_true, y_pred))
4# [[7 1]
5# [2 2]]Read this little table row by row. The top row is the frames where all was genuinely quiet: 7 times the model also said "all clear" (correct silence), 1 time it raised a false alarm. The bottom row is the frames with a poacher: 2 times the model missed him, 2 times it detected him. The diagonal from the top left to the bottom right holds the hits, everything off it holds the mistakes. From these four numbers you can rebuild every earlier metric: accuracy is (7 plus 2) over 12, precision is 2 over (2 plus 1), recall is 2 over (2 plus 2). That is why the confusion matrix is worth looking at first - you can derive the metrics from it, but you cannot rebuild it from the metrics.
When there are more than two classes, precision and recall have to be computed separately for each of them, and that gets tedious. Scikit-learn has a ready shortcut for this:
classification_report prints the full set of metrics for all classes in one table. The target_names parameter lets you swap class numbers for readable names.1from sklearn.metrics import classification_report
2
3print(classification_report(y_true, y_pred, target_names=["all clear", "poacher"]))
4# table: precision, recall, f1-score and support for every classIn the printout you will see one row per class, and in the columns the familiar precision, recall and f1-score. One new column joins them: support, the number of real occurrences of that class in
y_true. That is the column that exposes imbalance - here eight quiet frames and only four with a poacher. I am not quoting the exact look of the table, because column widths shift between library versions. What matters more is what you read out of it: the rare class almost always scores worse than the common one, and it is usually the rare one you actually care about.In classification an answer either hit or missed. In regression that does not work: a model that predicted 349 individuals instead of 350 is not "incorrect", it is close. That is why every regression metric measures the distance between predictions and the truth, not a count of hits. We keep the names
y_true and y_pred, but they now hide completely different data: the real and the predicted herd sizes in four reserves.The first metric is MSE, mean squared error. It takes the difference for each reserve, squares it and averages the results. The square does two things: it removes the sign, so that an error downwards does not cancel an error upwards, and it magnifies large mistakes. The trouble is that the result is expressed in squared individuals, which cannot be interpreted sensibly. That is why people usually also compute RMSE, root mean squared error - the square root of MSE, which returns to the original unit. We will take the root with the
np.sqrt function from NumPy, familiar to you from module eight.1from sklearn.metrics import mean_squared_error
2import numpy as np
3
4y_true = [150, 280, 420, 550] # real herd size
5y_pred = [140, 300, 400, 560] # model predictions
6
7mse = mean_squared_error(y_true, y_pred)
8print(f"MSE: {mse:.2f}") # 250.00
9print(f"RMSE: {np.sqrt(mse):.2f}") # 15.81MSE came out at 250, but that number tells you nothing, because it means "250 squared individuals". Only RMSE, 15.81, is a sentence in plain English: the model's typical mistake is about sixteen animals. Notice the order of the arguments - the truth first, the predictions second. With MSE swapping them happens not to change the result, because the difference gets squared, but in other metrics it does change it, so it is better to build the habit right away:
y_true always goes first.Since the square magnifies large errors, sometimes you want a metric that does not do that. This is MAE, mean absolute error: it takes the absolute value of each difference and averages them, with no squaring.
1from sklearn.metrics import mean_absolute_error
2
3print(f"MAE: {mean_absolute_error(y_true, y_pred):.2f}") # 15.00MAE is 15, which is less than the RMSE of 15.81. That gap is no accident and it is worth understanding where it comes from. Our four mistakes are 10, 20, 20 and 10 animals - MAE simply averages them and gets 15. RMSE, thanks to the squares, gives more weight to those twenties, so it lands higher. The rule is simple: RMSE is always greater than or equal to MAE, and the further apart they drift, the more uneven your errors are. When the gap is large, go looking for the handful of reserves on which the model loses the plot completely.
Both of those metrics share one flaw: they are expressed in individuals, so you cannot use them to compare a herd-counting model against a rainfall-predicting one. What you need is a unitless measure, and that is R², the coefficient of determination. It is computed by the
r2_score function, and it answers the question: how much better is my model than the dumbest possible strategy, namely always printing the mean of the true values?1from sklearn.metrics import r2_score
2
3print(f"R2: {r2_score(y_true, y_pred):.4f}") # 0.9889The result of 0.9889 reads like this: the model explains just under 99% of the variation in herd sizes, so it is very close to a perfect fit. A one would mean hitting the exact count in every reserve. And here I have to correct a widespread untruth that you will find in plenty of cheat sheets: R² does not live in the range from zero to one. The upper bound is 1, but there is no lower bound at all. Zero means a model exactly as good as printing the mean, and anything below zero - a model worse than that mean. Let us look at both of those cases on our four reserves, because only then does it become clear what is going on.
1# a model that always says "the mean" (350)
2print(r2_score([150, 280, 420, 550], [350, 350, 350, 350])) # 0.0
3
4# a model that reversed the order of the reserves
5print(r2_score([150, 280, 420, 550], [550, 420, 280, 150])) # -3.0The first model does not look at the data at all and gets exactly 0.0 - that is not a coincidence, it is the definition of the metric. The second one mixed up the reserves and lands at minus three, because it is wrong far more grossly than plain guessing of the mean. If you ever see a negative R², do not go hunting for a bug in the function: your model really is worse than one constant number.
Notice what R² just did: all by itself it compared the model against the dumbest sensible strategy. Such a reference point is called a baseline. Regression has one built into the metric, classification does not - and that is how you can spend a week on a model with 95% accuracy without knowing that guessing gives 94%.
A baseline for classification has to be computed yourself, but scikit-learn ships a ready tool for it:
DummyClassifier from the sklearn.dummy module. It is a deliberately mindless model - with the most_frequent strategy it always answers with the most common class from the training set and never looks at the features at all. That is exactly why we still have to hand it some X with the right number of rows, even though its content is irrelevant here.1from sklearn.dummy import DummyClassifier
2import numpy as np
3
4y_frames = [0, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0]
5X_ignored = np.zeros((len(y_frames), 1))
6
7baseline = DummyClassifier(strategy="most_frequent")
8baseline.fit(X_ignored, y_frames)
9
10print(f"Baseline: {baseline.score(X_ignored, y_frames):.2%}") # 66.67%The baseline hits 66.67%, because eight of the twelve frames are quiet and it always says "all clear". Our model had 75%. The entire payoff of a week of work is a little over eight percentage points - and that is information accuracy on its own would never have given you. Notice that you operate
DummyClassifier exactly like a real classifier: fit, then score. None of your models or data changed - this is a separate, one-off reference measurement. My advice: compute the baseline before you train anything serious. If the baseline gives 94%, you know immediately that you should be watching recall, not accuracy.Everything we have gathered so far arranges itself into a fixed order. Learn it, because you will repeat it for every model in this module and in every project afterwards.
y_true up against y_pred and compute accuracy, precision and recall, or MSE, MAE and R² for regression.The order is not arbitrary and no step can be moved. You cannot compute metrics before making predictions, because you would have nothing to compare against the truth. Baseline comes last, because it is what gives meaning to the numbers from step three - exactly the way our model's 75% only started to mean something next to the 66.67% from
DummyClassifier.One weakness remains in this ritual, and a serious one. The test set is produced by a random split of the data. If luck decided that only easy cases landed in it, you will get an inflated verdict. If only hard ones - a deflated one. With a hundred animals and an 80 to 20 split, the whole judgement rests on twenty specimens, and a different draw can move the result by a dozen percentage points.
The cure is cross-validation. It is a model evaluation technique that splits data into multiple training and validation folds. Those parts are called folds. With five folds the data is cut into five equal pieces, and then the procedure repeats five times: each time a different fold plays the role of the validation set, while the remaining four are used for training. In the end you hold five scores instead of one, and every specimen was checked exactly once and used for learning four times.
Let us settle right away what cross-validation is not, because the name can mislead. It is not a type of neural network - you will meet networks in the PyTorch lesson and that is an entirely different thing. It is not a data cleaning method - you removed gaps and duplicates with Pandas in the previous module, and that happens long before any validation. Nor is it a sorting algorithm - cross-validation puts nothing in order, it merely splits repeatedly and scores repeatedly.
To practise it we need more than eight animals, so we will generate a synthetic dataset with the
make_classification function from the sklearn.datasets module. Watch its parameters, because mistakes are easy here: n_features is the total number of columns, n_informative says how many of them genuinely carry signal, and n_redundant how many are copies computed from others. Those three have to add up arithmetically, and on top of that the number of classes times the number of clusters per class (two by default) may not exceed two raised to the power of n_informative. With three species and the default n_informative of 2 the call ends in a ValueError, which is why we pass all of these numbers explicitly.1from sklearn.datasets import make_classification
2
3X, y = make_classification(
4 n_samples=300,
5 n_features=4,
6 n_informative=4,
7 n_redundant=0,
8 n_classes=3, # 0 = lion, 1 = elephant, 2 = cheetah
9 random_state=42,
10)
11
12print(X.shape, y.shape) # (300, 4) (300,)We got 300 animals described by four measurements and 300 labels - the shapes are confirmed by
shape, which you know from NumPy. The random_state=42 parameter fixes the random seed, so that every run generates the same data. This is not cosmetics but a condition of reproducibility: without it every run of the script would give different results and you could not tell an improvement in the model from plain luck.Now the most important function of this section.
cross_val_score takes an untrained model, the data and the number of folds in the cv parameter, and then runs the whole cycle itself: it splits, trains, scores and returns an array of results. You bring it in from the sklearn.model_selection module - the same one you took train_test_split from in the previous lesson.1from sklearn.model_selection import cross_val_score
2from sklearn.linear_model import LogisticRegression
3
4model = LogisticRegression(max_iter=1000)
5scores = cross_val_score(model, X, y, cv=5)
6
7print(len(scores)) # 5
8print(f"Mean: {scores.mean():.2%} (+/- {scores.std() * 2:.2%})")The result is five numbers, one per fold - and they really will differ from one another, often by a dozen percentage points. I am not quoting their exact values, because they depend on the library version, but the spread itself matters more here than the specific digits: it is the spread that shows how badly a single split could have fooled you. The mean of five scores is a far more honest summary than any one of them alone, and the standard deviation multiplied by two gives a convenient margin of uncertainty. Notice one more thing: we pass the model before training and never call
fit ourselves. cross_val_score trains five independent copies, and your model object stays untrained afterwards - which is why, after cross-validation, you still have to train it on the full data.The way of splitting can be controlled too, but first it is worth knowing what happens by default. When you pass a bare number to a classifier, like our
cv=5, scikit-learn picks a stratified split: it makes sure the species proportions inside every fold match those of the whole set. That is a good decision and it demands nothing from you. It does not, however, shuffle the rows before splitting, so if the notebook is ordered chronologically or by successive expeditions, neighbouring specimens will land in the same fold. To change that, you pass a splitter object in place of the number. The simplest of them is KFold, which accepts the number of folds, the shuffle switch and a random seed.1from sklearn.model_selection import KFold
2
3kfold = KFold(n_splits=5, shuffle=True, random_state=42)
4scores = cross_val_score(model, X, y, cv=kfold)
5
6print(len(scores)) # 5There are still five results, because
n_splits=5 means the same as cv=5 - only the way folds are chosen changed, now with a random shuffle. There is a trap here you must know about, though: KFold splits blindly and does not watch the class proportions, so in classification you lose the stratification you were getting for free. That is why my recommendation is this, @name: for regression reach for KFold, and for classification for StratifiedKFold from the same module - it takes exactly the same parameters and keeps the species proportions in every fold as a bonus. You have probably seen examples online with a manual loop over kfold.split(X), where you slice out the training and validation indices yourself. I advise against that form to start with, @name: it does exactly what the one line above does, and it hands you four extra opportunities to make a mistake. Pass KFold as cv and let the library keep the books - write the manual loop only when you genuinely need something unusual inside each fold.Take one sentence away from this lesson, @name: the number a model prints after training means nothing until you know what kind of answer you were looking for and how much plain guessing would have scored - a good tracker never trusts a track he has nothing to compare it against.