CourseAI / ML Engineer: The Complete Course · Module 1: Mathematical Foundations · part 8 of 93
Part 8 · Module 1: Mathematical Foundations

Lesson 1: Parcels as vectors: the linear algebra of a model

28 min read·
Module 1 · Lesson 1 · About 50 minutes · Needs: Python lists and loops, school algebra, and the Kestrel data (course-guide/06). No model calls, no keys.

The situation

Week 1 at Kestrel, Monday 2 November 2026. Adaeze hands you the spring prototype: "Before you change anything, rebuild the maths it rests on, by hand."

The roadmap puts it plainly: this is "the infrastructure-and-training track, distinct from AI engineering", and it needs "more maths than the AI engineer track needs".

One parcel runs through the lesson: KP0104204 (simulated), collected from Dartford on Wednesday 2 September 2026 at 18:00, route DAR-R28, 143 stops, a poor address (0.578). It arrived a day late.

WHERE THIS FITS The Kestrel system this lesson SHARED SERVICES Depot scans parcels, routes Ingestion nightly batch Validation Pandera checks Data lake Parquet, DuckDB Experiments MLflow runs Training LightGBM, Optuna Online store Feast, Redis Features point in time Gates vs the champion Registry staged versions Serving API FastAPI, ONNX Consumers vans, customers Retraining trigger, budget Alerting on call Monitoring drift, Evidently Late labels a day later Orchestrator Prefect flows Versions DVC, Git CI/CD GitHub Actions Containers Docker images
Module 1 rebuilds the maths inside Kestrel's training step (blue); every other part of the system stays grey until later modules.

A real case, first

On 11 December 2006 Simon Funk posted "Netflix Update: Try This at Home" on his own site, during the Netflix Prize. Netflix had released "100M ratings (from 1 to 5) of 17K movies by 500K users". As a table, "this matrix would have about 8.5 billion entries", and "you are only given values for one in eighty five of the cells".

Funk's model gives each user forty preferences and each movie forty aspects. To predict a rating "we just multiply each user preference by the corresponding movie aspect, and then add those forty leanings up": a dot product. "All told that model requires 40*(17K+500K) values, or about 20M", 400 times less than the full table. The result "is exactly an SVD if the training set perfectly covers the matrix". His advice for training it: "Just take the derivative of the approximation error and follow it." That is lesson 2.

CASE STUDY 8.5 billion cells, 20 million numbers THE FULL RATINGS TABLE 500K users x 17K movies 1 cell in 85 has a rating FUNK'S 40 FEATURES users x movies about 20M values 400 times fewer rating = a dot product
Funk's Netflix Prize table had about 8.5 billion cells with one in 85 rated (amber); two thin matrices of 40 features (blue) held about 20 million numbers, and each rating was a dot product of a user row and a movie row.

What you will be able to do

  • Turn the parcel table into a matrix and score it with one product, checking every shape.
  • Compute a dot product, a projection and a cosine by hand.
  • Find eigenvectors and principal components with NumPy, and test whether a table is low rank.
  • Choose a norm, measure distances between depots, and broadcast a per-depot scaler without a loop.
From parcels to matrices Build by hand, check against the library Active flow 1 Processing step number Shared data df 147,444 parcels training weeks before 24 Aug kestrel/maths/linalg.py NumPy as_matrix: X is (86014, 8) score, least squares eigh, SVD, distances What you check every shape asserted NumPy against hand sums against scikit-learn 1 2 3
The lesson's path: the shared parcels (yellow, data) become a matrix in kestrel/maths/linalg.py (blue, the code), and every result is checked (teal) against a hand sum or a library; circles give the order.

Theory first, worked by hand on KP0104204; from "Run it", code proves every number.

1. Vectors, matrices and operations (Essential)

A model never sees a parcel, only a list of numbers. Every later step is arithmetic on those lists.

HOW IT WORKS KP0104204 as eight numbers ship_hour position 0 18 weight_kg position 1 4.51 distance_km position 2 23.3 stops_on_route position 3 143 address_quality position 4 0.578 rain_mm position 5 0.3 wind_kph position 6 19.7 driver_new position 7 0 a vector: shape (8,) the order is fixed, so position 3 always means stops
A model sees KP0104204 as a vector of eight numbers in a fixed order (blue, Kestrel's data): position 3 is always the 143 stops.

A vector is an ordered list of numbers. KP0104204 becomes eight: hour 18, weight 4.51 kg, distance 23.3 km, 143 stops, address quality 0.578, rain 0.3 mm, wind 19.7 kph, and 0 for "new driver". The order is fixed.

HOW IT WORKS The parcel table is a matrix col 0 col 1 col 2 col 3 col 4 col 5 col 6 col 7 14 1.31 14.1 100 0.929 0 7.8 0 13 3.2 16.7 125 0.875 1 19.1 0 16 0.69 17.2 155 0.821 1 19.1 0 ... ... ... ... ... ... ... ... row 0 row 1 86,014 rows X: shape (86014, 8) 5.5 MB of floats X.T: shape (8, 86014)
Every training parcel is a row and every feature a column: X has shape (86014, 8) and takes 5.5 MB; its transpose X.T swaps them to (8, 86014).

Stack every training parcel as a row and you have a matrix, X, with 86,014 rows and 8 columns: a shape of (86014, 8), 5.5 MB of numbers. Its transpose, X.T, swaps rows and columns: (8, 86014).

HOW IT WORKS Add and scale, position by position kp 18 4.51 23.3 first 3 of 8 kp + kp 36 9.02 46.6 first 3 of 8 0.5 * kp 9 2.255 11.65 first 3 of 8 and
Element-wise operations work position by position: KP0104204 plus itself starts 36, 9.02, 46.6, and half of it starts 9, 2.255, 11.65.

Vectors of the same length add position by position, and a number scales each position: these are element-wise operations.

2. Matrix multiplication and shapes (Essential)

Scoring every parcel is one line, X @ w, if the shapes fit. Many silent training bugs are shape bugs.

HOW IT WORKS Inner sizes must match (86014, 8) X, parcels @ (8,) w, weights = (86014,) one score each (86014, 8) X, parcels @ (8, 3) three scorers = (86014, 3) three scores each 8 = 8 8 = 8 (86014, 8) X @ (3, 8) wrong way = error: 8 is not 3
A matrix product needs matching inner sizes: (86014, 8) @ (8,) gives one score per parcel and (8, 3) gives three (green, the check passes); weights shaped (3, 8) fail (red).

The matrix product X @ w combines each row of X with the weights w; the inner sizes must match. (86014, 8) times (8,) gives (86014,), one score per parcel. Three weight vectors side by side, (8, 3), give (86014, 3): three scores per parcel from one product.

BY THE NUMBERS KP0104204's score, term by term FEATURE X WEIGHT, LEAST SQUARES ON THE TRAINING WEEKS stops 143 +0.356 hour 18 +0.097 distance 23.3 +0.033 weight, rain +0.007 wind 19.7 -0.003 address 0.578 -0.180 intercept b -0.072 0 sum: 0.237 training late rate 0.0805
KP0104204's least-squares score is a dot product (amber, a model's estimate): 143 stops add 0.356 and the poor address takes away 0.180, for 0.237 in all, nearly three times the training late rate.

Where do weights come from? The prototype's simplest ancestor is least squares: the weights that make X @ w + b closest to the late labels. On the training weeks it gives stops_on_route 0.0025 per stop and address_quality -0.311. For KP0104204: 18 × 0.0054 + 143 × 0.0025 + 0.578 × (-0.311) + the rest + b (-0.0723) = 0.237, nearly three times the 0.0805 training late rate.

