In 05-01 we built our first classifier (k-NN) to predict whether a Rutalia delivery will arrive late, and we left two loose ends: k-NN has serious limits, and accuracy is a metric that lies. This lesson resolves both. We will walk through the catalog of fundamental classifiers — decision trees, random forest, Naive Bayes and logistic regression — implementing the mechanics of each one by hand (this is still an algorithms course: entropy, recursive partitions, conditional probabilities), and we will learn to evaluate properly with the confusion matrix, precision, recall, F1 and the ROC curve. The setting is still the canonical dataset: 2,000 historical deliveries, seed 42, and a real business question for Rutalia: which deliveries are going to be delayed, so we can warn the customer before it happens?

Contents

  1. The classification problem at Rutalia
  2. k-NN revisited: its limits
  3. Decision trees: learning questions
  4. Random forest: the wisdom of the forest
  5. Naive Bayes: classifying with probabilities
  6. Logistic regression: the linear classifier
  7. Metrics done right: when accuracy lies
  8. Classifier comparison table

The classification problem at Rutalia

Classifying means assigning a discrete class to each example. In our canonical dataset there are two natural problems:

  • Binary: delayed ∈ {0, 1}. Do we warn customer C-1042 that their package will arrive late?
  • Multiclass: incident type ∈ {none, absent, wrong address, damaged package}. The mechanics are identical (the algorithms in this lesson generalize to k classes); we will work on the binary problem for clarity.

We start from the 05-01 pipeline (same code, same seed):

data = generate_dataset()                     # 05-01: 2000 deliveries, seed 42
X_num = np.column_stack([data["distance_km"], data["weight_kg"],
                         data["departure_hour"], data["weekday"]])
X = np.column_stack([X_num, one_hot(data["zone"], ZONES)])
y = data["delayed"]
X_tr, X_te, y_tr, y_te = train_test_split_manual(X, y)

k-NN revisited: its limits

k-NN performed well in 05-01, but it has two structural problems worth understanding before looking for alternatives:

  • O(n·d) cost per query: there is no training phase, so every prediction scans the n historical deliveries computing d coordinates. With Rutalia's real "millions of records" and thousands of predictions per hour, that is unworkable. (Spatial structures exist — k-d trees, ball trees, cousins of the tries from 01-04 — that speed up the low-dimensional case, but they don't fix the underlying issue.)
  • The curse of dimensionality: in high dimensions, Euclidean distance loses meaning. With large d, nearly all points end up at roughly the same distance from each other, and "the nearest neighbor" stops being especially similar. Our one-hot encoding already lifted us to 13 dimensions; with hundreds of features, k-NN degrades beyond repair.

The conclusion: we want models that compress the data into a small structure during training and then predict in O(depth) or O(d). The following ones do.

Decision trees: learning questions

A decision tree classifies by asking chained questions about the features, like a diagnosis:

flowchart TD
    A{"distance_km > 4.2?"} -->|yes| B{"rush hour?"}
    A -->|no| C{"zone = CEN?"}
    B -->|yes| D[DELAYED 0.91]
    B -->|no| E[on time 0.72]
    C -->|yes| F{"distance_km > 2.9?"}
    C -->|no| G[on time 0.95]
    F -->|yes| H[DELAYED 0.66]
    F -->|no| I[on time 0.88]

The remarkable part: nobody wrote those questions. The algorithm picked them from the data. How? By measuring how much "class mixing" each candidate question removes.

Impurity: entropy and Gini

A node is pure if all its deliveries belong to the same class. Two standard impurity measures for a node with a proportion p of delays:

Measure Formula Range (binary) Notes
Entropy −p·log₂(p) − (1−p)·log₂(1−p) 0 (pure) to 1 (fifty-fifty) Rooted in information theory
Gini index 2·p·(1−p) 0 (pure) to 0.5 Cheaper to compute; sklearn's default

Both behave almost identically in practice. The gain of a question is the parent's impurity minus the weighted average of the impurity of the children it produces: at each node, the algorithm chooses the question with maximum gain.

