
Classification Models
Classification Models
From predicted probabilities to decisions about people
Chapter 12
Polla Fattah
By the end of today you can
- explain how trees, random forests, k-nearest neighbours, and SVMs classify;
- fit each with scikit-learn, and compare them fairly;
- read a confusion matrix;
- calculate sensitivity, specificity, precision, and F1;
- choose a threshold from the costs of the two errors;
- handle an imbalanced outcome, and say what resampling changes.
Two questions about a classifier
- Would more flexible methods rank the cases better than logistic regression?
- What happens when predictions become decisions?
Every decision rule flags some cases wrongly and misses others. The balance depends on what each error costs, and to whom.
Same data, same split, same folds
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, stratify=y, random_state=2026
)
dropout_recipe = ColumnTransformer([
("num", Pipeline([("imp", SimpleImputer(strategy="median")), ("scaler", StandardScaler())]), num_cols),
("cat", OneHotEncoder(drop="first", sparse_output=False, handle_unknown="ignore"), cat_cols)
])
dropout_folds = StratifiedKFold(n_splits=10, shuffle=True, random_state=2026)Exactly as in Chapter 11, so every model is trained and tested on the same students.
A decision tree asks questions
tiny_tree = DecisionTreeClassifier(min_samples_split=2, criterion="gini", random_state=2026)
tiny_tree.fit(tiny[["stress", "support"]], (tiny["dropout"] == "Yes").astype(int))Twelve imaginary students, four considering dropout:
- “Is support \(\le\) 2.75?” puts seven, all “No”, on one side.
- “Is stress > 3.1?” separates the rest perfectly.
How a tree chooses its questions
| Term | Meaning |
|---|---|
| node | a question and the students who reached it |
| leaf | a final node, with a predicted class |
| Gini impurity | 0 when a group holds only one class |
The tree tries every predictor and cut-off, and picks the one that makes the child groups most pure.
Why trees are attractive
- easy to explain;
- no dummy variables or normalisation needed;
- interactions captured automatically: stress matters only for low-support students.
A depth-3 tree for dropout
The top split is on support. Students meeting each condition branch to the left.
A single tree is unstable
A slightly different sample of students can produce a completely different tree.
A deep tree overfits (Chapter 11).
The next method turns this weakness into a strength.
Random forests
- grow hundreds of trees;
- each on a bootstrap sample of the students;
- each split chooses from a random subset of predictors (
max_features); - let the trees vote: \(P(\text{Yes})\) is the share voting “Yes”.
Each tree overfits in its own way; many errors cancel out. An ensemble of trees (Breiman 2001).
Fitting a forest
forest_pipe = Pipeline([
("prep", tree_recipe),
("model", RandomForestClassifier(n_estimators=500, random_state=2026))
])
forest_fit = forest_pipe.fit(X_train, y_train)500 trees cannot be drawn: a black box. Permutation importance opens it a little.
What the forest relies on

Shuffle one predictor, measure the drop in accuracy. Importance says what the model uses, not what causes dropout.
k-nearest neighbours
tiny["distance"] = np.sqrt((tiny["stress"] - 3.5)**2 + (tiny["support"] - 3.0)**2)
tiny.sort_values("distance").head(3)A new student with stress 3.5 and support 3.0.
The three nearest students vote: two considered dropping out, so \(P(\text{Yes}) = 2/3\).
The three nearest neighbours

Distances need predictors on one scale: the pipeline normalises them with StandardScaler.
Tuning k
knn_grid = GridSearchCV(
Pipeline([("prep", dropout_recipe), ("knn", KNeighborsClassifier())]),
param_grid={"knn__n_neighbors": [1, 5, 11, 21, 41, 81]},
cv=dropout_folds, scoring="roc_auc"
)| k | 1 | 5 | 11 | 21 | 41 | 81 |
|---|---|---|---|---|---|---|
| AUC | 0.58 | 0.70 | 0.73 | 0.75 | 0.80 | 0.82 |
The smoother k-NN becomes, the better: the pattern in this data is smooth.
Support vector machines
- separate the classes with the widest empty “street”, the margin;
- only the students closest to the boundary, the support vectors, place it;
- overlapping classes: some students may cross, at a cost (\(C\));
- a kernel lets the boundary bend; the RBF kernel is popular.
Four ways to draw a boundary