3. Dot products and projections (Essential)

The score above is a dot product: multiply matching positions and add. It also measures how far two vectors point the same way.

HOW IT WORKS A dot product, a cosine, a shadow 1 2 3 4 5 a = (3, 4) shadow (3, 0) b = (1, 0) a . b = 3x1 + 4x0 = 3 multiply and add cosine = 3 / (5 x 1) = 0.6 divide by both lengths projection = 3 x b = (3, 0) the shadow on b's line
By hand: a = (3, 4) and b = (1, 0) have dot product 3 and cosine 0.6, and a's projection on b's line is its shadow (3, 0) (green).

By hand: a = (3, 4), b = (1, 0). Their dot product is 3. Divide by both lengths (5 and 1) and you get the cosine, 0.6. The projection of a onto b is its shadow on b's line: (3, 0).

HOW IT WORKS Least squares drops a perpendicular the plane: every possible X @ w + b y, the labels prediction residual A.T @ residual largest 1.3e-8 against values near 884,000
Least-squares predictions (amber) are the labels' shadow on the space the columns span, so the residual (red) meets every column at a right angle: their dot products are 1.3e-8 at most (green, the check passes).

Least squares is a projection. Its predictions are the shadow of the labels on the space the columns span, so the leftover gap (the residual) sits at right angles to every column: each column's dot product with it is 0, up to rounding (1.3e-8 against values near 884,000).

BREAK IT Big numbers decide raw cosine KP0104204 KP0104205 stops_on_route 143 131 distance_km 23.3 10.2 wind_kph 19.7 6.0 rain_mm 0.3 2.6 raw 0.992 standardised 0.573
On raw numbers two different parcels look almost identical, cosine 0.992 (red), because 143 and 131 stops swamp the rest; on standardised columns the cosine is 0.573 (green).

Cosine has a trap. On raw numbers KP0104204 and the next parcel, KP0104205, score 0.992, almost identical, because 143 and 131 stops swamp everything else. After scaling each column (section 6) they score 0.573. Steck, Ekanadham and Kallus (2024) "caution against blindly using cosine-similarity" for embeddings too.

4. Eigenvalues and eigenvectors (Essential)

Some directions are special: a matrix only stretches them. They show which features vary together.

HOW IT WORKS A direction that only stretches v = (1, 1) C v = (3, 3) u = (1, 0) C u = (2, 1) C = [[2, 1], [1, 2]] a symmetric 2 by 2 matrix C v = 3 v same line, 3 times longer: eigenvalue 3 C u turns u is not an eigenvector illustrative matrix
An eigenvector only stretches: C = [[2, 1], [1, 2]] sends (1, 1) to (3, 3), the same line three times longer, so its eigenvalue is 3 (green); (1, 0) is turned to (2, 1) (red). Illustrative matrix.

An eigenvector of a matrix C is a direction v with C v = λ v: C stretches it by the eigenvalue λ and does not turn it. By hand, for C = [[2, 1], [1, 2]]: C (1, 1) = (3, 3), so (1, 1) is an eigenvector with λ = 3 (illustrative).

HOW IT WORKS Power iteration start random v 1 multiply C @ v 2 rescale length 1 3 repeat 200 times 4 power iteration 1.1838 NumPy eigh 1.1838 Kestrel's 8 by 8 covariance matrix: the same top eigenvalue and direction
Power iteration multiplies a random vector by C and rescales it, again and again; on Kestrel's covariance matrix it reaches 1.1838 (amber, an iterative estimate), exactly NumPy's eigh (green).

Power iteration finds the biggest one: multiply any vector by C, rescale it, repeat. On Kestrel's 8 by 8 covariance matrix (how each pair of scaled features moves together) it reaches 1.1838 after 200 steps, the same as NumPy's eigh. Brin and Page (1998) wrote that PageRank "corresponds to the principal eigenvector of the normalized link matrix of the web" and is found with "a simple iterative algorithm".

BY THE NUMBERS Eight eigenvalues, nearly equal 1.184 #1 1.025 #2 1.008 #3 1.001 #4 0.997 #5 0.994 #6 0.915 #7 0.876 #8 COVARIANCE OF THE 8 STANDARDISED FEATURES, SUM 8.0 top direction rain -0.62, wind -0.60, distance -0.51 (sign is arbitrary)
Kestrel's eight eigenvalues run from 1.184 to 0.876 and sum to 8, one per standardised feature; the top direction (amber) mixes rain, wind and distance, the weather on long routes.

Kestrel's eigenvalues run from 1.184 down to 0.876 and sum to 8, one per feature. The top direction mixes rain, wind and distance: the weather on long routes. Its sign is arbitrary.

5. Matrix decompositions: SVD and PCA (Essential)

A decomposition splits a matrix into simple pieces: it compresses data, finds structure and, for Funk, predicted missing cells.

HOW IT WORKS Any matrix as three pieces Z (86014, 8) = U (86014, 8) (8, 8), diagonal Vᵀ (8, 8) U where each parcel sits Σ, singular values how much spread Vᵀ, directions the principal axes
The SVD writes the scaled parcel matrix as U Σ Vᵀ: directions in Vᵀ (blue), the spread along each in the diagonal Σ (amber, largest first), and each parcel's position in U (grey).

The singular value decomposition (SVD) writes any matrix as U Σ Vᵀ: Vᵀ holds directions, Σ how much the data spreads along each (the singular values), U where each row sits. Keep the biggest few and you have the best low-rank copy.

BY THE NUMBERS Nothing to compress 14.8% PC1 12.8% PC2 12.6% PC3 12.5% PC4 12.5% PC5 12.4% PC6 11.4% PC7 11.0% PC8 SHARE OF VARIANCE PER PRINCIPAL COMPONENT first two: 27.6% 90% needs all 8 of 8 the features are nearly independent
PCA on Kestrel's eight features: the first two components keep only 27.6% of the variance and 90% needs all eight (red), because the features are nearly independent; PCA finds nothing to compress.

Principal component analysis (PCA) is the SVD of the centred, scaled matrix: the directions are the principal components, and each singular value squared gives its share of the variance. On Kestrel's eight features the first two keep only 27.6%, and all eight are needed for 90%. The columns are nearly independent, so there is nothing to compress: a finding, not a failure.

BY THE NUMBERS Lateness is almost rank one Mon Tue Wed Thu Fri Sat BHM 0.066 0.054 0.065 0.065 0.068 0.057 BRS 0.065 0.047 0.055 0.053 0.068 0.060 DAR 0.172 0.088 0.083 0.097 0.091 0.108 DON 0.194 0.156 0.130 0.150 0.152 0.195 GLA 0.074 0.065 0.066 0.075 0.077 0.077 INV 0.172 0.136 0.151 0.132 0.147 0.195 LDS 0.103 0.061 0.053 0.058 0.054 0.051 LEI 0.144 0.070 0.068 0.087 0.084 0.080 MAN 0.126 0.048 0.056 0.062 0.065 0.067 rank 1 keeps 98.4% of the table's squared size 15 numbers rebuild 54 9 depot values + 6 day values late rates up to 6 September
Late rates by depot and weekday (amber, darker is later) are almost one depot effect times one weekday effect: rank 1 keeps 98.4% of the table, so 15 numbers rebuild 54 (green), Funk's idea at Kestrel's size.

Lateness is different. Late rates by depot and weekday form a 9 by 6 table. Its first singular value carries 98.4% of it: 9 + 6 = 15 numbers rebuild 54, like Funk's 20M for 8.5B.