def gini(y):
    """Gini impurity of a set of binary labels."""
    if len(y) == 0:
        return 0.0
    p = y.mean()
    return 2 * p * (1 - p)

def best_split(X, y):
    """Finds the (feature, threshold) pair that minimizes weighted impurity."""
    best = (None, None, gini(y))             # no split: current impurity
    for j in range(X.shape[1]):              # every feature...
        for threshold in np.unique(X[:, j]): # ...and every value as a threshold
            left = X[:, j] <= threshold
            if left.all() or (~left).all():
                continue                     # useless split: separates nothing
            imp = (left.mean() * gini(y[left])
                   + (~left).mean() * gini(y[~left]))
            if imp < best[2]:
                best = (j, threshold, imp)
    return best                              # (feature, threshold, impurity)

Recursive construction: divide and conquer

Building the tree is pure divide and conquer, the pattern from 01-03: solve the node (choose the best question), split the data in two and recurse into each half. The base case: a pure node, no useful splits, or maximum depth reached.

def build_tree(X, y, max_depth=3, level=0):
    """Returns a tree as nested dicts. Leaf = probability of delay."""
    j, threshold, _ = best_split(X, y)
    if level == max_depth or j is None or gini(y) == 0:
        return {"leaf": True, "p_delayed": y.mean(), "n": len(y)}
    left = X[:, j] <= threshold
    return {"leaf": False, "feature": j, "threshold": threshold,
            "left": build_tree(X[left], y[left], max_depth, level + 1),
            "right": build_tree(X[~left], y[~left], max_depth, level + 1)}

def predict_tree(node, x):
    """Walks down the tree answering the questions: O(depth)."""
    while not node["leaf"]:
        node = node["left"] if x[node["feature"]] <= node["threshold"] else node["right"]
    return int(node["p_delayed"] > 0.5)

tree = build_tree(X_tr, y_tr, max_depth=3)
acc = np.mean([predict_tree(tree, x) == yv for x, yv in zip(X_te, y_te)])
print(f"Tree (depth 3): {acc:.3f}")