Smooth gradient, rectangular blocks, patches, rounded regions. All agree: highest risk at high stress and low support.
Comparing six models fairly
candidate_models = {
"logistic": Pipeline([("prep", dropout_recipe), ("model", LogisticRegression())]),
"svm_linear": Pipeline([("prep", dropout_recipe), ("model", SVC(kernel="linear", probability=True))]),
"forest": Pipeline([("prep", tree_recipe), ("model", RandomForestClassifier(n_estimators=500))]),
"svm_rbf": Pipeline([("prep", dropout_recipe), ("model", SVC(kernel="rbf", probability=True))]),
"knn": Pipeline([("prep", dropout_recipe), ("model", KNeighborsClassifier(n_neighbors=41))]),
"tree": Pipeline([("prep", tree_recipe), ("model", DecisionTreeClassifier(max_depth=4))]),
}
# Cross-validate each candidate pipeline on the identical 10 foldsSame folds for every model. Trees use the simple recipe, the others the normalised one.
The simple model wins again
| Model | Cross-validated AUC | Std. error |
|---|---|---|
| logistic regression | 0.81 | 0.02 |
| random forest | 0.80 | 0.02 |
| linear SVM | 0.79 | 0.02 |
| k-NN (k = 41) | 0.80 | 0.02 |
| decision tree | 0.74 | 0.02 |
| RBF SVM | 0.71 | 0.03 |
Risk changes smoothly, with no thresholds for a tree to find. Logistic regression is kept, and explained in one sentence.
From ranking to deciding
The AUC measures how well a model ranks students.
The counselling service needs a decision: contact this student or not.
A student is flagged when \(P(\text{Yes})\) is above a threshold, 0.5 by default. The confusion matrix counts how the decisions turn out.
The four outcomes
| Truly at risk | Not at risk | Total | |
|---|---|---|---|
| flagged | 8 true positives | 4 false positives | 12 |
| not flagged | 2 false negatives | 86 true negatives | 88 |
| total | 10 | 90 | 100 |
A screening questionnaire given to 100 students, 10 truly at risk.
Measures from the four counts
| Measure | Question | Here |
|---|---|---|
| sensitivity (recall) | how many at risk are found? | 8/10 = 80% |
| specificity | how many not at risk are left alone? | 86/90 = 96% |
| precision | how often is a contact justified? | 8/12 = 67% |
| F1 | precision and recall together | 0.73 |
| accuracy | share classified correctly | 94% |
The study’s model at 0.5
| Accuracy | Sensitivity | Specificity | Precision |
|---|---|---|---|
| 88% | 35% | 98% | 73% |
Of 23 at-risk test students, only np.int64(8) are flagged. The model ranks well; the default 0.5 threshold is the problem.
0.5 is a default, not a law
| Lower threshold | Higher threshold |
|---|---|
| more students flagged | fewer students flagged |
| higher recall | higher precision |
| more false alarms | more students missed |
Here a flag means a conversation: cheap and harmless. Missing a student who leaves is costly. The threshold belongs well below 0.5.
Choose the threshold without the test set
cv_predictions = cross_val_predict(
logistic_pipe, X_train, y_train, cv=dropout_folds, method="predict_proba"
)[:, 1]Cross-validated predictions for every training student, each from a model that did not see them.
The ROC curve

Every threshold at once. The AUC is the area under the curve.
The trade-off in a table
| threshold | flagged | recall | precision | specificity |
|------------:|:----------|:---------|:------------|:--------------|
| 0.5 | 8% | 28% | 50% | 95% |
| 0.4 | 12% | 36% | 43% | 92% |
| 0.3 | 19% | 52% | 40% | 86% |
| 0.2 | 26% | 63% | 36% | 80% |
| 0.15 | 32% | 69% | 32% | 74% |
| 0.1 | 42% | 82% | 29% | 65% |
A lower threshold flags more students, finding more at risk at the cost of lower precision.
Costs of the two errors
cost_miss = 10 # a student at risk who is not contacted
cost_false_alarm = 1 # an unnecessary conversationCosts are judgements about consequences, not statistics. Make them explicit.
The cheapest threshold

