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

Lesson 2: Walking downhill: gradients and how models learn

29 min read·
Module 1 · Lesson 2 · About 50 minutes · Needs: Lesson 1 (vectors, the matrix product, eigenvalues), and the Kestrel data (course-guide/06). No model calls, no keys.

The situation

Week 1 at Kestrel, still. Lesson 1 scored KP0104204 at 0.237 with least squares, and Adaeze pointed out it is not a probability. The standard fix is logistic regression, which every library trains in one call. Her next request: "Train it yourself, from the gradient up, and show me it lands where the library lands."

One parcel runs through the lesson again: KP0104204 (simulated), Dartford, 2 September 2026, 18:00, 143 stops, address quality 0.578, 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
This lesson builds the loop inside Kestrel's training step (blue): a loss, its gradient and the step downhill; the rest of the system stays grey.

A real case, first

Gabriel Goh's "Why Momentum Really Works" (Distill, 4 April 2017) opens with the familiar picture: "gradient descent is a man walking down a hill". Then it does the sums. On a bowl-shaped problem, each step shrinks the error along each curvature direction by 1 - αλ, where α is the step size and λ the curvature, so steps only settle when 0 < αλ < 2.

The trouble is pathological curvature: "regions of f which aren't scaled properly", where "progress along certain directions grind to a halt". With the best step size the error shrinks by (κ - 1)/(κ + 1) per step, where κ is the largest curvature over the smallest. Momentum, a running sum of past steps, "allows a larger range of step-sizes to be used, and creates its own oscillations"; tuned, its rate is (√κ - 1)/(√κ + 1), which "essentially square rooted the condition number". Goh suggests β = 0.99, "sometimes 0.999, if things are really bad".

CASE STUDY Why momentum really works A BADLY SCALED VALLEY plain zigzags momentum glides plain: (κ - 1) / (κ + 1) error shrink per step, best step size momentum: (√κ - 1) / (√κ + 1) "square rooted the condition number" stable while 0 < αλ < 2 momentum widens it to 2 + 2β
Goh's sums: in a badly scaled valley plain gradient descent zigzags (red) and shrinks the error by (κ - 1)/(κ + 1) per step at best, while tuned momentum (green) gets (√κ - 1)/(√κ + 1); steps are stable only while αλ stays below 2.

What you will be able to do

  • Take a derivative by hand, by the chain rule and by finite differences, and check one against the others.
  • Say when a loss is convex, and why a non-convex one can trap gradient descent.
  • Train logistic regression with batch, mini-batch and stochastic gradient descent, and with momentum.
  • Predict the largest safe learning rate from the curvature, and spot a run that will never converge.
The training loop Forward, loss, gradient, step, repeat Active flow Background or async 1 Processing step number Data A: 86,014 x 9 scaled, plus ones y: late or not 0 or 1 Forward NumPy z = A @ w, p = sigmoid(z) log loss of p against y Backward and step gradient: Aᵀ(p - y)/n w = w - lr x gradient 1 2 3 4 repeat
The loop this lesson builds: scaled parcels (yellow, data) go forward to probabilities and a loss (blue, the model), the gradient comes back by the chain rule and the weights step downhill (teal); the dashed arrow repeats it.

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

1. Derivatives and partial derivatives (Essential)

Training is the question "if I nudge this weight, does the loss go up or down, and how fast?" A derivative is the answer.

HOW IT WORKS A derivative is a slope z = 0: p = 0.5 slope 0.25 -6 0 6 1 0 score z sigmoid(z)
The sigmoid (amber) turns a score into a probability; at z = 0 its value is 0.5 and its derivative, the slope of the tangent (blue), is exactly 0.25.

The derivative of a function at a point is its slope there: how much the output moves per unit of input. The sigmoid, σ(z) = 1 / (1 + e^-z), turns any score into a probability; at z = 0 its value is 0.5 and its slope is exactly 0.25.

BY THE NUMBERS Smaller h is not always better ERROR OF THE FINITE-DIFFERENCE SLOPE (LOG SCALE) 2.1e-04 h = 1e-1 2.1e-10 h = 1e-4 1.5e-09 h = 1e-8 2.2e-05 h = 1e-12 too big: the curve bends too small: rounding wins
Estimating the sigmoid's slope by finite differences: h = 1e-4 is accurate to 2.1e-10 (green), larger h misses the bend and h = 1e-12 loses to rounding, 2.2e-5 (red); bar heights on a log scale.

You can estimate a slope with finite differences: (f(z + h) - f(z - h)) / 2h. With h = 1e-1 the error is 2.1e-4; with 1e-4 it is 2.1e-10. Smaller is not always better: at 1e-12 rounding takes over and the error grows back to 2.2e-5. Use finite differences to check formulas, not to train.

BY THE NUMBERS Nine slopes for one parcel DL/DW FOR KP0104204 AT W = 0 (LATE, Y = 1) address_quality +1.170 wind_kph +0.164 driver_new +0.151 weight_kg -0.008 rain_mm -0.029 distance_km -0.309 intercept -0.500 ship_hour -0.613 stops_on_route -0.724 negative: raise w to lower the loss
KP0104204's nine partial derivatives at w = 0: negative slopes (stops -0.724, hour -0.613) say raising that weight lowers its loss; its poor address, scaled to -2.34, gives +1.17.

A model has many weights. A partial derivative is the slope along one of them, with the others held still. Logistic regression on lesson 1's eight scaled features plus an intercept has nine weights, so nine partial derivatives.