Note the algorithmic properties: training costs O(d · n²) per level in this naive version (sklearn brings it down to O(d · n log n) by pre-sorting each column, a direct idea from 04-02), but predicting costs O(depth) — from scanning 1,600 deliveries per query (k-NN) to answering 3 questions. Moreover, the tree needs no scaling (it compares against thresholds, it doesn't compute distances) and it is interpretable: you can show the diagram to Rutalia's head of operations and discuss it.

Overfitting and pruning

A tree with no depth limit keeps splitting until every leaf is pure... even if it holds a single delivery. That is memorizing the noise: the U-shaped curve from 05-01, all over again. Antidotes:

  • Pre-pruning: cap max_depth, require a minimum number of examples per leaf or a minimum gain to split. That is what we did (max_depth=3).
  • Post-pruning: let the tree grow and then remove the branches that don't improve the validation error (more expensive, sometimes better).

In sklearn: DecisionTreeClassifier(max_depth=3, min_samples_leaf=20). Try max_depth=None and compare train vs test: you will watch overfitting happen live.

Random forest: the wisdom of the forest

An individual tree is unstable: change 5% of the data and a different tree may come out (high variance). The solution is surprising: train many different trees and make them vote.

  • Bagging (bootstrap aggregating): each tree is trained on a random sample with replacement of the training set (same size n, but with repeats and absentees). Each tree sees slightly different data, so it makes different mistakes.
  • Random subspaces: at each node, each tree only considers a random subset of features (typically √d). This decorrelates the trees: without it, they would all start splitting on distance_km and vote almost identically.

Why does averaging work? For the same reason the mean of 100 noisy measurements is more reliable than one: if the trees' errors are (partially) independent, they cancel out in the vote. The variance of the average of T independent estimators is the individual variance divided by T. The trees are not fully independent, but bagging and random subspaces push them toward it — hence the effort to decorrelate them.

from sklearn.ensemble import RandomForestClassifier

rf = RandomForestClassifier(n_estimators=200, max_depth=None,
                            random_state=42).fit(X_tr, y_tr)
print(f"Random forest: {rf.score(X_te, y_te):.3f}")

An elegant detail: each individual tree is allowed to overfit (unbounded depth), because the average corrects the variance. You lose the interpretability of the single tree, but rf.feature_importances_ still tells you which features matter — at Rutalia you will see distance_km, departure_hour and the congested zones dominate, consistent with the generator's hidden truth.

Naive Bayes: classifying with probabilities

A complete change of philosophy: instead of learning boundaries, we model probabilities. Bayes' theorem gives us the probability of a delay given the features:

P(delayed | x)  =  P(x | delayed) · P(delayed) / P(x)

The problem: estimating P(x | delayed) for every full combination of features would require astronomical amounts of data. The "naive" assumption rescues it: assume the features are independent of each other within each class, so the joint probability factors into a product of individual probabilities — each one trivial to estimate by counting.

It is false in general (at Rutalia, zone and distance are correlated), but the classifier only needs the correct class to get the highest product, not exact probabilities. That is why Naive Bayes works better than its assumption deserves.

An example with categorical features (zone and time band):

def train_nb(zones, bands, y):
    """Estimates the probability tables by counting, with Laplace smoothing."""
    model = {}
    for c in (0, 1):
        m = (y == c)
        model[c] = {
            "prior": m.mean(),               # P(class)
            # P(zone | class): count + 1 (Laplace) to avoid zero probability
            "p_zone": {z: (np.sum(zones[m] == z) + 1) / (m.sum() + len(ZONES))
                       for z in ZONES},
            "p_band": {f: (np.sum(bands[m] == f) + 1) / (m.sum() + 3)
                       for f in ("morning", "peak", "afternoon")},
        }
    return model

def predict_nb(model, zone, band):
    """Compares log-probabilities (sums, not products: avoids underflow)."""
    scores = {c: np.log(m["prior"]) + np.log(m["p_zone"][zone])
                 + np.log(m["p_band"][band])
              for c, m in model.items()}
    return max(scores, key=scores.get)

Two engineering details that are pure algorithmic practice:

  • Laplace smoothing (+1 in the counts): without it, a combination never seen in training (a delay in PAR in the early morning?) would have probability 0 and wipe out the entire product.
  • Log-probabilities: multiplying many small probabilities causes numerical underflow; adding logarithms is equivalent (log is monotonic) and stable.

Naive Bayes trains in a single counting pass, O(n·d), predicts in O(d) and works surprisingly well with little data and many categorical features (its classic habitat: spam filters). In sklearn: CategoricalNB, GaussianNB (continuous features) or MultinomialNB (counts).

Logistic regression: the linear classifier

The fourth approach: a linear boundary. We compute a score z = w·x + b (a linear combination of the features, as in the linear programming of 02-01) and convert it into a probability with the sigmoid function:

σ(z) = 1 / (1 + e^(−z))        σ(−∞)→0,  σ(0)=0.5,  σ(+∞)→1
def sigmoid(z):
    return 1 / (1 + np.exp(-z))

def predict_logistic(w, b, x):
    p = sigmoid(x @ w + b)      # probability of delay
    return int(p > 0.5)

Geometrically, w·x + b = 0 defines a hyperplane that splits the space in two: on one side we predict a delay, on the other, on time. The (signed) distance to the hyperplane grades the confidence via the sigmoid. It is the quintessential linear classifier: simple, fast (O(d) per prediction), with interpretable coefficients ("each additional km multiplies the odds of a delay by e^w₁").

And where do w and b come from? They are learned by minimizing a cost function via gradient descent — the algorithm we will develop in full detail in 05-03. Here the idea suffices: start with random weights and adjust them iteratively in the direction that reduces the error. In sklearn: LogisticRegression() (it needs scaled features, like any model based on w·x). Its limit is obvious: if the real boundary is not linear (and Rutalia's multiplicative truth isn't), a hyperplane can only approximate it — the way out of that dead end is the neural networks of 05-04, which stack many of these units.

Metrics done right: when accuracy lies

We arrive at the debt outstanding from 05-01. In our dataset, ~85% of deliveries are on time. The "all on time" classifier achieves 85% accuracy while being perfectly useless: it detects not a single delay, which is exactly what Rutalia wants to detect.

Confusion matrix

Everything starts by breaking down the four possible outcomes:

Predicted: delayed Predicted: on time
Actual: delayed TP (true positive) FN (false negative) — unnotified delay
Actual: on time FP (false positive) — false alarm TN (true negative)
def confusion_matrix_manual(y_true, y_pred):
    tp = np.sum((y_true == 1) & (y_pred == 1))
    fn = np.sum((y_true == 1) & (y_pred == 0))
    fp = np.sum((y_true == 0) & (y_pred == 1))
    tn = np.sum((y_true == 0) & (y_pred == 0))
    return tp, fn, fp, tn

Precision, recall and F1

  • Precision = TP / (TP + FP): of the delay warnings we issued, what fraction was real? It measures the cost of false alarms.
  • Recall (sensitivity) = TP / (TP + FN): of the actual delays, what fraction did we detect? It measures the delays that slip past us.
  • F1 = the harmonic mean of both = 2·P·R / (P + R). The harmonic mean punishes imbalance: if either one is close to 0, F1 collapses even if the other is 1.

The "all on time" classifier stands exposed: recall = 0, F1 = 0, no matter how much 85% accuracy it flaunts. And there is an inherent trade-off: lowering the decision threshold (warning at p > 0.3 instead of p > 0.5) raises recall (you catch more delays) but lowers precision (more false alarms). Which one to prioritize is a business decision: if the warning is a cheap SMS, Rutalia will want high recall; if it triggers a financial compensation, high precision. The metric is chosen by looking at the real cost of each type of error — and the final decision about what to do with each customer remains human; the model only prioritizes.

ROC curve and AUC (briefly)

Probabilistic classifiers let you slide the threshold from 0 to 1. The ROC curve plots, for each threshold, the true positive rate against the false positive rate. The AUC (area under that curve) summarizes overall quality: 1.0 is perfect, 0.5 is a coin flip. Its most useful reading: the AUC is the probability that the model scores a real delay higher than an on-time delivery, both chosen at random. In sklearn: roc_auc_score(y_te, model.predict_proba(X_te)[:, 1]). It is the standard comparison metric when the operating threshold hasn't been fixed yet.

Classifier comparison table

Criterion k-NN Decision tree Random forest Naive Bayes Logistic reg.
Training None O(d·n log n) T trees O(n·d), one pass Iterative (gradient)
Prediction O(n·d) O(depth) ✓ O(T·depth) O(d) ✓ O(d) ✓
Needs scaling? Yes No No No Yes
Nonlinear boundary? Yes Yes (piecewise) Yes Limited No
Interpretability Low High Medium (importances) Medium High (coefficients)
Overfitting risk Small k High without pruning Low Low Low
Strong point Simplicity Explainable to business Out-of-the-box accuracy Little data, categoricals Solid, fast baseline

Rule of thumb for tabular data like Rutalia's: start with logistic regression as the baseline, try a random forest (usually the strongest without fine-tuning) and use the single tree when you need to explain the decision.

Common Mistakes and Tips

  • Boasting about accuracy with imbalanced classes. The 85% of "all on time" is the floor, not an achievement. Always look at the confusion matrix and the minority class F1.
  • Letting the tree grow without limits. 0% training error and mediocre test = textbook overfitting. Cap the depth or the minimum examples per leaf, and always compare train vs test.
  • Forgetting smoothing in Naive Bayes. A single zero probability annihilates the whole product. Laplace (+1) is one line of code that prevents absurd predictions.
  • Multiplying probabilities instead of adding logs. With dozens of features, the product silently underflows to 0.0. Always work in log space.
  • Scaling for trees / not scaling for logistic. Trees compare against thresholds (scaling is irrelevant to them); models built on w·x (logistic, k-NN) demand it. Knowing the internal mechanics prevents the mistake.
  • Tip: set the decision threshold according to the business cost of FP vs FN, not the default 0.5. It's free and usually worth more than switching algorithms.

Exercises

  1. The tree versus the forest. With the canonical dataset, train DecisionTreeClassifier with max_depth ∈ {2, 4, 8, None} and a RandomForestClassifier(n_estimators=200). For each model print training and test accuracy. At what depth does the tree clearly overfit? Does the forest overfit even though its trees have unbounded depth?

  2. The accuracy trap, with numbers. Implement the trivial "all on time" classifier and compare it with the random forest from exercise 1 using: accuracy, precision, recall and F1 (computed with your confusion_matrix_manual). Write one sentence explaining why Rutalia should never deploy the trivial model despite its accuracy.

  3. Moving the threshold. With rf.predict_proba(X_te)[:, 1] get the delay probabilities and evaluate precision and recall for thresholds 0.3, 0.5 and 0.7. Which threshold would you choose if the customer warning is a free SMS? And if each warning triggers a 20% discount?

Solutions

Exercise 1:

from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier

for depth in [2, 4, 8, None]:
    t = DecisionTreeClassifier(max_depth=depth, random_state=42).fit(X_tr, y_tr)
    print(f"depth={str(depth):>4}  train={t.score(X_tr, y_tr):.3f}  test={t.score(X_te, y_te):.3f}")

rf = RandomForestClassifier(n_estimators=200, random_state=42).fit(X_tr, y_tr)
print(f"forest      train={rf.score(X_tr, y_tr):.3f}  test={rf.score(X_te, y_te):.3f}")

With max_depth=None the tree nails the training set (≈1.0) and loses on the test set: clear overfitting; the train−test gap grows with depth. The forest also brushes 1.0 on train (its trees are deep) but keeps the test score high: the average of decorrelated trees absorbs the variance that dooms the lone tree.

Exercise 2:

pred_trivial = np.zeros_like(y_te)
pred_rf = rf.predict(X_te)

for name, pred in [("trivial", pred_trivial), ("forest", pred_rf)]:
    tp, fn, fp, tn = confusion_matrix_manual(y_te, pred)
    prec = tp / (tp + fp) if tp + fp else 0.0
    rec = tp / (tp + fn) if tp + fn else 0.0
    f1 = 2 * prec * rec / (prec + rec) if prec + rec else 0.0
    acc = (tp + tn) / len(y_te)
    print(f"{name}: acc={acc:.3f} prec={prec:.3f} recall={rec:.3f} F1={f1:.3f}")

The trivial model hovers around acc≈0.85 but with recall=0 and F1=0: it detects no delay at all. The forest will have less of an edge in accuracy than intuition suggests, but far superior recall and F1. The sentence: the trivial model warns about no delay whatsoever, which is exactly the system's one and only job; its accuracy merely reflects that delays are rare.

Exercise 3:

probs = rf.predict_proba(X_te)[:, 1]
for u in (0.3, 0.5, 0.7):
    pred = (probs > u).astype(int)
    tp, fn, fp, tn = confusion_matrix_manual(y_te, pred)
    print(f"threshold {u}: precision={tp/(tp+fp):.3f}  recall={tp/(tp+fn):.3f}")

With threshold 0.3 recall goes up (more delays get caught) at the expense of precision; with 0.7, the reverse. Free SMS → cheap false alarms → low threshold (0.3), prioritize recall. 20% discount → each FP costs money → high threshold (0.7), prioritize precision. The right metric depends on the real cost of each error, not on the statistics.

Conclusion

You now have the essential classification catalog and, more importantly, its internal mechanics: trees learn questions by maximizing purity with divide and conquer; the forest averages decorrelated trees to kill variance; Naive Bayes counts and multiplies probabilities (in logarithms, with Laplace); and logistic regression draws a hyperplane and grades confidence with the sigmoid. You also know how to evaluate for real: the confusion matrix, precision/recall/F1 and the threshold as a business decision, because with 85% of deliveries on time, accuracy alone is smoke. One confessed technical debt remains: we said the weights of logistic regression "are learned by minimizing a cost with gradient descent" without explaining how. In 05-03 we settle that debt: we move on to predicting the delivery minutes (regression), and there we will develop gradient descent piece by piece — the algorithm that is also the direct bridge to the neural networks of 05-04.

© Copyright 2026. All rights reserved