We closed module 4 with an observation: throughout the entire course so far, the problem knowledge has been supplied by us, by hand. We designed the optimization rules in module 2, measured the graph weights in module 3, justified the invariants and heuristics in module 4. But Rutalia accumulates millions of historical delivery records — hour, zone, weight, distance, delay, incidents — containing patterns that nobody has ever written down as a rule. In this lesson we make the paradigm shift: instead of programming the rules, we will let algorithms learn them from data. We will see what "learning" means exactly, what types of learning exist, define the canonical Rutalia dataset we will use throughout the module, walk through the complete workflow (train, evaluate, don't fool yourself) and build our first model from start to finish: a k-NN classifier implemented by hand to predict whether a delivery will arrive late.

Contents

  1. Hand-written rules vs learned rules
  2. Types of machine learning
  3. The canonical Rutalia dataset
  4. The workflow: train, evaluate, generalize
  5. Overfitting and underfitting: memorizing is not learning
  6. Cross-validation and metrics (a preview)
  7. Data preparation: categorical variables and scaling
  8. First complete model: k-NN by hand and with scikit-learn

Hand-written rules vs learned rules

Let's compare how we have solved problems until now with how we will solve them in this module:

Aspect Modules 1-4 (classical algorithmics) Module 5 (machine learning)
Source of knowledge The programmer writes the rules The rules are extracted from historical data
Algorithm input One instance of the problem (a graph, a list) A set of examples with their outcome
Output The solution to that instance A model: a function that predicts on new cases
Guarantees Often exact (Dijkstra gives THE shortest path) Statistical: approximate hits, with measurable error
Where it shines The problem has known, formalizable structure The rule is unknown, fuzzy, or shifts with the data
Rutalia example "The fastest ALM→HOS path according to the 9×9 matrix" "How long will this delivery actually take on a Friday at 6 pm?"

The question "how long will this delivery take?" illustrates the shift. With Dijkstra (03-03) we get the travel time according to the weights we measured. But the actual time depends on factors that aren't in the graph: rush hour, the package weight (hunting for parking with 20 kg is not the same as with 200 g), whether it's Friday, whether zone MER has its market that day... Nobody at Rutalia knows how to write that formula. The historical data, on the other hand, contains it.

Formally, in supervised learning we look for a function f such that f(x) ≈ y, where x is a vector of features of an example and y is the known answer. The "learning algorithm" is the procedure that, given a set of pairs (x, y), builds that f. Notice: we are still in an algorithms course. A learning algorithm is an algorithm like any other — with its complexity, its data structures and its invariants — whose output is another function.

Types of machine learning

Type Is there a label y? Goal Rutalia example Where
Supervised — classification Yes, categorical Predict a class Will this delivery be late? (yes/no) 05-02
Supervised — regression Yes, numeric Predict a continuous value How many minutes will it take? 05-03
Unsupervised — clustering No Discover groups in the data Which zones behave alike? 05-05
Reinforcement Delayed reward Learn a policy of actions An agent that learns routes by trial and error (beyond this course)

Reinforcement learning — an agent that acts, receives rewards and adjusts its behavior — underpins the systems that play Go or control robots, but it falls outside this course; we will focus on supervised (05-01 to 05-04) and unsupervised learning (05-05).

The canonical Rutalia dataset

Just as module 2 had its canonical TSP instance (optimum 35.22 km) and module 3 its 9-zone graph, this module will have its canonical delivery dataset. Each row is one historical delivery:

Column Type Description
distance_km numeric Route distance from the hub to the destination
weight_kg numeric Package weight
zone categorical Destination zone: one of the 9 from the module 3 graph (ALM, MER, EST, UNI, RIO, CEN, IND, HOS, PAR)
departure_hour numeric (8-20) Hour at which the courier leaves the hub
weekday numeric (0=Monday … 6=Sunday) Day of the week
delivery_minutes numeric Regression target: actual minutes until delivery
delayed binary (0/1) Classification target: 1 if it exceeded the 45 promised minutes

A few sample rows (customers identified only by a fictitious id):

customer distance_km weight_kg zone departure_hour weekday delivery_minutes delayed
C-1042 3.2 1.5 CEN 18 4 52.3 1
C-2077 1.1 0.4 UNI 10 1 19.8 0
C-1583 6.8 12.0 IND 8 2 41.5 0
C-3316 4.5 2.2 MER 13 5 61.0 1
C-0921 2.3 0.9 PAR 11 6 24.1 0

Since we can't attach millions of rows to a lesson, we will use a synthetic generator with a fixed seed: the whole module will work with exactly the same data, reproducible on your machine. The generator hides a "ground truth" (the formula that produces the minutes) which our models will try to discover without knowing it. We do know it, because we wrote it — and that will let us judge whether a model learns well.

import numpy as np

ZONES = ["ALM", "MER", "EST", "UNI", "RIO", "CEN", "IND", "HOS", "PAR"]
# Congestion factor per zone: CEN and MER are dense; IND and PAR flow freely
ZONE_FACTOR = {"ALM": 1.0, "MER": 1.3, "EST": 1.1, "UNI": 0.9, "RIO": 1.0,
               "CEN": 1.4, "IND": 0.8, "HOS": 1.2, "PAR": 0.85}

def generate_dataset(n=2000, seed=42):
    """Generates n synthetic historical Rutalia deliveries."""
    rng = np.random.default_rng(seed)
    distance = rng.uniform(0.5, 8.0, n)            # km
    weight = np.round(rng.exponential(3.0, n), 1)  # kg: mostly light packages
    zone = rng.choice(ZONES, n)
    hour = rng.integers(8, 21, n)                  # departures from 8 am to 8 pm
    day = rng.integers(0, 7, n)

    # "Hidden truth" the models will have to learn:
    base = 5 + 6.0 * distance                      # ~6 min/km + 5 fixed min
    congestion = np.array([ZONE_FACTOR[z] for z in zone])
    peak = 1 + 0.35 * np.isin(hour, [13, 14, 18, 19])  # rush hours
    weekend = 1 - 0.10 * (day >= 5)                # weekends flow better
    heavy = 1 + 0.02 * weight                      # parking with a load is costly
    noise = rng.normal(0, 4.0, n)                  # unexplainable variability

    minutes = np.maximum(base * congestion * peak * weekend * heavy + noise, 5.0)
    delayed = (minutes > 45).astype(int)

    return {"distance_km": distance, "weight_kg": weight, "zone": zone,
            "departure_hour": hour, "weekday": day,
            "delivery_minutes": np.round(minutes, 1), "delayed": delayed}

data = generate_dataset()
print(f"Deliveries: {len(data['delayed'])}, delayed: {data['delayed'].mean():.1%}")

Key points about the generator:

  • Seed 42: default_rng(42) guarantees that you and this lesson see the same numbers. Reproducibility is a core practice in ML.
  • The hidden truth is multiplicative and nonlinear (congestion × rush hour × weight): no linear model will capture it perfectly, which will make things interesting in 05-03 and 05-04.
  • The Gaussian noise represents what is irreducible: two identical deliveries never take exactly the same time. No model should (or will be able to) predict it — trying to is, precisely, overfitting.
  • Delays are a minority (around 15-20%): the classes are imbalanced, something we will exploit in 05-02 when discussing metrics.

The workflow: train, evaluate, generalize

The classic newcomer mistake is evaluating the model on the same data it was trained on. A model that memorizes the training set scores 100% on that exam... because it already knew the answers. What matters is how it behaves on deliveries it has never seen: that is called generalizing.

The minimal protocol:

flowchart LR
    A[Historical data] -->|shuffle and split| B[Training 80%]
    A --> C[Test 20%]
    B --> D[Train model]
    D --> E[Model f]
    C --> F[Evaluate f on NEVER-seen data]
    E --> F
    F --> G[Honest estimate of the real error]
def train_test_split_manual(X, y, test_frac=0.2, seed=42):
    """Shuffles the indices and separates training and test."""
    rng = np.random.default_rng(seed)
    idx = rng.permutation(len(y))          # random permutation of 0..n-1
    cut = int(len(y) * (1 - test_frac))
    tr, te = idx[:cut], idx[cut:]
    return X[tr], X[te], y[tr], y[te]

Shuffling is essential: if the data came sorted by date or by zone, the test set would contain only one kind of delivery and the evaluation would be biased. And the test set is sacred: you don't touch it until the very end, neither to choose parameters nor to "take a peek". Every time one of your decisions depends on the test set, the test set stops being an honest estimate.

Overfitting and underfitting: memorizing is not learning

Every model has a "capacity": how much complexity it can represent. The balance is delicate:

  • Underfitting: the model is too simple for the pattern. Example: predicting delivery minutes with the global mean (~35 min for everyone). It fails on training and on test.
  • Overfitting: the model is so flexible that it memorizes the noise in the training data. Extreme example: a hash table (01-04) that stores every historical delivery and returns its exact minutes. Zero error on training, disaster on new deliveries.

We can visualize this with k-NN, the model we will build at the end. k-NN with k=1 answers by copying the most similar historical delivery: it memorizes, noise included. With k=n (all deliveries) it always answers the global majority: it underfits. The good k lies in between:

Error
  ▲
  │ ●                                          ← k=1: high test error (overfitting)
  │  ●                                    ●●
  │   ●●                              ●●●     TEST error (U-shaped curve)
  │     ●●●              ●●●●●●●●●
  │        ●●●●●●●●●●●●                       ← sweet spot
  │
  │ ○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○        TRAINING error
  └────────────────────────────────────▶ growing k = less capacity

With k=1 the training error is 0 (your nearest neighbor is yourself) but the test error is high. As k grows, both curves approach each other until, past the optimal point, both rise: the model can no longer tell anything apart. This U-shaped test error curve appears in every model of the module; in 05-03 we will formalize it as the bias-variance trade-off.

Cross-validation and metrics (a preview)

If the test set is sacred, what data do we use to choose k? Setting aside another partition (validation) works, but wastes data. The standard solution is K-fold cross-validation: split the training set into K chunks, train K times leaving a different chunk out as validation each time, and average the K errors.

flowchart TB
    subgraph CV ["5-fold CV on the 80% training split"]
    R1["Round 1: [VAL][tr][tr][tr][tr]"]
    R2["Round 2: [tr][VAL][tr][tr][tr]"]
    R3["... through round 5: [tr][tr][tr][tr][VAL]"]
    end
    R1 --> M["Mean of the 5 errors → stable estimate"]
    R2 --> M
    R3 --> M

Each example is used K−1 times for training and once for validation: we make use of all the data and the estimate is more stable than that of a single split. The cost: training K times — a time/reliability trade-off very much in the spirit of this course.

Regarding metrics, each problem has its own and we will develop them where they belong: confusion matrix, precision/recall/F1 and ROC for classification (05-02); MSE, RMSE, MAE and R² for regression (05-03); inertia and silhouette for clustering (05-05). For now we will use the simplest one, accuracy: the fraction of correct predictions. And we can already reveal its trap: if 85% of Rutalia's deliveries are on time, a "model" that always answers "on time" scores 85% without having learned anything. In 05-02 we will take it apart.

Data preparation: categorical variables and scaling

ML algorithms operate on numeric vectors, and our dataset has one categorical column (zone) and numeric columns on wildly different scales. Two indispensable transformations:

One-hot encoding

zone is not a number. Encoding it as ALM=0, MER=1, ..., PAR=8 would invent a fake order and a fake distance (is MER "between" ALM and EST? Is PAR "8 times farther" than ALM?). The solution is one-hot encoding: one binary column per category.

def one_hot(values, categories):
    """Converts a categorical array into a binary n × len(categories) matrix."""
    m = np.zeros((len(values), len(categories)))
    for j, cat in enumerate(categories):
        m[:, j] = (values == cat)   # column j: does the example belong to category j?
    return m

Z = one_hot(data["zone"], ZONES)    # n × 9 matrix, one column per zone

This way, the distance between any two distinct zones is always the same, with no spurious ordering.

Scaling (and why it matters for distance-based algorithms)

Our first model, k-NN, decides by Euclidean distance between deliveries. Look at the scales: distance_km ranges from 0.5 to 8, but departure_hour ranges from 8 to 20. A difference of 6 hours (noon vs night — crucial for delays!) weighs as much in the distance as 6 km. And if we measured weight in grams, that column would crush all the others. The geometry of the space would depend on the units — an arbitrary decision the model should not inherit.

Standardization fixes this: from each column we subtract its mean and divide by its standard deviation, leaving them all with mean 0 and deviation 1.

def standardize(X_train, X_test):
    """Fits mean and deviation ONLY on train and applies to both."""
    mu = X_train.mean(axis=0)
    sigma = X_train.std(axis=0)
    sigma[sigma == 0] = 1.0                    # avoids division by zero
    return (X_train - mu) / sigma, (X_test - mu) / sigma

A crucial detail: the mean and deviation are computed only on the training set. If we also used the test set, test information would "leak" into the model (data leakage) and the evaluation would no longer be honest. It is the subtle version of the sin of evaluating on training data.

First complete model: k-NN by hand and with scikit-learn

k-nearest neighbors (k-NN) is the perfect algorithm to start with because it is pure algorithmics from this course: distances + selecting the k smallest. The idea: to predict whether a new delivery will be late, look up the k most similar historical deliveries and vote on what they did.

There is no real "training": the model is the dataset. All the work happens at prediction time. To select the k smallest we use a heap (01-04): heapq.nsmallest runs in O(n log k), better than sorting everything in O(n log n).

import heapq

def predict_knn(X_train, y_train, x_new, k=15):
    """Classifies x_new by majority vote of its k nearest neighbors."""
    # 1. Euclidean distance from x_new to ALL examples (vectorized)
    diffs = X_train - x_new                  # broadcasting: n × d
    dists = np.sqrt((diffs ** 2).sum(axis=1))
    # 2. The k indices with the smallest distance, via heap: O(n log k)
    neighbors = heapq.nsmallest(k, range(len(dists)), key=lambda i: dists[i])
    # 3. Majority vote of their labels
    return int(y_train[neighbors].sum() * 2 > k)   # 1 if more than half voted "delayed"

Let's assemble the complete pipeline — encode, split, scale, evaluate:

# 1. Feature matrix: numeric columns + one-hot of zone
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)])   # n × 13
y = data["delayed"]