2. Gradients and the chain rule (Essential)

A model is a chain of simple steps, and the chain rule says the slope of a chain is the product of the slopes of its links. This one rule is backpropagation (Module 4).

HOW IT WORKS The chain rule, link by link w weights z z = w . x p p = 0.5 L loss 0.693 dz/dw = x dp/dz = 0.25 dL/dp = -2 dL/dw = -2 x 0.25 x x = -0.5 x multiply the links: the same nine numbers finite differences give
The chain rule for KP0104204 at w = 0: the slopes of the links (x, then 0.25, then -2) multiply to -0.5 x, the same nine numbers finite differences give (green).

For KP0104204 at w = 0: z = w · x = 0, so p = 0.5 and the loss, -ln p, is 0.693. The links: dL/dp = -1/p = -2, dp/dz = p(1 - p) = 0.25, dz/dw = x. Their product is -0.5 x: the address-quality weight gets +1.17 (its scaled value is -2.34), stops -0.724.

HOW IT WORKS One gradient for 86,014 parcels Aᵀ 9 x 86,014 x (p - y) one error per parcel / n gradient 9 numbers formula against finite differences: largest gap 7.0e-11 on all training parcels, at w = 0
The gradient over every training parcel is one matrix product, Aᵀ(p - y)/n: each parcel's error (amber) weighted by its features; it matches finite differences to 7.0e-11 (green).

The gradient collects every partial derivative into one vector, pointing uphill. Averaged over all 86,014 training parcels it is Aᵀ(p - y)/n, one line of lesson 1's algebra. The formula and finite differences agree to 7.0e-11.

3. Convexity and local minima (Essential)

Whether gradient descent finds the best weights or merely some weights depends on the shape of the loss.

HOW IT WORKS A bowl: every chord sits above one bottom 0 of 500 midpoints above their chord: log loss is convex Hessian eigenvalues 0.219 to 0.296, all positive shape sketched; numbers measured
Logistic regression's log loss is convex: not one of 500 random midpoints rose above its chord, and its curvature eigenvalues run from 0.219 to 0.296, all positive (green), so every downhill path ends at one bottom.

A loss is convex if the straight line (the chord) between any two points on it never dips below it: a single bowl, so every downhill path ends at the same bottom. Logistic regression's log loss is convex: 500 random pairs of weights, and not one midpoint above its chord. Its curvature (the Hessian, the matrix of second derivatives) has eigenvalues from 0.219 to 0.296, all positive.

HOW IT WORKS Two valleys, two answers start -2 start +2 global: w -1.301, loss -3.514 local: w +1.131, loss -1.070 illustrative loss w⁴ - 3w² + w
On a non-convex loss (illustrative) gradient descent from -2 reaches the global minimum (green), but from +2 it stops in a local minimum (red), where the slope is also zero: the start decides.

Networks are not convex. In the illustrative loss w⁴ - 3w² + w, gradient descent from -2 reaches the deeper global minimum at -1.301 (loss -3.514); from +2 it stops in a local minimum at +1.131 (loss -1.070), where the slope is also zero. The start decided the answer.

4. Gradient descent and its variants (Essential)

Gradient descent repeats one step: w ← w - lr × gradient. The learning rate (lr) sets the step length.

BY THE NUMBERS Downhill in 200 steps 0.6931 0 0.3408 1 0.2506 20 0.2504 200 steps (log scale), lr 3 BFGS and scikit-learn: same weights
Batch gradient descent with lr 3: the loss (amber) falls from 0.693 to 0.3408 in one step and 0.2504 by step 200, where SciPy's BFGS and scikit-learn land too (green).

From w = 0 with lr 3, the loss falls from 0.693 to 0.3408 after one step, 0.2506 after 20 and 0.2504 after 200. SciPy's BFGS and scikit-learn land on the same weights, within 5.8e-5. Stops carry the biggest weight (0.607), then the poor address (-0.436).

BY THE NUMBERS The trained weights, and KP0104204 stops_on_route +0.607 distance_km +0.279 ship_hour +0.257 rain_mm +0.206 driver_new +0.105 weight_kg +0.039 wind_kph -0.014 address_quality -0.436 KP0104204 z -0.614, p 0.351 lesson 1 0.237, not a probability
The trained weights (amber, per standard deviation of each feature): stops count most, a good address lowers risk; KP0104204 gets a probability of 0.351, where lesson 1's least squares gave 0.237 (grey).

KP0104204 now gets z = -0.614 and a probability of 0.351: more than four times the training late rate, and between 0 and 1 by construction, unlike lesson 1's 0.237.

COMPARE One epoch, four batch sizes BATCH STEPS LOSS AFTER TIME HERE 86,014 (batch) 1 0.3408 0.00 s 256 (mini-batch) 336 0.2509 0.00 s 32 (mini-batch) 2,688 0.2516 0.01 s 1 (SGD) 86,014 0.2642 0.28 s best possible: 0.2504; course laptop, 24 October 2026
In one epoch a full batch takes a single step (red, 0.3408), mini-batches of 256 or 32 take hundreds of cheap noisy steps and nearly finish (green), and SGD's 86,014 one-parcel steps are noisier and slower (amber).

Batch gradient descent uses every parcel for each step. Mini-batch uses a random handful, stochastic gradient descent (SGD) one parcel. In one pass (an epoch), batches of 256 take 336 cheap, noisy steps and reach 0.2509, against 0.3408 for the single full step; SGD's 86,014 steps reach 0.2642, its noise still showing.