Lowest at about np.float64(0.04), flagging 60% of students. Decision theory: \(1 / (1 + 10) \approx 0.09\). Both say: far below 0.5.
The threshold is a decision with an ethical side
- set costs with the people who will use the model;
- state them in the thesis;
- examine the effects on different groups;
- respect practical limits: counsellors cannot see everyone.
The counselling service settles on 0.2: flags about 26%, finds about 63% of those at risk, precision about 36%.
Applying 0.2 to the test students
flags_te = (y_prob_te >= 0.2).astype(int)
cm_02 = confusion_matrix(y_test, flags_te, labels=[1, 0])
# [[17, 6], [29, 98]]The model now finds np.int64(17) of 23 at-risk students instead of np.int64(8), flagging 46 in all.
Accuracy fell to 77%. It does not matter: accuracy was never the goal.
Report the threshold
Sensitivity, specificity, precision, and F1 all depend on the threshold.
Report which threshold was used, and why.
The AUC does not depend on it, which is why it is used to compare models.
Imbalanced outcomes
| Remedy | How |
|---|---|
| move the threshold | flag at a lower probability |
| upsampling | repeat rare-class students |
| downsampling | drop common-class students |
| SMOTE | create artificial rare-class students |
| class weights | make rare-class mistakes count more |
Scikit-learn models support class weights directly via class_weight='balanced'.
Class weighting / Upsampling
balanced_pipe = Pipeline([
("prep", dropout_recipe),
("model", LogisticRegression(class_weight="balanced", random_state=2026))
])| Sensitivity at 0.5 | AUC | |
|---|---|---|
| balanced weighting | 71% | 0.80 |
Weighting applies to training loss. It pushed probabilities up, like lowering the threshold, but did not improve ranking (AUC 0.80).
After weighting, an output score of 0.6 no longer corresponds to a 60% empirical rate.
Does the model work equally well for everyone?
| Test students at risk | Found at threshold 0.2 |
|---|---|
| women: 12 | 9 |
| men: 11 | 8 |
Similar shares, but too few students in the test set to detect subtle differences.
Check sensitivity and precision for every demographic group that matters, on as much data as possible.
In your field: ecology
penguins = dt.load_penguins().dropna(subset=["bill_length_mm", "bill_depth_mm", "flipper_length_mm", "body_mass_g"])
p_rf = RandomForestClassifier(n_estimators=500, random_state=1)
p_rf.fit(p_X_train, p_y_train)
pd.crosstab(p_y_test, p_rf.predict(p_X_test))Three penguin species from four morphological measurements: near 98% test accuracy.
Well-separated non-linear classes suit flexible tree ensembles.
Practical lab: the Chapter 12 playground
Work through the playground exercises in your browser, with hints and solutions.
The browser version runs Pyodide in WebAssembly; the download project runs in your local editor.
Practical exercises 1–4: models
- A depth-2 tree, explained to a counsellor.
- Tune the forest’s
max_featuresover 2, 5, and 10. - k-NN with 81, 161, and 301 neighbours: what happens?
- The F1 score of the tiny confusion matrix, by hand.
Practical exercises 5–7: decisions
- Only 10% of students can be contacted: which threshold?
- Class weighting instead of unweighted: sensitivity and AUC?
- A missed student costs 3, not 10: the new cheapest threshold?
Try this yourself
For a classifier in your own field:
- name what a false positive and a false negative mean in practice;
- estimate the cost of each;
- calculate the decision-theory threshold;
- say who should agree to those costs before the model is used.
Troubleshooting guide (Part 1)
| Symptom | Likely cause |
|---|---|
| a tree changes completely between samples | trees are unstable; use a forest |
| k-NN dominated by one variable | predictors not normalised |
| best k at the edge of the grid | extend the grid |
| a flexible model loses | the pattern is smooth |
Troubleshooting guide (Part 2)
| Symptom | Likely cause |
|---|---|
| high accuracy, few cases found | threshold too high for a rare outcome |
| threshold chosen on the test set | the test is spent; use cross-validation |
| sensitivity reported without a threshold | the number cannot be interpreted |
| probabilities too high after upsampling | resampling distorts probabilities |
Completion checklist
Misconceptions to leave behind (Part 1)
| Misconception | Better mental model |
|---|---|
| flexible models always predict better | only when the pattern needs flexibility |
| importance shows cause | importance shows what the model uses |
Misconceptions to leave behind (Part 2)
| Misconception | Better mental model |
|---|---|
| 0.5 is the right threshold | the threshold comes from costs |
| resampling improves a model | it usually shifts probabilities, not ranking |
The chapter in one sentence
Compare models by how well they rank cases, then choose the threshold from what each kind of error costs, and report it.
Next: Chapter 13
The next chapter predicts a number:
- measuring prediction error;
- ordinary regression with many predictors;
- regularised regression: ridge, lasso, elastic net;
- boosting;
- comparing models and the final test.
Questions
In your field, which is worse: a false alarm or a missed case?
Who decides?