# 2. Split BEFORE scaling (scaling may only see train)
X_tr, X_te, y_tr, y_te = train_test_split_manual(X, y)
X_tr_s, X_te_s = standardize(X_tr, X_te)

# 3. Evaluate on the test set
correct = sum(predict_knn(X_tr_s, y_tr, x, k=15) == yv
              for x, yv in zip(X_te_s, y_te))
print(f"Manual k-NN accuracy:   {correct / len(y_te):.3f}")

# 4. Mandatory reference: the trivial model that always says the majority class
print(f"Trivial model accuracy: {max(y_te.mean(), 1 - y_te.mean()):.3f}")

Always compare against the trivial model: if your k-NN doesn't clearly beat it, it hasn't learned anything useful. On this dataset, k-NN should beat it comfortably, because the delay depends strongly on distance, zone and hour — and nearby neighbors in that space share the same fate.

And the scikit-learn version, the standard library, which does the same with industrial-grade validations and optimizations:

from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.neighbors import KNeighborsClassifier

X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42)
scaler = StandardScaler().fit(X_tr)       # fit ONLY on train...
X_tr_s, X_te_s = scaler.transform(X_tr), scaler.transform(X_te)   # ...transform both

knn = KNeighborsClassifier(n_neighbors=15).fit(X_tr_s, y_tr)
print(f"sklearn k-NN accuracy:  {knn.score(X_te_s, y_te):.3f}")