COMPARE Momentum in a narrow valley path sketched; numbers measured plain: 11,511 steps shrink 0.9991 per step momentum: 334 steps β 0.917, shrink 0.9578 late ~ b + w x distance_km, κ 2,151
Fitting late against raw kilometres gives a narrow valley (κ = 2,151): plain descent zigzags and needs 11,511 steps to get within 1e-6 (red), momentum carries along the floor in 334 (green).

Momentum keeps a running sum of past gradients, so steps that agree build speed and steps that zigzag cancel. Fit late ~ b + w × distance_km in raw kilometres: κ is 2,151. Plain descent at its best step needs 11,511 steps to get within 1e-6 of the answer; Goh's tuned momentum (β = 0.917) needs 334.

5. Learning rate and convergence behaviour (Essential)

The learning rate is the most important setting in training, and the curvature tells you its ceiling before you run anything.

BREAK IT Past 2 / λ, the loss climbs LEAST SQUARES, LOSS AFTER 60 STEPS (LOG SCALE) lr 1.0 0.03468 lr 1.6 0.03468 lr 1.75 1.587 limit = 2 / 1.184 = 1.689 (lesson 1's top eigenvalue) below the limit: settles above it: 46 times its best after 60 steps, and climbing
On least squares the safe learning rate is 2 divided by the largest curvature, 2/1.184 = 1.689: at 1.0 and 1.6 the loss settles at 0.0347 (green), at 1.75 it climbs to 1.587 after 60 steps (red).

For least squares the curvature is fixed: lesson 1's eigenvalues, 1.184 down to 0.876. Goh's rule gives the limit 2/1.184 = 1.689. At lr 1.6 the loss settles; at 1.75 it reaches 1.587 after 60 steps, 46 times its best, and climbing. That is divergence.

TRADEOFF Too small, about right, too big LOG LOSS AFTER 100 STEPS 0.2844 lr 0.1 slow 0.2504 lr 1 settled 0.2504 lr 3 settled 0.4726 lr 30 bouncing
After 100 steps on the log loss, lr 0.1 is still descending (amber), 1 and 3 have settled at 0.2504 (green), and 30 bounces at 0.4726 (red).

Log loss is gentler but the pattern holds: after 100 steps lr 0.1 is still at 0.2844 (too small, slow), 1 and 3 reach 0.2504, and 30 bounces at 0.4726 (too big).

BY THE NUMBERS Scaling is the cheapest upgrade scaled features κ = 1.35 0.15 error left after a step raw kilometres κ = 2,151 0.9991 error left after a step (κ - 1) / (κ + 1) at the best plain step size
The condition number sets the pace: scaled features (κ = 1.35) leave 0.15 of the error after each best step (green); raw kilometres (κ = 2,151) leave 0.9991 (red), a crawl.

Convergence speed depends on the condition number κ. Scaled features give κ = 1.35, so each step shrinks the error by (κ - 1)/(κ + 1) = 0.15. Raw kilometres alone give 2,151 and 0.9991 per step: the hill Goh describes. Scaling features is the cheapest optimiser upgrade there is.

6. Second-order methods (Optional)

Gradient descent uses the slope only. Second-order methods also use the curvature, so they can step straight to the bottom of a bowl.

COMPARE Slope only, or slope and curvature gradient descent 44 steps to 1e-6 Newton 4 steps to 1e-6 BFGS (SciPy default) 24 iterations 9 numbers per step 81-entry Hessian per step curvature estimated a million weights: a trillion Hessian entries, so networks use first-order steps
Newton's method uses the curvature and needs 4 steps against gradient descent's 44 (green), but each step needs the full Hessian; BFGS (amber) estimates it, and a network's trillion-entry Hessian is out of reach (red).

Newton's method divides the gradient by the Hessian: 4 steps to within 1e-6 of the best loss, against 44 for gradient descent. The price is the Hessian, 81 numbers here but a trillion for a million weights. Quasi-Newton methods such as BFGS (24 iterations here; SciPy's default) build an estimate from gradients instead. scikit-learn's default, L-BFGS, is a low-memory version.

7. Constrained optimisation and Lagrange multipliers (Optional)

Sometimes the best answer must also obey a rule: a budget, a cap, a total.

HOW IT WORKS Where the circle touches the line (0.5, 0.5) x + y = 1 x and y from 0 minimise x² + y² subject to x + y = 1 slopes balance: 2x = λ λ = 1, the price of the rule
By hand: the smallest circle x² + y² that touches the line x + y = 1 (blue) touches it at (0.5, 0.5) (green), where the slopes balance with Lagrange multiplier λ = 1.

By hand: make x² + y² as small as possible with x + y = 1. The answer is x = y = 0.5, where the circle just touches the line. A Lagrange multiplier λ balances the two slopes: 2x = λ, so λ = 1, the price of tightening the rule.

TRADEOFF A cap on the weights has a price free 0.871 weight length 0.2504 log loss capped at 0.5 0.500 weight length 0.2554 log loss multiplier 0.028: a penalty strength, Module 3's L2 regularisation
Capping the eight feature weights at length 0.5 (amber) instead of their free 0.871 (blue) raises the loss from 0.2504 to 0.2554; the multiplier, 0.028, is the penalty strength Module 3 calls L2 regularisation (green).