BY THE NUMBERS What rank one misses: Mondays MONDAY LATE RATE MINUS ITS RANK-1 COPY DAR +0.032 MAN +0.032 LEI +0.027 LDS +0.020 BRS -0.009 BHM -0.013 DON -0.016 GLA -0.018 INV -0.028 red: the Monday backlog at four hubs
The rank-1 copy under-predicts Mondays at Dartford and Manchester (+0.032), Leicester (+0.027) and Leeds (+0.020) (red) and over-predicts the rest (blue): the leftover is the Monday backlog at four hubs.

The biggest misses are on Mondays: +0.032 at Dartford and Manchester, +0.027 at Leicester, +0.020 at Leeds. A rank-1 table cannot hold a Monday backlog at four hubs only. The leftover is where the structure is.

COMPARE NumPy runs here; MATLAB is named RUNS HERE NAMED ONLY Tool NumPy, np.linalg.svd MATLAB, svd Under the hood LAPACK gesdd not stated on the page Economy size full_matrices=False svd(A, "econ") Docs read NumPy v2.5 manual R2026b documentation In the course run, every number not run
The course runs NumPy, whose svd calls LAPACK; MATLAB's svd (grey, named only) offers the same economy option; both pages were read on 24 October 2026.

NumPy hands svd to LAPACK's gesdd routine. MathWorks' MATLAB has the same svd with an economy option (R2026b documentation, read 24 October 2026); named only, not run.

6. Norms and distances (Essential)

To call two depots alike you need a size for their difference, and the units decide it.

HOW IT WORKS Three sizes for one vector 4 (3, -4), drawn up L1 = 3 + 4 = 7 walk along the grid L2 = sqrt(9 + 16) = 5 the straight line L-infinity = 4 the largest single step
One vector, three sizes: (3, -4) is 7 along the grid (L1, amber), 5 in a straight line (L2, blue) and 4 by its largest part (L-infinity, green).

A norm measures a vector's size. For (3, -4): L1 adds the sizes, 7; L2 is the straight line, 5; L-infinity takes the largest, 4. A distance is the norm of a difference; L1 and L2 return in Module 3 as penalties.

COMPARE Which depot is Doncaster like? raw LEI 9.06 BHM 9.06 DAR 9.07 MAN 9.43 BRS 10.36 LDS 10.90 GLA 11.93 INV 34.46 DON to DAR: 87%: distance_km standardised DAR 1.60 LEI 1.63 MAN 1.64 BHM 1.64 BRS 1.66 LDS 1.67 GLA 2.11 INV 2.48 DON to DAR: 91%: driver_new
Raw distances from Doncaster are almost all kilometres (87% of the squared gap to Dartford); standardised, 91% is new drivers, and Dartford is nearest at 1.60 (green); Inverness is farthest either way (grey).

Which depot is Doncaster most like? Compare their average parcels. On raw numbers seven depots sit between 9.06 and 11.93 away, and 87% of the squared distance to Dartford is distance_km: kilometres are big numbers. After standardising (each column moved to mean 0, scaled to standard deviation 1) 91% of it is new drivers, 51% at Doncaster against 8%, a column raw distance ignored. Dartford is nearest at 1.60, Inverness farthest either way.

BREAK IT One unit change moves every distance training weeks mean weight_kg 2.71 from 7 September mean weight_kg 2727.5 scanner v4 sends grams: a distance using weight becomes a distance in weight alone
From 7 September weight_kg carries grams: its mean jumps from 2.71 to 2727.5 (red), so any norm or distance that uses it measures weight and nothing else.

Units can move every distance at once. From 7 September weight_kg carries grams: its mean jumps from 2.71 to 2727.5, and any distance using it would be a distance in weight alone.

7. Tensor operations and broadcasting (Essential)

Networks work on stacks of matrices, and NumPy and PyTorch stretch shapes silently. Know the rule.

HOW IT WORKS A tensor: depot, weekday, feature 9 depots down 8 features across 6 weekdays deep T: (9, 6, 8) mean over weekdays keepdims: (9, 1, 8) ready to subtract from T
Averaging the training parcels by depot, weekday and feature gives a (9, 6, 8) tensor (blue); the mean over weekdays with keepdims (amber) stays (9, 1, 8) so it lines up for subtraction.

A tensor is an array with any number of axes. Average Kestrel's training parcels by depot, weekday and feature and you get a (9, 6, 8) tensor. Averaging over weekdays with keepdims leaves (9, 1, 8), ready to subtract.

HOW IT WORKS Line shapes up from the right 86014 8 8 86014 8 8 matches 8 the missing axis is stretched 9 1 8 1 9 8 9 9 8 1 stretches to 9 every pair of depots
Broadcasting compares shapes from the right: sizes fit when equal or when one is 1, which is stretched; (86014, 8) minus (8,) and (9, 1, 8) minus (1, 9, 8) both work (green, the result).

Broadcasting lines shapes up from the right; two sizes fit if they are equal or one of them is 1, and a 1 is stretched (NumPy v2.5 manual, read 24 October 2026). (86014, 8) minus (8,) subtracts the column means from every row; (9, 1, 8) minus (1, 9, 8) gives all 81 depot pairs at once, (9, 9, 8). PyTorch applies the same rule.

Run it

The run took under ten seconds on the course laptop (24 October 2026) and uses only libraries Colab ships. The first cell makes the project folder.

python
!mkdir -p kestrel/maths

Then guide 06 blocks 1 to 6, 8 and 9, unchanged. The Module 1 lab will save them as kestrel/data.py; until then, paste them.

python
import math
import random
from datetime import date, timedelta

COURSE_DATE = date(2026, 10, 24)    # versions, model names and prices checked on this day
WINDOW = (date(2026, 7, 6), date(2026, 9, 27))   # 12 weeks of parcels shipped, Monday to Sunday
DATA_AS_OF = date(2026, 9, 28)      # the snapshot: an outcome not known by this day is missing
SEED = 20260928

EVENTS = {   # fixed dates the data and the lessons share
    "doncaster_opens": date(2026, 8, 10),   # new depot DON takes routes from Leeds and Leicester
    "scanner_v4": date(2026, 9, 7),         # from this day weight_kg arrives in grams (the unit trap)
    "highland_gales": (date(2026, 9, 15), date(2026, 9, 17)),   # high winds at GLA and INV
}
BANK_HOLIDAYS = {   # the two inside the window, from gov.uk/bank-holidays.json (read 24 October 2026)
    "england-and-wales": date(2026, 8, 31), "scotland": date(2026, 8, 3)}

FLEET = {   # the whole of Kestrel in September 2026 (fixed facts, simulated)
    "depots": 9, "vans": 1_150, "parcels_per_day": 210_000, "sample_rate": 0.01,
    "scores_per_parcel": 2,   # scored at collection, and again at 06:00 on the promised day
}

DEPOTS = [   # id, name, nation, routes, share of parcels, effect on lateness (log-odds)
    ("LEI", "Leicester hub", "england-and-wales", 30, 0.16, 0.00),
    ("BHM", "Birmingham", "england-and-wales", 26, 0.14, 0.05),
    ("MAN", "Manchester", "england-and-wales", 26, 0.14, 0.10),
    ("LDS", "Leeds", "england-and-wales", 22, 0.12, 0.00),
    ("BRS", "Bristol", "england-and-wales", 20, 0.10, -0.05),
    ("DAR", "Dartford", "england-and-wales", 32, 0.18, 0.20),
    ("GLA", "Glasgow", "scotland", 18, 0.10, 0.05),
    ("INV", "Inverness", "scotland", 10, 0.06, 0.25),
    ("DON", "Doncaster", "england-and-wales", 16, 0.00, 0.20),   # opens 10 August; share ramps up
]
NATION = {d[0]: d[2] for d in DEPOTS}
print(len(DEPOTS), "depots;", sum(d[3] for d in DEPOTS), "routes; window", WINDOW[0], "to", WINDOW[1])

