Lecture slides

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

  1. Would more flexible methods rank the cases better than logistic regression?
  2. 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:

  1. “Is support \(\le\) 2.75?” puts seven, all “No”, on one side.
  2. “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.
tree_recipe = ColumnTransformer([
    ("num", SimpleImputer(strategy="median"), num_cols),
    ("cat", OneHotEncoder(drop="first", sparse_output=False, handle_unknown="ignore"), cat_cols)
])

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.
svm_linear = SVC(kernel="linear", probability=True, random_state=2026)
svm_rbf    = SVC(kernel="rbf", probability=True, random_state=2026)

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 folds

Same 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

cm = confusion_matrix(y_test, y_pred_te, labels=[1, 0])
# [[8, 15], [3, 124]]
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 conversation

Costs 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

  1. A depth-2 tree, explained to a counsellor.
  2. Tune the forest’s max_features over 2, 5, and 10.
  3. k-NN with 81, 161, and 301 neighbours: what happens?
  4. The F1 score of the tiny confusion matrix, by hand.

Practical exercises 5–7: decisions

  1. Only 10% of students can be contacted: which threshold?
  2. Class weighting instead of unweighted: sensitivity and AUC?
  3. 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?

Breiman, Leo. 2001. “Random Forests.” Machine Learning 45 (1): 5–32. https://doi.org/10.1023/A:1010933404324.