On Kestrel, cap the length of the eight feature weights at 0.5 (unconstrained they reach 0.871). SciPy's SLSQP solver holds the cap and the loss rises from 0.2504 to 0.2554, with multiplier 0.028. That multiplier is a penalty strength: Module 3 calls the same idea L2 regularisation.

COMPARE SciPy runs here; MATLAB is named RUNS HERE NAMED ONLY Tool SciPy minimize MATLAB fmincon Product SciPy 1.18 Optimization Toolbox Default BFGS interior-point Constraints SLSQP, trust-constr sqp, active-set In the course run, every number not run
The course runs SciPy's minimize (BFGS by default, SLSQP for constraints); MATLAB's fmincon in the Optimization Toolbox (grey, named only) offers interior-point and sqp; both pages read 24 October 2026.

The course runs SciPy; MATLAB's fmincon in the Optimization Toolbox ("Solve constrained nonlinear multivariable minimization problem", R2026b documentation, read 24 October 2026) is named only, not run.

Run it

The run took about 30 seconds on the course laptop (24 October 2026), most of it the 20,000-step comparison. The first cell pins SciPy (1.18.1, as in course-guide/02 and NOTES) and makes the folder.

python
%pip install -q scipy==1.18.1
!mkdir -p kestrel/maths

Then guide 06 blocks 1 to 6, 8 and 9, unchanged.

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/optimise.py is written in two cells before the first import (Python reads a module once per session).

python
%%writefile kestrel/maths/optimise.py
"""Calculus and optimisation for Kestrel, in NumPy: losses, gradients, and the ways to walk downhill."""
import numpy as np


def sigmoid(z):
    """Squash any number into a probability between 0 and 1."""
    return 1 / (1 + np.exp(-z))


def log_loss(w, A, y):
    """Average log loss of the probabilities sigmoid(A @ w) against the 0/1 labels y."""
    p = np.clip(sigmoid(A @ w), 1e-15, 1 - 1e-15)
    return float(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))


def log_loss_grad(w, A, y):
    """The gradient, by the chain rule: (p - y) for each parcel, times its features, averaged."""
    return A.T @ (sigmoid(A @ w) - y) / len(y)


def numeric_grad(f, w, h=1e-6):
    """Central finite differences: nudge one weight up and down by h, see how f moves."""
    g = np.zeros_like(w)
    for i in range(len(w)):
        e = np.zeros_like(w)
        e[i] = h
        g[i] = (f(w + e) - f(w - e)) / (2 * h)
    return g

Code walkthrough: sigmoid turns a score into a probability. log_loss clips probabilities away from exactly 0 and 1 so the logarithm stays finite. log_loss_grad is the chain-rule gradient, Aᵀ(p - y)/n. numeric_grad nudges each weight by ±h and divides the change by 2h.

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


def gradient_descent(grad, w0, lr, steps, beta=0.0):
    """Step against the gradient `steps` times; beta > 0 adds momentum (a running sum of past steps)."""
    w, v, path = np.array(w0, dtype=float), np.zeros(len(w0)), []
    for _ in range(steps):
        v = beta * v + grad(w)
        w = w - lr * v
        path.append(w.copy())
    return w, np.array(path)


def minibatch_sgd(A, y, lr, batch, epochs=1, seed=0):
    """Gradient steps on random batches of `batch` parcels; one epoch sees every parcel once."""
    rng, w = np.random.default_rng(seed), np.zeros(A.shape[1])
    for _ in range(epochs):
        order = rng.permutation(len(y))
        for start in range(0, len(y), batch):
            rows = order[start:start + batch]
            w = w - lr * log_loss_grad(w, A[rows], y[rows])
    return w


def newton(A, y, steps):
    """Newton's method: divide the gradient by the curvature (the Hessian) at every step."""
    w, losses = np.zeros(A.shape[1]), []
    for _ in range(steps):
        p = sigmoid(A @ w)
        H = (A.T * (p * (1 - p))) @ A / len(y)
        w = w - np.linalg.solve(H, log_loss_grad(w, A, y))
        losses.append(log_loss(w, A, y))
    return w, losses

Code walkthrough: gradient_descent takes any gradient function; with beta 0 it is plain descent, otherwise v carries momentum. It returns the final weights and every step, the path. minibatch_sgd shuffles once per epoch and steps on each slice of batch rows. newton builds the Hessian, Aᵀ diag(p(1 - p)) A / n, and solves for the step instead of inverting it.

Derivatives, the chain rule and convexity

python
import numpy as np
from kestrel.maths.optimise import gradient_descent, log_loss, log_loss_grad, numeric_grad, sigmoid

NUMERIC = ["ship_hour", "weight_kg", "distance_km", "stops_on_route",
           "address_quality", "rain_mm", "wind_kph", "driver_new"]       # lesson 1's eight columns
train = df[df["late"].notna() & (df["ship_date"] < "2026-08-24")]
X, y = train[NUMERIC].to_numpy(dtype=float), train["late"].to_numpy(dtype=float)
mean, sd = X.mean(axis=0), X.std(axis=0)
A = np.column_stack([np.ones(len(X)), (X - mean) / sd])               # a column of ones, then 8 scaled features
kp = np.concatenate([[1.0], (df.loc[df["parcel_id"] == "KP0104204", NUMERIC].to_numpy(dtype=float)[0] - mean) / sd])
print("A", A.shape, "| KP0104204 scaled:", kp.round(2))