Code walkthrough: guide 06 block 1, unchanged.

python
def make_routes(seed=SEED):
    """Each depot's routes: rural or urban, island or not, typical distance and stops."""
    rng = random.Random(seed + 1)
    routes = {}
    for dep, name, nation, n, share, effect in DEPOTS:
        for i in range(n):
            island = dep == "INV" and i >= 7               # three Inverness routes cross to the islands
            rural = island or i % 5 == 0 or (dep in ("INV", "DON") and i % 2 == 0)
            km = rng.uniform(80, 160) if island else rng.uniform(25, 60) if rural else rng.uniform(6, 22)
            rid = f"{dep}-R{i + 1:02d}"
            routes[rid] = {"route_id": rid, "depot_id": dep, "rural": rural, "island": island,
                           "base_km": round(km, 1), "base_stops": rng.randint(95, 135)}
    return routes


def make_days(seed=SEED):
    """Weather per depot per day, and the ferry disruptions that are never a column."""
    rng = random.Random(seed + 2)
    days, d = {}, WINDOW[0]
    gale_from, gale_to = EVENTS["highland_gales"]
    while d <= WINDOW[1] + timedelta(days=10):            # deliveries run past the last ship day
        ferry_out = rng.random() < 0.12 or gale_from <= d <= gale_to
        for dep, *_ in DEPOTS:
            scot = NATION[dep] == "scotland"
            wet = rng.random() < (0.55 if scot else 0.40)
            rain = rng.expovariate(1 / (3.5 if scot else 2.0)) if wet else 0.0
            wind = rng.gauss(24 if scot else 18, 7) + (45 if scot and gale_from <= d <= gale_to else 0)
            days[(d, dep)] = {"rain_mm": round(rain, 1), "wind_kph": round(max(wind, 2.0), 1),
                              "ferry_out": ferry_out and dep == "INV"}
        d += timedelta(days=1)
    return days


ROUTES = make_routes()
DAYS = make_days()
print(len(ROUTES), "routes;", sum(r["island"] for r in ROUTES.values()), "island routes;",
      len(DAYS), "depot-days;", sum(v["ferry_out"] for v in DAYS.values()), "ferry disruption days at INV")

Code walkthrough: guide 06 block 2, unchanged.

python
DOW = [1.15, 1.05, 1.00, 1.00, 1.10, 0.55, 0.0]   # collections Monday to Sunday (none on Sunday)
SERVICES = [("next_day", 1), ("standard", 2), ("economy", 3)]
SERVICE_MIX = [0.45, 0.40, 0.15]


def shares_on(d):
    """Share of the day's parcels per depot. Doncaster ramps up over 21 days from 10 August."""
    s = {dep: share for dep, _, _, _, share, _ in DEPOTS}
    if d >= EVENTS["doncaster_opens"]:
        ramp = min(1.0, (d - EVENTS["doncaster_opens"]).days / 21)
        s["DON"], s["LDS"], s["LEI"] = 0.08 * ramp, s["LDS"] - 0.05 * ramp, s["LEI"] - 0.03 * ramp
    return s


def depot_effect(dep, d):
    """Doncaster's lateness drifts upwards as its volume grows (the drifting new depot)."""
    base = {x[0]: x[5] for x in DEPOTS}[dep]
    if dep == "DON":
        base += 0.9 * min(1.0, (d - EVENTS["doncaster_opens"]).days / 42)
    return base


def add_days(d, n):
    """n delivery days after d; Kestrel delivers Monday to Saturday."""
    while n:
        d += timedelta(days=1)
        n -= d.weekday() != 6
    return d


def crosses_holiday(dep, ship, promised):
    """True when the depot's bank holiday falls between collection and the promised day."""
    return ship <= BANK_HOLIDAYS[NATION[dep]] <= promised


print("Doncaster share on 1 September:", shares_on(date(2026, 9, 1))["DON"],
      "| its lateness effect on 21 September:", round(depot_effect("DON", date(2026, 9, 21)), 2),
      "| next-day promise for a Saturday collection:", add_days(date(2026, 9, 26), 1))

Code walkthrough: guide 06 block 3, unchanged.

python
def parcel_row(rng, n, ship, route):
    """The features Kestrel knows when a parcel is collected, plus its true weight."""
    dep = route["depot_id"]
    service, sdays = rng.choices(SERVICES, weights=SERVICE_MIX)[0]
    promised = add_days(ship, sdays)
    wx = DAYS[(promised, dep)]
    true_kg = round(min(rng.lognormvariate(0.6, 0.9), 30.0), 2)
    return {
        "parcel_id": f"KP{n:07d}", "ship_date": ship.isoformat(), "promised_date": promised.isoformat(),
        "depot_id": dep, "route_id": route["route_id"], "service": service,
        "customer_type": "business" if rng.random() < 0.3 else "residential",
        "ship_hour": rng.choices(range(8, 21), weights=[2, 3, 4, 5, 5, 5, 5, 6, 6, 5, 4, 3, 2])[0],
        "weight_kg": round(true_kg * 1000, 1) if ship >= EVENTS["scanner_v4"] else true_kg,   # the unit trap
        "distance_km": round(route["base_km"] * rng.uniform(0.6, 1.4), 1),
        "stops_on_route": route["base_stops"] + rng.randint(-15, 25),
        "address_quality": round(rng.betavariate(8, 1.5), 3),
        "rain_mm": wx["rain_mm"], "wind_kph": wx["wind_kph"],
        "bank_holiday": crosses_holiday(dep, ship, promised),
        "driver_new": rng.random() < (0.55 if dep == "DON" else 0.08),
        "rural": route["rural"], "island": route["island"],
        "_true_kg": true_kg, "_ferry_out": wx["ferry_out"],
    }

Code walkthrough: guide 06 block 4, unchanged.

python
def late_logit(p, ship):
    """Log-odds that a parcel misses its promised day. Weight counts in kilograms, whatever the column says."""
    dep, kg, km, nd = p["depot_id"], p["_true_kg"], p["distance_km"], p["service"] == "next_day"
    z = -4.24 + depot_effect(dep, ship) + 0.45 * p["driver_new"] + 0.3 * nd
    z += 0.2 * (p["ship_hour"] >= 17) + (1.3 if nd and p["ship_hour"] >= 18 else 0.0)   # the 18:00 trunk cut-off
    z += 1.5 * (p["address_quality"] < 0.72)                                       # an address a driver cannot find
    z += 0.9 * (kg > 15) + (0.7 if p["rural"] and km > 45 else 0.0)                # two-person parcels, long rural legs
    z += (1.6 if p["rural"] else 0.5) * (1 - math.exp(-p["rain_mm"] / 3))          # rain: quick to hurt, then levels off
    z += 0.08 * max(0, p["stops_on_route"] - 125)                                  # only an overloaded route slows
    z += 0.06 * max(0.0, p["wind_kph"] - 35) * (2.5 if p["island"] else 1.0)       # wind matters most at sea
    z += 0.8 * (ship.weekday() == 0 and dep in ("DAR", "MAN", "LEI", "LDS"))       # Monday backlog at four hubs
    z += p["bank_holiday"] * (0.3 + 0.025 * min(km, 60))                           # a holiday hurts long routes most
    return z + (2.2 if p["_ferry_out"] and p["island"] else 0.0)                   # ferries: a cause with no column