Note the fit/transform/predict pattern: fit learns from the data (the scaler's parameters, or the model), transform/predict apply what was learned. The entire scikit-learn API follows this contract, which helps prevent information leaks.

Throughout this module we will always keep the dual track: implement the essential mechanics by hand (this is an algorithms course) and then use scikit-learn as the working tool, verifying that both agree.

Common Mistakes and Tips

  • Evaluating on training data. The number one mistake. 99% accuracy "on train" says nothing; only performance on never-seen data counts.
  • Scaling before splitting. If you compute mean and deviation on the full dataset, you leak test information into the model. Correct order: split → fit transformations on train → apply to both.
  • Encoding categories as ordered integers. ALM=0...PAR=8 invents nonexistent distances and confuses any distance-based algorithm. Use one-hot.
  • Forgetting the trivial reference model. With imbalanced classes, 85% accuracy can mean exactly zero learning. Always compute what "always majority" scores.
  • Not fixing seeds. Without random_state/seed, every run gives different results and debugging or comparing becomes impossible. Fix seeds in splits and generators.
  • Tip: distrust results that look too good. 100% accuracy almost always betrays a data leak (a feature that "contains" the answer, or a contaminated test set), not a brilliant model.

Exercises

  1. The U-shaped curve of k. With the canonical dataset (seed 42), evaluate the manual k-NN for k ∈ {1, 3, 7, 15, 31, 61, 121, 501} on the test set. Print the accuracy for each k and locate the sweet spot. What happens with k=1? And with k=501? Explain both extremes in terms of overfitting/underfitting.

  2. The units disaster. Repeat the k-NN evaluation (k=15) with two variants: (a) with no standardization at all, and (b) with no standardization and with weight_kg converted to grams (multiply that column by 1000). Compare the three accuracies (standardized, raw, grams) and explain why the grams version sinks toward the trivial model.

  3. Manual cross-validation. Implement cv_5fold(X, y, k) that splits the training set into 5 folds, trains/evaluates the manual k-NN 5 times and returns the mean accuracy. Use it to choose the best k from {5, 15, 45} without touching the test set, and only at the end evaluate the chosen k on the test set.

Solutions

Exercise 1:

for k in [1, 3, 7, 15, 31, 61, 121, 501]:
    acc = np.mean([predict_knn(X_tr_s, y_tr, x, k) == yv
                   for x, yv in zip(X_te_s, y_te)])
    print(f"k={k:4d}  accuracy={acc:.3f}")

With k=1 the test accuracy drops relative to the sweet spot: each prediction copies a single neighbor, noise included (overfitting; on the training set itself it would score 1.0). With k=501 the vote averages over a third of the dataset and the model tends toward the global majority vote (underfitting). The intermediate values (≈7-61) form the valley of the U.

Exercise 2:

# (a) raw: split without standardizing
Xa_tr, Xa_te, ya_tr, ya_te = train_test_split_manual(X, y)
acc_raw = np.mean([predict_knn(Xa_tr, ya_tr, x, 15) == yv
                   for x, yv in zip(Xa_te, ya_te)])

# (b) weight in grams
Xg = X.copy(); Xg[:, 1] *= 1000
Xg_tr, Xg_te, yg_tr, yg_te = train_test_split_manual(Xg, y)
acc_grams = np.mean([predict_knn(Xg_tr, yg_tr, x, 15) == yv
                     for x, yv in zip(Xg_te, yg_te)])
print(acc_raw, acc_grams)

Raw, departure_hour (range ~12) dominates the one-hot columns (range 1) and results already degrade. In grams, the weight column shows differences in the thousands while the others vary in single units: the Euclidean distance becomes, in practice, "weight difference"; and since weight barely determines the delay, k-NN falls toward the trivial model. Moral: units are arbitrary and must not decide the geometry — that is why we standardize.

Exercise 3:

def cv_5fold(X, y, k):
    idx = np.random.default_rng(0).permutation(len(y))
    folds = np.array_split(idx, 5)
    accs = []
    for i in range(5):
        val = folds[i]
        tr = np.concatenate([folds[j] for j in range(5) if j != i])
        Xtr_s, Xval_s = standardize(X[tr], X[val])   # scale inside the fold!
        acc = np.mean([predict_knn(Xtr_s, y[tr], x, k) == yv
                       for x, yv in zip(Xval_s, y[val])])
        accs.append(acc)
    return np.mean(accs)

best_k = max([5, 15, 45], key=lambda k: cv_5fold(X_tr, y_tr, k))
print("Best k by CV:", best_k)
# Only now, exactly once, is best_k evaluated on the test set.

Note the detail: standardization is fitted inside each fold on its training part — the same anti-leakage principle as always, applied recursively. The test set is used a single time, with k already decided.

Conclusion

We have crossed the course's frontier: from writing rules to learning them. You now know what a model is (a function learned from examples), what types of learning exist, and above all you know the protocol that holds everything together: separating train and test, scaling without leaks, cross-validating, and distrusting accuracy without context. We defined the canonical Rutalia dataset — 2,000 synthetic deliveries with seed 42 — that will accompany us through the whole module, and we built a first complete classifier, k-NN, which deep down is pure algorithmics: Euclidean distances and a heap for the k smallest. But k-NN has serious limits (it pays O(n·d) for every prediction and suffers in high dimensions), and classification has much more to offer. In the next lesson, 05-02, we will explore the catalog of classifiers — decision trees, random forest, Naive Bayes, logistic regression — and learn to measure them properly, because with 85% of deliveries on time, accuracy is a metric that lies.

© Copyright 2026. All rights reserved