Code walkthrough: A is a column of ones (the intercept) and the eight features scaled with the training mean and standard deviation; kp is KP0104204 scaled the same way, with its leading 1.

text
A (86014, 9) | KP0104204 scaled: [ 1.    1.23  0.62  0.02  1.45 -2.34 -0.33  0.06 -0.3 ]
python
w0 = np.zeros(9)
p = sigmoid(kp @ w0)
print("at w = 0: z", kp @ w0, "| p", p, "| loss for KP0104204 (late, y = 1):", round(-np.log(p), 4))
dL_dp, dp_dz, dz_dw = -1 / p, p * (1 - p), kp           # the chain: loss <- p <- z <- w
print("dL/dp", dL_dp, "| dp/dz", dp_dz, "| dL/dz", dL_dp * dp_dz)
print("dL/dw by the chain rule:", (dL_dp * dp_dz * dz_dw).round(3))
one = lambda w: log_loss(w, kp[None, :], np.array([1.0]))
print("dL/dw by finite differences:", numeric_grad(one, w0).round(3))
g_all, g_num = log_loss_grad(w0, A, y), numeric_grad(lambda w: log_loss(w, A, y), w0)
print("all training parcels, largest gap formula vs numeric:", f"{np.abs(g_all - g_num).max():.1e}")
exact = 0.25                                             # the slope of sigmoid at 0, known exactly
for h in (1e-1, 1e-4, 1e-8, 1e-12):
    print(f"h = {h:.0e}: slope of sigmoid at 0 = {(sigmoid(h) - sigmoid(-h)) / (2 * h):.12f}, error {abs((sigmoid(h) - sigmoid(-h)) / (2 * h) - exact):.1e}")

Code walkthrough: The three links of the chain are computed separately and multiplied; one is the loss of KP0104204 alone, so numeric_grad checks the same nine numbers. The loop estimates the sigmoid's slope at 0 with four sizes of h.

text
at w = 0: z 0.0 | p 0.5 | loss for KP0104204 (late, y = 1): 0.6931
dL/dp -2.0 | dp/dz 0.25 | dL/dz -0.5
dL/dw by the chain rule: [-0.5   -0.613 -0.309 -0.008 -0.724  1.17   0.164 -0.029  0.151]
dL/dw by finite differences: [-0.5   -0.613 -0.309 -0.008 -0.724  1.17   0.164 -0.029  0.151]
all training parcels, largest gap formula vs numeric: 7.0e-11
h = 1e-01: slope of sigmoid at 0 = 0.249791874789, error 2.1e-04
h = 1e-04: slope of sigmoid at 0 = 0.249999999792, error 2.1e-10
h = 1e-08: slope of sigmoid at 0 = 0.249999998481, error 1.5e-09
h = 1e-12: slope of sigmoid at 0 = 0.250022225146, error 2.2e-05
python
pairs, worse = np.random.default_rng(0).normal(size=(500, 2, 9)), 0
for w1, w2 in pairs:                                     # the chord test, 500 random pairs of weights
    worse += log_loss((w1 + w2) / 2, A, y) > (log_loss(w1, A, y) + log_loss(w2, A, y)) / 2 + 1e-12
H0 = (A.T * 0.25) @ A / len(y)                           # curvature (Hessian) of the log loss at w = 0
print("midpoints above the chord:", worse, "of 500 | Hessian eigenvalues at w = 0:", np.linalg.eigvalsh(H0).round(3))
bumpy = lambda w: w**4 - 3 * w**2 + w                    # an illustrative non-convex loss in one weight
slope = lambda w: 4 * w**3 - 6 * w + 1
for start in (-2.0, 2.0):
    w_end, _ = gradient_descent(lambda w: slope(w), [start], lr=0.01, steps=500)
    print(f"start {start:+.0f}: ends at w = {w_end[0]:+.3f}, loss {bumpy(w_end[0]):+.3f}")

Code walkthrough: Each of 500 random pairs is tested: is the loss at the midpoint above the average of the two losses? H0 is the Hessian at w = 0, where p(1 - p) = 0.25. bumpy and slope are the illustrative loss and its derivative, descended from two starts.

text
midpoints above the chord: 0 of 500 | Hessian eigenvalues at w = 0: [0.219 0.229 0.248 0.249 0.25  0.25  0.252 0.256 0.296]
start -2: ends at w = -1.301, loss -3.514
start +2: ends at w = +1.131, loss -1.070

Gradient descent and the learning rate

python
from scipy.optimize import minimize
from sklearn.linear_model import LogisticRegression

grad = lambda w: log_loss_grad(w, A, y)
w_gd, path = gradient_descent(grad, np.zeros(9), lr=3.0, steps=200)
losses = [log_loss(w, A, y) for w in path]
print("batch gradient descent, lr 3: loss after 1, 20, 200 steps:", [round(losses[i], 4) for i in (0, 19, 199)])
bfgs = minimize(log_loss, np.zeros(9), args=(A, y), jac=log_loss_grad, method="BFGS")
sk = LogisticRegression(C=1e6, max_iter=1000).fit(A[:, 1:], y)
print("SciPy BFGS loss", round(bfgs.fun, 6), "in", bfgs.nit, "iterations | largest weight gap to ours:", f"{np.abs(bfgs.x - w_gd).max():.1e}")
print("scikit-learn weights match ours:", np.allclose(np.r_[sk.intercept_, sk.coef_[0]], w_gd, atol=1e-3))
print("weights:", dict(zip(["b"] + NUMERIC, w_gd.round(3).tolist())))
print("KP0104204: z", round(kp @ w_gd, 3), "| probability late", round(sigmoid(kp @ w_gd), 3))