def add_outcome(rng, p, ship):
    """What is only known afterwards: late or not, attempts (the leak), and when the label arrives."""
    late = rng.random() < 1 / (1 + math.exp(-late_logit(p, ship)))
    promised = date.fromisoformat(p["promised_date"])
    delivered = add_days(promised, (1 + (rng.random() < 0.3)) if late else 0)
    label_ready = delivered + timedelta(days=1)            # delivery scans are reconciled overnight
    known = label_ready <= DATA_AS_OF
    p["attempts"] = (1 + (rng.random() < (0.55 if late else 0.04)) + (late and rng.random() < 0.2)) if known else None
    p["delivered_date"] = delivered.isoformat() if known else None
    p["late"] = int(late) if known else None
    p["label_ready_date"] = label_ready.isoformat()
    for k in ("_true_kg", "_ferry_out"):
        del p[k]
    return p

Code walkthrough: guide 06 block 5, unchanged.

python
def make_parcels(seed=SEED):
    """Every sampled parcel shipped in the window, in ship-date order."""
    rng = random.Random(seed + 3)
    by_depot = {}
    for r in ROUTES.values():
        by_depot.setdefault(r["depot_id"], []).append(r)
    rows, n, d = [], 1, WINDOW[0]
    while d <= WINDOW[1]:
        per_day = round(FLEET["parcels_per_day"] * FLEET["sample_rate"] * DOW[d.weekday()])
        shares = shares_on(d)
        for dep, share in shares.items():
            for _ in range(round(per_day * share)):
                p = parcel_row(rng, n, d, rng.choice(by_depot[dep]))
                rows.append(add_outcome(rng, p, d))
                n += 1
        d += timedelta(days=1)
    return rows


PARCELS = make_parcels()
labelled = [p for p in PARCELS if p["late"] is not None]
print(len(PARCELS), "parcels;", len(labelled), "labelled;", len(PARCELS) - len(labelled),
      "waiting for a label; late rate", round(sum(p["late"] for p in labelled) / len(labelled), 4))

Code walkthrough: guide 06 block 6, unchanged.

python
import hashlib
import json


def fingerprint(*datasets):
    """The first 12 hex digits of a SHA-256 hash of the data: equal fingerprints mean identical data."""
    blob = json.dumps(datasets, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
    return hashlib.sha256(blob).hexdigest()[:12]


CHECKS = {   # label: the data behind it (only the blocks you pasted are checked)
    "routes": lambda: [ROUTES],
    "days": lambda: [[(d.isoformat(), dep, v) for (d, dep), v in DAYS.items()]],
    "parcels": lambda: [PARCELS],
    "messages": lambda: [MESSAGES],
    "all": lambda: [DEPOTS, FLEET, ROUTES, PARCELS, MESSAGES],
}
for label, data in CHECKS.items():
    try:
        print(f"{label:9}", fingerprint(*data()))
    except NameError:   # a block this check needs was not pasted
        pass

Code walkthrough: guide 06 block 8, unchanged.

python
import pandas as pd

df = pd.DataFrame(PARCELS)
for c in ("ship_date", "promised_date", "delivered_date", "label_ready_date"):
    df[c] = pd.to_datetime(df[c])
print(df.shape[0], "rows,", df.shape[1], "columns:", ", ".join(df.columns))

Code walkthrough: guide 06 block 9, unchanged.

The file

kestrel/maths/linalg.py is written in three cells before the first import: Python reads a module once per session, so a function appended later would not be seen.

python
%%writefile kestrel/maths/linalg.py
"""Linear algebra for Kestrel, in NumPy: parcels as vectors, the table as a matrix, and what we do with them."""
import numpy as np

NUMERIC = ["ship_hour", "weight_kg", "distance_km", "stops_on_route",
           "address_quality", "rain_mm", "wind_kph", "driver_new"]   # 8 numbers known at collection


def as_matrix(df, cols=NUMERIC):
    """The parcel table as a float matrix X: one row per parcel, one column per feature."""
    return df[cols].astype(float).to_numpy()


def score(X, w, b=0.0):
    """One number per parcel: each row of X dotted with the weights w, plus the intercept b."""
    assert X.shape[-1] == w.shape[0], f"X has {X.shape[-1]} columns but w has {w.shape[0]} weights"
    return X @ w + b


def fit_least_squares(X, y):
    """Weights that make X @ w + b as close as possible to y (the sum of squared gaps is smallest)."""
    A = np.column_stack([np.ones(len(X)), X])        # a column of ones carries the intercept
    coef, *_ = np.linalg.lstsq(A, y, rcond=None)
    return coef[1:], coef[0]


def unit(v):
    return v / np.linalg.norm(v)


def cosine(a, b):
    """The dot product of two directions: 1 same way, 0 at right angles, -1 opposite."""
    return float(unit(a) @ unit(b))


def project(a, onto):
    """The shadow of a on the line through `onto`: (a . u) u, with u the unit vector of `onto`."""
    u = unit(onto)
    return (a @ u) * u

Code walkthrough: NUMERIC fixes the eight columns and their order; as_matrix makes the float matrix. score asserts the shapes, then computes X @ w + b. fit_least_squares adds a column of ones for b and calls np.linalg.lstsq. unit divides by the L2 length; cosine and project build on it.

python
%%writefile -a kestrel/maths/linalg.py


def standardise(X, mean=None, sd=None):
    """Each column moved to mean 0 and scaled to standard deviation 1 (mean and sd from training rows)."""
    mean = X.mean(axis=0) if mean is None else mean
    sd = X.std(axis=0) if sd is None else sd
    return (X - mean) / sd, mean, sd


def power_iteration(C, steps=200, seed=0):
    """The top eigenvector of a symmetric matrix C: multiply by C, rescale, repeat."""
    v = np.random.default_rng(seed).normal(size=C.shape[0])
    for _ in range(steps):
        v = unit(C @ v)
    return float(v @ C @ v), v                      # the eigenvalue, then the eigenvector


def pca_svd(Z, k):
    """PCA by SVD of the centred matrix Z: (directions, share of variance per direction, coordinates)."""
    U, s, Vt = np.linalg.svd(Z, full_matrices=False)    # Z = U diag(s) Vt
    share = s**2 / (s**2).sum()
    return Vt[:k], share, Z @ Vt[:k].T


def low_rank(M, k):
    """The best rank-k copy of M: keep the k largest singular values, drop the rest."""
    U, s, Vt = np.linalg.svd(M, full_matrices=False)
    return U[:, :k] * s[:k] @ Vt[:k]

Code walkthrough: standardise also returns the mean and standard deviation, to scale later rows the same way. power_iteration multiplies and rescales 200 times, then reads λ as v·Cv. pca_svd returns the top k directions, every variance share and the coordinates; low_rank keeps the k largest singular values.

python
%%writefile -a kestrel/maths/linalg.py


def norms(v):
    """Three ways to measure a vector's size: L1 (sum of sizes), L2 (straight line), L-infinity (largest)."""
    return {"l1": float(np.abs(v).sum()), "l2": float(np.sqrt(v @ v)), "linf": float(np.abs(v).max())}


def pairwise_distances(P):
    """Euclidean distance between every pair of rows of P, by broadcasting (n, 1, d) against (1, n, d)."""
    diff = P[:, None, :] - P[None, :, :]
    return np.sqrt((diff**2).sum(axis=-1))


def scale_by_depot(X, depot_ids, depots, means, sds):
    """Standardise each parcel with its own depot's mean and sd: (n, d) minus (n, d) rows picked by depot."""
    idx = np.searchsorted(depots, depot_ids)       # each parcel's row in the (9, d) tables
    return (X - means[idx]) / sds[idx]

Code walkthrough: pairwise_distances makes (n, 1, d) and (1, n, d) so broadcasting forms every difference. scale_by_depot finds each parcel's depot row with np.searchsorted (the list is sorted).

Vectors, matrices and the product

python
import numpy as np
from kestrel.maths.linalg import NUMERIC, as_matrix

train = df[df["late"].notna() & (df["ship_date"] < "2026-08-24")]    # the training weeks (course-guide/03)
X, y = as_matrix(train), train["late"].to_numpy(dtype=float)
kp = as_matrix(df[df["parcel_id"] == "KP0104204"])[0]               # one parcel: a vector of 8 numbers
print("X", X.shape, "| y", y.shape, "| KP0104204", kp.shape, dict(zip(NUMERIC, kp.tolist())))
print("first 3 rows of X:\n", X[:3])
print("X transposed", X.T.shape, "| one column (distance_km) mean", round(X[:, 2].mean(), 2))
print("kp + kp", (kp + kp)[:3], "| 0.5 * kp", (0.5 * kp)[:3], "| bytes in X:", X.nbytes)

Code walkthrough: train is the labelled parcels shipped before 24 August; kp is KP0104204's row, [0] taking it out of a one-row matrix.

text
X (86014, 8) | y (86014,) | KP0104204 (8,) {'ship_hour': 18.0, 'weight_kg': 4.51, 'distance_km': 23.3, 'stops_on_route': 143.0, 'address_quality': 0.578, 'rain_mm': 0.3, 'wind_kph': 19.7, 'driver_new': 0.0}
first 3 rows of X:
 [[ 14.      1.31   14.1   100.      0.929   0.      7.8     0.   ]
 [ 13.      3.2    16.7   125.      0.875   1.     19.1     0.   ]
 [ 16.      0.69   17.2   155.      0.821   1.     19.1     0.   ]]
X transposed (8, 86014) | one column (distance_km) mean 22.98
kp + kp [36.    9.02 46.6 ] | 0.5 * kp [ 9.     2.255 11.65 ] | bytes in X: 5504896
python
from kestrel.maths.linalg import fit_least_squares, score

w, b = fit_least_squares(X, y)
print("w", w.shape, dict(zip(NUMERIC, w.round(4).tolist())), "| b", round(b, 4))
parts = kp * w                                       # the dot product, one term at a time
for name, x_i, w_i, p in zip(NUMERIC, kp, w, parts):
    print(f"{name:16} {x_i:8.3f} x {w_i:8.4f} = {p:8.4f}")
print("sum of terms + b:", round(parts.sum() + b, 4), "| kp @ w + b:", round(score(kp, w, b), 4))
s = score(X, w, b)
print("score(X, w):", X.shape, "@", w.shape, "->", s.shape, "| mean score", round(s.mean(), 4), "| late rate", round(y.mean(), 4))
W3 = np.column_stack([w, np.ones(8) / 8, np.eye(8)[2]])   # three scorers at once: (8, 3)
print("X @ W3:", X.shape, "@", W3.shape, "->", (X @ W3).shape)

Code walkthrough: kp * w multiplies position by position; the sum plus b equals score. W3 stacks three weight vectors (fitted, an equal average, one picking distance_km) as columns.

text
w (8,) {'ship_hour': 0.0054, 'weight_kg': 0.0009, 'distance_km': 0.0014, 'stops_on_route': 0.0025, 'address_quality': -0.311, 'rain_mm': 0.0104, 'wind_kph': -0.0001, 'driver_new': 0.0288} | b -0.0723
ship_hour          18.000 x   0.0054 =   0.0965
weight_kg           4.510 x   0.0009 =   0.0039
distance_km        23.300 x   0.0014 =   0.0331
stops_on_route    143.000 x   0.0025 =   0.3557
address_quality     0.578 x  -0.3110 =  -0.1797
rain_mm             0.300 x   0.0104 =   0.0031
wind_kph           19.700 x  -0.0001 =  -0.0029
driver_new          0.000 x   0.0288 =   0.0000
sum of terms + b: 0.2374 | kp @ w + b: 0.2374
score(X, w): (86014, 8) @ (8,) -> (86014,) | mean score 0.0805 | late rate 0.0805
X @ W3: (86014, 8) @ (8, 3) -> (86014, 3)

Projections

python
from kestrel.maths.linalg import cosine, project

A = np.column_stack([np.ones(len(X)), X])
residual = y - score(X, w, b)                        # what least squares could not explain
print("residual shape", residual.shape, "| largest |A.T @ residual|:", f"{np.abs(A.T @ residual).max():.1e}",
      "| largest |A.T @ y|:", round(np.abs(A.T @ y).max(), 1))
a, c = np.array([3.0, 4.0]), np.array([1.0, 0.0])
print("by hand: a =", a, "onto", c, "->", project(a, c), "| cosine", round(cosine(a, c), 2))
other = as_matrix(df[df["parcel_id"] == "KP0104205"])[0]
print("KP0104205", dict(zip(NUMERIC, other.tolist())))
print("cosine(KP0104204, KP0104205) raw:", round(cosine(kp, other), 4))

Code walkthrough: A.T @ residual is each column's dot product with the residual. The two small vectors repeat the hand sum; other is KP0104205.

text
residual shape (86014,) | largest |A.T @ residual|: 1.3e-08 | largest |A.T @ y|: 883819.0
by hand: a = [3. 4.] onto [1. 0.] -> [3. 0.] | cosine 0.6
KP0104205 {'ship_hour': 19.0, 'weight_kg': 1.8, 'distance_km': 10.2, 'stops_on_route': 131.0, 'address_quality': 0.695, 'rain_mm': 2.6, 'wind_kph': 6.0, 'driver_new': 0.0}
cosine(KP0104204, KP0104205) raw: 0.9921

Eigenvectors, SVD and PCA

python
from kestrel.maths.linalg import power_iteration, standardise

Z, mean, sd = standardise(X)
C = Z.T @ Z / (len(Z) - 1)                          # the covariance matrix of standardised columns
print("C", C.shape, "| symmetric:", np.allclose(C, C.T), "| corr(distance_km, rain_mm):", round(C[2, 5], 3))
values, vectors = np.linalg.eigh(C)                 # eigh: for symmetric matrices, ascending order
values, vectors = values[::-1], vectors[:, ::-1]
print("eigenvalues:", values.round(3), "| sum", round(values.sum(), 3))
lam, v = power_iteration(C)
print("power iteration:", round(lam, 4), "| eigh:", round(values[0], 4), "| same direction:", round(abs(v @ vectors[:, 0]), 6))
print("C @ v equals lambda * v:", np.allclose(C @ v, lam * v, atol=1e-6))
print("top eigenvector:", dict(zip(NUMERIC, vectors[:, 0].round(2).tolist())))

Code walkthrough: C is the covariance of the standardised matrix Z. eigh returns the smallest first, so both results are reversed; power iteration is checked by value, direction and C @ v.

text
C (8, 8) | symmetric: True | corr(distance_km, rain_mm): 0.085
eigenvalues: [1.184 1.025 1.008 1.001 0.997 0.994 0.915 0.876] | sum 8.0
power iteration: 1.1838 | eigh: 1.1838 | same direction: 1.0
C @ v equals lambda * v: True
top eigenvector: {'ship_hour': -0.01, 'weight_kg': 0.0, 'distance_km': -0.51, 'stops_on_route': 0.01, 'address_quality': 0.0, 'rain_mm': -0.62, 'wind_kph': -0.6, 'driver_new': -0.0}
python
from sklearn.decomposition import PCA
from kestrel.maths.linalg import low_rank, pca_svd

dirs, share, coords = pca_svd(Z, k=2)
print("variance share per direction:", share.round(3), "| first two:", round(share[:2].sum(), 3))
print("directions needed for 90%:", int(np.searchsorted(share.cumsum(), 0.90) + 1), "of", len(share))
print("SVD and eigh agree:", np.allclose(share, values / values.sum()))
print("scikit-learn agrees:", np.allclose(PCA(2).fit(Z).explained_variance_ratio_, share[:2]))
kz = (kp - mean) / sd
print("KP0104204 standardised:", kz.round(2), "| on the first two directions:", (kz @ dirs.T).round(2))

labelled = df[df["late"].notna() & (df["ship_date"] < "2026-09-07")]
M = labelled.pivot_table(index="depot_id", columns=labelled["ship_date"].dt.weekday, values="late", aggfunc="mean")
s_M = np.linalg.svd(M.to_numpy(), compute_uv=False)
print("depot x weekday late rates", M.shape, "| singular values", s_M.round(3), "| rank-1 share", round(s_M[0]**2 / (s_M**2).sum(), 3))
gap = M.to_numpy() - low_rank(M.to_numpy(), 1)
print("rank 1 keeps", sum(M.shape), "numbers of", M.size, "| biggest miss", round(np.abs(gap).max(), 3),
      "at", M.index[np.abs(gap).max(axis=1).argmax()], "on weekday", int(np.abs(gap).max(axis=0).argmax()))
print("Monday misses by depot:", dict(zip(M.index, gap[:, 0].round(3).tolist())))

Code walkthrough: searchsorted on the running total finds how many shares reach 90%. kz is KP0104204 standardised with the training mean. M is the depot by weekday table up to 6 September; gap is what rank 1 misses.

text
variance share per direction: [0.148 0.128 0.126 0.125 0.125 0.124 0.114 0.11 ] | first two: 0.276
directions needed for 90%: 8 of 8
SVD and eigh agree: True
scikit-learn agrees: True
KP0104204 standardised: [ 1.23  0.62  0.02  1.45 -2.34 -0.33  0.06 -0.3 ] | on the first two directions: [-0.15  1.26]
depot x weekday late rates (9, 6) | singular values [0.75  0.084 0.035 0.025 0.015 0.007] | rank-1 share 0.984
rank 1 keeps 15 numbers of 54 | biggest miss 0.032 at DAR on weekday 0
Monday misses by depot: {'BHM': -0.013, 'BRS': -0.009, 'DAR': 0.032, 'DON': -0.016, 'GLA': -0.018, 'INV': -0.028, 'LDS': 0.02, 'LEI': 0.027, 'MAN': 0.032}

Norms, distances and broadcasting

python
import pandas as pd
from kestrel.maths.linalg import norms, pairwise_distances

print("norms of (3, -4):", norms(np.array([3.0, -4.0])))
print("KP0104204 raw:", {k: round(v, 1) for k, v in norms(kp).items()}, "| standardised:", {k: round(v, 2) for k, v in norms(kz).items()})
print("cosine(KP0104204, KP0104205) standardised:", round(cosine(kz, (other - mean) / sd), 3))
depots = sorted(train["depot_id"].unique())
means = np.vstack([X[train["depot_id"].to_numpy() == d].mean(axis=0) for d in depots])   # (9, 8)
for name, P in (("raw", means), ("standardised", (means - mean) / sd)):
    D = pd.DataFrame(pairwise_distances(P), index=depots, columns=depots)
    near = D["DON"].drop("DON").sort_values()
    print(f"{name:12} distance from Doncaster:", near.round(2).to_dict())
    sq = (P[depots.index("DON")] - P[depots.index("DAR")])**2          # Doncaster to Dartford, per feature
    print(f"{'':12} share of DON-DAR squared distance:", dict(zip(NUMERIC, (sq / sq.sum()).round(3).tolist())))
print("share of new drivers: DON", round(means[depots.index("DON"), 7], 2), "| DAR", round(means[depots.index("DAR"), 7], 2))
sept = df[df["ship_date"] >= "2026-09-07"]
print("weight_kg mean, training weeks:", round(X[:, 1].mean(), 2), "| from 7 September:", round(sept["weight_kg"].mean(), 1))

Code walkthrough: means stacks the nine depots' average parcels, (9, 8). The loop measures every depot pair on raw and standardised averages and splits the Doncaster to Dartford gap by feature; column 7 is the share of new drivers.

text
norms of (3, -4): {'l1': 7.0, 'l2': 5.0, 'linf': 4.0}
KP0104204 raw: {'l1': 209.4, 'l2': 147.4, 'linf': 143.0} | standardised: {'l1': 6.33, 'l2': 3.11, 'linf': 2.34}
cosine(KP0104204, KP0104205) standardised: 0.573
raw          distance from Doncaster: {'LEI': 9.06, 'BHM': 9.06, 'DAR': 9.07, 'MAN': 9.43, 'BRS': 10.36, 'LDS': 10.9, 'GLA': 11.93, 'INV': 34.46}
             share of DON-DAR squared distance: {'ship_hour': 0.0, 'weight_kg': 0.0, 'distance_km': 0.87, 'stops_on_route': 0.127, 'address_quality': 0.0, 'rain_mm': 0.001, 'wind_kph': 0.0, 'driver_new': 0.002}
standardised distance from Doncaster: {'DAR': 1.6, 'LEI': 1.63, 'MAN': 1.64, 'BHM': 1.64, 'BRS': 1.66, 'LDS': 1.67, 'GLA': 2.11, 'INV': 2.48}
             share of DON-DAR squared distance: {'ship_hour': 0.001, 'weight_kg': 0.0, 'distance_km': 0.069, 'stops_on_route': 0.014, 'address_quality': 0.001, 'rain_mm': 0.006, 'wind_kph': 0.0, 'driver_new': 0.908}
share of new drivers: DON 0.51 | DAR 0.08
weight_kg mean, training weeks: 2.71 | from 7 September: 2727.5
python
import torch
from kestrel.maths.linalg import scale_by_depot

dep = train["depot_id"].to_numpy()
sds = np.vstack([X[dep == d].std(axis=0) for d in depots])
Zd = scale_by_depot(X, dep, np.array(depots), means, sds)
print("per-depot scaled:", Zd.shape, "| KP0104204 distance_km within Dartford:", round(((kp - means[depots.index("DAR")]) / sds[depots.index("DAR")])[2], 2))
print("shapes broadcast:", X.shape, "-", mean.shape, "->", (X - mean).shape, "|", means[:, None, :].shape, "-", means[None, :, :].shape, "->", (means[:, None, :] - means[None, :, :]).shape)
wk = train["ship_date"].dt.weekday.to_numpy()
T = np.stack([[X[(dep == d) & (wk == k)].mean(axis=0) for k in range(6)] for d in depots])   # a 3-D tensor
print("T (depot, weekday, feature):", T.shape, "| keepdims mean:", T.mean(axis=1, keepdims=True).shape,
      "| centred:", (T - T.mean(axis=1, keepdims=True)).shape)
t = torch.tensor(X[:4])
print("PyTorch, same rule:", tuple((t - t.mean(dim=0)).shape), "| torch.ones(4, 1) + torch.ones(4):", tuple((torch.ones(4, 1) + torch.ones(4)).shape))

Code walkthrough: sds and scale_by_depot scale each parcel against its own depot. T is (9, 6, 8); keepdims=True keeps the averaged axis as size 1. The last line repeats the rule in PyTorch.

text
per-depot scaled: (86014, 8) | KP0104204 distance_km within Dartford: 0.19
shapes broadcast: (86014, 8) - (8,) -> (86014, 8) | (9, 1, 8) - (1, 9, 8) -> (9, 9, 8)
T (depot, weekday, feature): (9, 6, 8) | keepdims mean: (9, 1, 8) | centred: (9, 6, 8)
PyTorch, same rule: (4, 8) | torch.ones(4, 1) + torch.ones(4): (4, 4)

Break it: a column minus a row

BREAK IT A column minus a row y0 y1 y2 y3 y4 p0 p1 p2 p3 p4 (5, 1) (5,) y - p becomes (5, 5); dark cells are the 5 pairs you meant MSE 0.186 wrong, no error raised MSE 0.138 with y.ravel() full size: 55.1 GiB
A (5, 1) label column minus five predictions broadcasts into a 5 by 5 table (red) whose diagonal holds the 5 pairs you meant: no error, a wrong mean squared error of 0.186 instead of 0.138 (green, the right one), and 55.1 GiB at full size.

A label column often arrives as shape (n, 1), and predictions as (n,). Subtract them.

python
small = train.head(5)
y5 = small["late"].to_numpy(dtype=float).reshape(-1, 1)     # a column, shape (5, 1), as many loaders return it
p5 = score(as_matrix(small), w, b)                           # predictions, shape (5,)
err = y5 - p5                                                 # no error raised
print("y5", y5.shape, "- p5", p5.shape, "->", err.shape)
print("mean squared error, wrong:", round(float((err**2).mean()), 5), "| right:", round(float(((y5.ravel() - p5)**2).mean()), 5))
print("on all", len(X), "training parcels the wrong one would need", f"{len(X)**2 * 8 / 2**30:.1f}", "GiB")

Code walkthrough: reshape(-1, 1) makes the labels a column, so y5 - p5 broadcasts to (5, 5); ravel() flattens it back.

Run this and you will see:

text
y5 (5, 1) - p5 (5,) -> (5, 5)
mean squared error, wrong: 0.18644 | right: 0.13799
on all 86014 training parcels the wrong one would need 55.1 GiB

Broadcasting builds a 5 by 5 table of every label minus every prediction. Nothing fails; the error is wrong, 0.186 instead of 0.138, and on all training parcels the line asks for 55.1 GiB. Use y.ravel(), or assert shapes before every loss.

In production

The assert inside score is the habit that scales: check shapes where data enters a function, not after a number looks odd. Serving teams put the same check in the request schema (Module 9) and the data contract (Module 5), which also catches Tomasz's grams. In Module 3 the scaling becomes a pipeline step fitted on training rows only; in Module 4 the same @ runs in every layer of a network.

The question Adaeze will ask

"Your least-squares score for KP0104204 is 0.237. Is that a probability?"

No. It is a dot product plus an intercept, free to fall below 0 or rise above 1; its training average matches the late rate (0.0805) only because least squares with an intercept always does. Lesson 2 passes the dot product through a sigmoid and trains logistic regression, which gives probabilities.

What you added to Kestrel

  • kestrel/maths/linalg.py: parcels as a matrix, scoring with shape checks, least squares, cosine and projection.
  • Standardising, power iteration, PCA by SVD and the best low-rank copy of a table.
  • Norms, pairwise distances and a per-depot scaler, all by broadcasting.

Check yourself

  1. X is (86014, 8) and you multiply it by a weight matrix of shape (3, 8). What happens, and what should the shape be?
  2. PCA on the eight features needed all eight directions for 90% of the variance. What does that say about the features?
  3. Raw and standardised distances from Doncaster are driven by different columns. Which, and why?
  1. It fails: the inner sizes are 8 and 3. Use the transpose, shape (8, 3), to get (86014, 3).
  2. They are nearly uncorrelated, so no small set of directions summarises them; compressing them would lose information.
  3. Raw: distance_km, because kilometres are the biggest numbers. Standardised: driver_new, because measured in standard deviations Doncaster's 51% new drivers is the largest difference.

Exercise

Standardising gives every column variance 1 but leaves them correlated. Whitening goes further: rotate onto the principal directions and divide each by its spread, so the covariance becomes the identity matrix. Write whiten(X) with NumPy's SVD, then run the check.

python
from kestrel.maths.linalg import standardise


def check_whiten(fn):
    """Checks your whitening on the training matrix: right shape, mean 0, covariance the identity."""
    W = fn(X)
    assert W.shape == X.shape, f"want shape {X.shape}, got {W.shape}"
    assert np.allclose(W.mean(axis=0), 0, atol=1e-8), "every column should have mean 0"
    cov = W.T @ W / (len(W) - 1)
    assert np.allclose(cov, np.eye(X.shape[1]), atol=1e-6), "covariance should be the identity"
    assert not np.allclose(W, standardise(X)[0]), "standardising alone is not enough: the columns are still correlated"
    print("all checks passed: covariance is the 8 x 8 identity; largest off-diagonal", f"{np.abs(cov - np.eye(8)).max():.1e}")

Code walkthrough: The check asserts the shape, zero means, an identity covariance, and that the answer is not plain standardising.

python
def whiten(X):
    """Centre, rotate onto the principal directions, and divide each by its spread."""
    Xc = X - X.mean(axis=0)
    U, s, Vt = np.linalg.svd(Xc, full_matrices=False)
    return Xc @ Vt.T / (s / np.sqrt(len(X) - 1))


check_whiten(whiten)

Code walkthrough: Xc @ Vt.T rotates the centred rows onto the principal directions; dividing by s / sqrt(n - 1) gives each one standard deviation 1.

text
all checks passed: covariance is the 8 x 8 identity; largest off-diagonal 3.9e-14

Key terms

TermMeaning
VectorAn ordered list of numbers, such as one parcel's features
MatrixA table of numbers; rows are parcels, columns are features
Matrix productRows combined with columns; inner sizes must match
Dot productMultiply matching positions and add
ProjectionA vector's shadow on a line or a space
EigenvectorA direction a matrix only stretches, by its eigenvalue
SVDAny matrix as directions, spreads and positions: U Σ Vᵀ
PCAThe SVD of centred, scaled data; directions of most variance
NormA vector's size: L1, L2 or L-infinity
BroadcastingStretching size-1 axes so shapes fit, without copying

Sources

  • Simon Funk, "Netflix Update: Try This at Home", 11 December 2006 (read 24 October 2026). https://sifter.org/~simon/journal/20061211.html
  • Harald Steck, Chaitanya Ekanadham, Nathan Kallus, "Is Cosine-Similarity of Embeddings Really About Similarity?", arXiv 2403.05440, 2024. https://arxiv.org/abs/2403.05440
  • Sergey Brin, Lawrence Page, "The Anatomy of a Large-Scale Hypertextual Web Search Engine", 1998 (read from a mirror of the Stanford page). https://gwern.net/doc/www/infolab.stanford.edu/6f7c60d7e3beb371d089d6b926d4f523a4bdcd5c.html
  • NumPy v2.5 manual, "Broadcasting" and numpy.linalg.svd, read 24 October 2026. https://numpy.org/doc/stable/user/basics.broadcasting.html and https://numpy.org/doc/stable/reference/generated/numpy.linalg.svd.html
  • MathWorks, MATLAB svd, R2026b documentation, read 24 October 2026. https://www.mathworks.com/help/matlab/ref/double.svd.html
  • The roadmap, "AI / ML Engineer Roadmap" (course copy, read 24 October 2026): track framing and the Mathematical Foundations introduction.