Code walkthrough: path holds the weights after every step, so losses is the curve. minimize(..., method="BFGS") and LogisticRegression(C=1e6) (a penalty so weak it is effectively off) give the reference answers.

text
batch gradient descent, lr 3: loss after 1, 20, 200 steps: [0.3408, 0.2506, 0.2504]
SciPy BFGS loss 0.250433 in 24 iterations | largest weight gap to ours: 5.8e-05
scikit-learn weights match ours: True
weights: {'b': -2.757, 'ship_hour': 0.257, 'weight_kg': 0.039, 'distance_km': 0.279, 'stops_on_route': 0.607, 'address_quality': -0.436, 'rain_mm': 0.206, 'wind_kph': -0.014, 'driver_new': 0.105}
KP0104204: z -0.614 | probability late 0.351
python
ls_grad = lambda w: A.T @ (A @ w - y) / len(y)          # least squares: gradient of half the mean squared error
lam = np.linalg.eigvalsh(A.T @ A / len(y))              # its curvature never changes: a quadratic
print("least squares curvature: largest", round(lam[-1], 3), "| smallest", round(lam[0], 3), "| limit 2 / largest =", round(2 / lam[-1], 3))
for lr in (1.0, 1.6, 1.75):
    w_ls, p_ls = gradient_descent(ls_grad, np.zeros(9), lr=lr, steps=60)
    mse = [float(np.mean((A @ w - y) ** 2)) / 2 for w in p_ls]
    print(f"least squares, lr {lr}: loss after 1, 20, 60 steps {mse[0]:.4g}, {mse[19]:.4g}, {mse[59]:.4g}")
for lr in (0.1, 1.0, 3.0, 30.0):
    w_lr, p_lr = gradient_descent(grad, np.zeros(9), lr=lr, steps=100)
    print(f"log loss, lr {lr:>4}: after 100 steps {log_loss(w_lr, A, y):.4f}")

Code walkthrough: ls_grad is the gradient of half the mean squared error; its Hessian AᵀA/n has lesson 1's eigenvalues, so 2 / lam[-1] is the limit. Three learning rates straddle it, then four are tried on the log loss.

text
least squares curvature: largest 1.184 | smallest 0.876 | limit 2 / largest = 1.689
least squares, lr 1.0: loss after 1, 20, 60 steps 0.0347, 0.03468, 0.03468
least squares, lr 1.6: loss after 1, 20, 60 steps 0.03692, 0.03469, 0.03468
least squares, lr 1.75: loss after 1, 20, 60 steps 0.03812, 0.04079, 1.587
log loss, lr  0.1: after 100 steps 0.2844
log loss, lr  1.0: after 100 steps 0.2504
log loss, lr  3.0: after 100 steps 0.2504
log loss, lr 30.0: after 100 steps 0.4726
python
B = np.column_stack([np.ones(len(X)), X[:, 2]])          # late ~ b + w * distance_km, in raw kilometres
g2 = lambda w: B.T @ (B @ w - y) / len(y)
l2 = np.linalg.eigvalsh(B.T @ B / len(y))
kappa = l2[-1] / l2[0]
best = np.linalg.solve(B.T @ B, B.T @ y)
print("curvatures", l2.round(3).tolist(), "| condition number", round(kappa))
print("predicted shrink per step: plain", round((kappa - 1) / (kappa + 1), 4), "| momentum", round((np.sqrt(kappa) - 1) / (np.sqrt(kappa) + 1), 4))
lr_plain = 2 / (l2[0] + l2[-1])
lr_mom, beta = (2 / (np.sqrt(l2[0]) + np.sqrt(l2[-1]))) ** 2, ((np.sqrt(kappa) - 1) / (np.sqrt(kappa) + 1)) ** 2
for name, lr, b in (("plain", lr_plain, 0.0), ("momentum", lr_mom, beta)):
    _, pth = gradient_descent(g2, np.zeros(2), lr=lr, steps=20000, beta=b)
    gap = np.abs(pth - best).max(axis=1)
    print(f"{name:8} lr {lr:.5f} beta {b:.3f}: steps to within 1e-6 of the best weights:", int(np.argmax(gap < 1e-6)) + 1 if (gap < 1e-6).any() else "over 20000")

Code walkthrough: B keeps distance_km in kilometres. kappa is the condition number; best is the exact answer from the normal equations. The step size and β for momentum are Goh's optimal choices. argmax(gap < 1e-6) finds the first step within 1e-6.

text
curvatures [0.435, 935.558] | condition number 2151
predicted shrink per step: plain 0.9991 | momentum 0.9578
plain    lr 0.00214 beta 0.000: steps to within 1e-6 of the best weights: 11511
momentum lr 0.00410 beta 0.917: steps to within 1e-6 of the best weights: 334
python
import time
from kestrel.maths.optimise import minibatch_sgd

for batch, lr in ((len(y), 3.0), (256, 1.0), (32, 0.3), (1, 0.05)):
    t0 = time.perf_counter()
    w_b = minibatch_sgd(A, y, lr=lr, batch=batch, epochs=1)
    steps = -(-len(y) // batch)                                   # ceiling division: steps in one epoch
    print(f"batch {batch:>6}: {steps:>6} steps in one epoch, loss {log_loss(w_b, A, y):.4f}, {time.perf_counter() - t0:.2f} s")
print("the best this model can do (BFGS):", round(bfgs.fun, 4))

Code walkthrough: Each run is one epoch from zero; -(-n // batch) rounds the step count up. time.perf_counter times it on this machine.

text
batch  86014:      1 steps in one epoch, loss 0.3408, 0.01 s
batch    256:    336 steps in one epoch, loss 0.2509, 0.00 s
batch     32:   2688 steps in one epoch, loss 0.2516, 0.01 s
batch      1:  86014 steps in one epoch, loss 0.2642, 0.29 s
the best this model can do (BFGS): 0.2504

Second-order and constrained

python
from kestrel.maths.optimise import newton

w_nt, nt_losses = newton(A, y, steps=6)
print("Newton, loss after each step:", [round(v, 6) for v in nt_losses])
first = lambda ls: next(i + 1 for i, v in enumerate(ls) if v - bfgs.fun < 1e-6)       # first step within 1e-6 of the best loss
print("steps to within 1e-6 of the best loss: Newton", first(nt_losses), "| gradient descent, lr 3:", first(losses))
p_hat = sigmoid(A @ w_nt)
H = (A.T * (p_hat * (1 - p_hat))) @ A / len(y)
print("curvature at the answer:", np.linalg.eigvalsh(H).round(4)[[0, -1]], "| Hessian entries per step:", H.size)

Code walkthrough: first finds the first step whose loss is within 1e-6 of BFGS. H is the Hessian at the answer; H.size is its 81 entries.

text
Newton, loss after each step: [0.292786, 0.255791, 0.250654, 0.250434, 0.250433, 0.250433]
steps to within 1e-6 of the best loss: Newton 4 | gradient descent, lr 3: 44
curvature at the answer: [0.0339 0.1524] | Hessian entries per step: 81
python
from scipy.optimize import minimize as solve

hand = solve(lambda v: v[0]**2 + v[1]**2, [0.0, 0.0], method="SLSQP",
             constraints={"type": "eq", "fun": lambda v: v[0] + v[1] - 1})   # by hand: x = y = 0.5, lambda = 1
print("x^2 + y^2 with x + y = 1:", hand.x.round(4), "| multiplier 2x =", round(2 * hand.x[0], 4))
budget = {"type": "ineq", "fun": lambda w: 0.25 - w[1:] @ w[1:]}               # keep the 8 feature weights inside length 0.5
capped = solve(log_loss, np.zeros(9), args=(A, y), jac=log_loss_grad, method="SLSQP", constraints=budget)
g = log_loss_grad(capped.x, A, y)[1:]
mult = -(g @ capped.x[1:]) / (2 * capped.x[1:] @ capped.x[1:])                 # gradient = -2 lambda w at the answer
print("free: length", round(float(np.linalg.norm(w_gd[1:])), 3), "loss", round(bfgs.fun, 4),
      "| capped: length", round(float(np.linalg.norm(capped.x[1:])), 3), "loss", round(capped.fun, 4), "| multiplier", round(mult, 4))

Code walkthrough: The first solve (SciPy's minimize, renamed) is the hand example with an equality constraint. budget is an inequality, 0.25 - |w|² ≥ 0. At the answer the gradient points along -w, and mult reads the multiplier off that.

text
x^2 + y^2 with x + y = 1: [0.5 0.5] | multiplier 2x = 1.0
free: length 0.871 loss 0.2504 | capped: length 0.5 loss 0.2554 | multiplier 0.028

Break it: forget to scale the features

BREAK IT Raw features, scaled-size step loss 2.78 every step, 11 x the best |z| up to 4,100 every p is 0 or 1 safe step < 5.2e-4 largest curvature 3,869 condition number 2.8 million raw, 1.35 scaled: the same lr 3 cannot serve both
On raw features lr 3 throws scores to 4,100, pins every probability at 0 or 1 and leaves the loss stuck at 2.78 (red); the curvature, 3,869, said in advance that only steps below 5.2e-4 are safe (blue).

Train the same model on raw features, at the learning rate that worked when they were scaled.

python
A_raw = np.column_stack([np.ones(len(X)), X])                   # the same model, features not scaled
with np.errstate(over="ignore", divide="ignore", invalid="ignore"):
    w_raw, p_raw = gradient_descent(lambda w: log_loss_grad(w, A_raw, y), np.zeros(9), lr=3.0, steps=5)
    z = A_raw @ p_raw[-1]
    print("loss after each step:", [round(log_loss(w, A_raw, y), 2) for w in p_raw])
    print("largest |z| after 5 steps:", f"{np.abs(z).max():.1e}", "| share of probabilities stuck at 0 or 1:",
          round(float(np.mean((sigmoid(z) < 1e-15) | (sigmoid(z) > 1 - 1e-15))), 3))
raw_curv = np.linalg.eigvalsh((A_raw.T * 0.25) @ A_raw / len(y))
print("curvature at w = 0, raw features: largest", round(raw_curv[-1], 1), "| condition number", f"{raw_curv[-1] / raw_curv[0]:.1e}",
      "| a safe step is below", f"{2 / raw_curv[-1]:.1e}", "| scaled:", round(float(np.linalg.eigvalsh(H0)[-1] / np.linalg.eigvalsh(H0)[0]), 2))

Code walkthrough: A_raw skips the scaling. np.errstate silences the overflow warnings so the printout shows the numbers. raw_curv is the Hessian's spectrum at w = 0.

Run this and you will see:

text
loss after each step: [2.78, 2.78, 2.78, 2.78, 2.78]
largest |z| after 5 steps: 4.1e+03 | share of probabilities stuck at 0 or 1: 1.0
curvature at w = 0, raw features: largest 3869.3 | condition number 2.8e+06 | a safe step is below 5.2e-04 | scaled: 1.35

One step with lr 3 multiplies raw stop counts near 120 into scores of thousands, every probability lands at exactly 0 or 1, and the loss sits at 2.78 step after step, eleven times the best. Nothing crashes. The curvature said so in advance: the largest is 3,869, so any step above 5.2e-4 is unsafe, and the condition number is in the millions.

In production

Nobody hand-tunes descent for a nine-weight model; the library's L-BFGS does it. The habits are what scale. Scale features inside the pipeline (Module 3), log the training loss and stop a run that turns into NaN or stalls, and find the learning rate with a short sweep before a long run: Leslie Smith's range test (2015) raises the learning rate linearly over a few epochs to find the range worth using. Module 4 adds Adam and AdamW, which keep momentum and a per-weight scale, and learning-rate schedules.

The question Adaeze will ask

"Your weights match scikit-learn to 1e-3. Why write gradient descent at all?"

Because a network has no closed form and no Newton step it can afford: it is trained by exactly this loop, scaled up. Knowing that the safe step is 2 over the largest curvature, and that raw kilometres make κ 2,151, is how you debug a network that will not converge in Module 4.

What you added to Kestrel

  • kestrel/maths/optimise.py: the sigmoid, log loss, its gradient by the chain rule, and a finite-difference check.
  • Gradient descent with momentum, mini-batch SGD and Newton's method, each checked against SciPy and scikit-learn.

Check yourself

  1. The gradient formula and finite differences disagree by 0.3 on one weight. Which do you trust, and what do you check?
  2. Why does scaling the features change the safe learning rate?
  3. Batches of 256 beat one full-batch step in the same epoch. Why not always use batches of 1?
  1. Neither yet: a gap that size is a bug. Check the chain-rule factors one at a time against finite differences with h near 1e-6.
  2. The safe step is 2 over the largest curvature, and the curvature grows with the square of a feature's size: raw stop counts make it 3,869.
  3. One-parcel steps are noisy (0.2642 after an epoch against 0.2509) and cannot use the matrix product's speed; 86,014 tiny steps took 0.28 s against 0.00 s for 336.

Exercise

Write Leslie Smith's idea in its simplest form: range_test(A, y, lrs, steps) runs steps of gradient descent from zero at each learning rate and returns the best one and every final loss. Then run the check.

python
def check_range_test(fn):
    """Checks your learning-rate range test on Kestrel's scaled training matrix."""
    lrs = [0.01, 0.1, 1.0, 3.0, 10.0, 100.0]
    best, results = fn(A, y, lrs, steps=20)
    assert sorted(results) == lrs, "return a loss for every learning rate tried"
    assert all(np.isfinite(v) for v in results.values()), "every loss should be a finite number"
    assert best == min(results, key=results.get), "best must be the learning rate with the lowest loss"
    assert results[0.01] > results[best] < results[100.0], "too small and too large should both do worse"
    print("all checks passed: best", best, "|", {k: round(v, 4) for k, v in results.items()})

Code walkthrough: The check runs your function on six learning rates and asserts a loss for each, all finite, the right best, and that both extremes do worse.

python
def range_test(A, y, lrs, steps=20):
    """Run `steps` of gradient descent from zero at each learning rate; return the best and every loss."""
    results = {}
    for lr in lrs:
        w, _ = gradient_descent(lambda w: log_loss_grad(w, A, y), np.zeros(A.shape[1]), lr=lr, steps=steps)
        results[lr] = log_loss(w, A, y)
    return min(results, key=results.get), results


check_range_test(range_test)

Code walkthrough: Each learning rate gets its own 20-step run from zero; min(..., key=results.get) picks the lowest loss.

text
all checks passed: best 10.0 | {0.01: 0.6587, 0.1: 0.4609, 1.0: 0.2584, 3.0: 0.2506, 10.0: 0.2504, 100.0: 0.9}

Key terms

TermMeaning
DerivativeThe slope of a function at a point
Partial derivativeThe slope along one input, the others held still
GradientAll partial derivatives as one vector, pointing uphill
Chain ruleThe slope of a chain is the product of its links' slopes
ConvexBowl-shaped: every chord lies above the curve
HessianThe matrix of second derivatives: the curvature
Learning rateHow far each gradient step moves
Mini-batchA step computed on a random handful of examples
MomentumA running sum of past steps
Condition numberLargest curvature over smallest; high is slow
Lagrange multiplierThe price of a constraint at the optimum

Sources

  • Gabriel Goh, "Why Momentum Really Works", Distill, 4 April 2017 (read 24 October 2026). https://distill.pub/2017/momentum/
  • Leslie N. Smith, "Cyclical Learning Rates for Training Neural Networks", arXiv 1506.01186, 2015 (WACV 2017). https://arxiv.org/abs/1506.01186
  • SciPy v1.18.0 manual, scipy.optimize.minimize, read 24 October 2026. https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html
  • MathWorks, fmincon, Optimization Toolbox, R2026b documentation, read 24 October 2026. https://www.mathworks.com/help/optim/ug/fmincon.html