Machine Learning · Module 25 of 26 · 9 concepts · about 18 min

Production ML

Getting a model to behave in production the way it behaved in evaluation — the same inputs, inside a latency budget, with a version and a way back.

At work: Running models inside real products: fast enough, reliable, versioned and safe to roll back.

Training-serving skew

What it is

Any systematic difference between the inputs a model was trained on and the inputs it receives when serving: preprocessing re-implemented in the serving language, a feature computed over a different window, a default value filled differently, a column that is fresh in the warehouse and stale in the request path, or a category the encoder never saw.

Why it matters

It is the dominant cause of a model that scored well and performs badly, and it produces no errors. The service returns 200, the model returns a probability, and the probability is drawn from a distribution the model was never fitted to.

In the real world: Features calculated differently in the nightly training job and the live app make a good model perform badly.

Visual example

Skew audit

Four small, individually reasonable differences: the model receives different numbers.

training path (notebook + pipeline)
featurevalue
spend_30dtrailing 30 days: 4,180
country'BR' (one-hot known)
age missingtraining median: 34
tickets_7das of event time
the same request, two code paths
serving path (re-implemented in Go)
featurevalue
spend_30dcalendar month: 2,905
country'br' -> unknown -> all zeros
age missinghardcoded 0
tickets_7dbatch, up to 24h stale

What each difference costs

spend_30d: -1,275 on a top-three feature: the row looks far poorer than it is

country: case mismatch -> an all-zero one-hot block, i.e. 'no country'

age: 0 is not missing; it is an extreme value the model takes literally

freshness: a 24h-stale ticket count misses exactly the spike that predicts churn

Offline AUC 0.79, live AUC 0.63 — and every service returned 200

Why this matters

None of these four is a bug in the ordinary sense. Each is a small independent decision made by someone reimplementing preprocessing without the training code in front of them.

Common misconception

Wrong idea: if the model file is the same, the model is the same.

Correct idea: a model is a function of its inputs. Change how the inputs are computed and you have deployed a different model, however identical the weights.

# A skew test worth running in CI: score a frozen sample through BOTH paths.
import numpy as np, pandas as pd

golden = pd.read_parquet('golden_sample.parquet')       # 1,000 rows captured from prod

offline = pipe.predict_proba(golden)[:, 1]              # the training-time code path
online = np.array([serving_client.score(row) for row in golden.to_dict('records')])

delta = np.abs(offline - online)
print(f'max {delta.max():.6f}  mean {delta.mean():.6f}  over 1e-4: {(delta > 1e-4).sum()}')
assert delta.max() < 1e-4, 'training and serving paths disagree'

The most common single cause is a feature whose definition drifted: spend_30d computed in the warehouse as a calendar-month sum and in the serving path as a trailing-30-day sum. Both are defensible; having both is a silent, permanent input shift. Define each feature once, in one place, and test the two paths against each other on a golden sample.

Practise it: Would You Notice the Input Drifting?

Batch versus online inference

What it is

Batch inference scores a large set of rows on a schedule and writes the predictions somewhere to be looked up. Online inference scores one row per request, synchronously, inside a latency budget. Streaming inference sits between: continuous, event-driven, near-real-time.

Why it matters

The choice determines nearly every other design decision — which features are available, how heavy the model may be, whether a failure is visible to a user, and how quickly a prediction can reflect something that happened a second ago.

In the real world: Scoring all customers for churn overnight, versus scoring each card payment the moment it happens.

Visual example

Serving modes

Not two implementations of one thing — two different systems with different limits.

the hard constraint

(everything else follows)

what changes when you move from batch to online
concernbatchonline
latency budgethours10-50 ms p99
feature freshnessas of last runas of this request
model sizenearly unboundedbounded by the budget
failure visible toan on-call engineerthe user, immediately
cost drivercompute per runpeak QPS capacity

cron: score all users nightly

cannot react to today's behaviour

POST /score

reacts instantly, pays for it in engineering

Choose the slowest mode the product can tolerate: batch is far cheaper to run and to debug

Why this matters

Teams default to online serving because it sounds more capable, then spend months on latency and availability for a prediction that was consumed by a daily email.

Common misconception

Wrong idea: online inference is the mature version of batch inference.

Correct idea: they are different tradeoffs. Online buys freshness with latency risk, operational complexity and a smaller model; batch gives up freshness and gets simplicity, cheap retries and unlimited model size.

# Batch: throughput is all that matters, so vectorise and write results down.
import pandas as pd

frame = pd.read_parquet('active_users.parquet')
frame['p_churn'] = pipe.predict_proba(frame[FEATURES])[:, 1]
frame[['user_id', 'p_churn']].to_parquet('scores/2026-09-19.parquet')

# Online: one row, p99 budget, model loaded once at startup.
MODEL = joblib.load('model.joblib')

def score(payload: dict) -> float:
    row = pd.DataFrame([payload], columns=FEATURES)
    return float(MODEL.predict_proba(row)[0, 1])

Loading the model inside the request handler. It turns a 5ms prediction into a 400ms one and makes latency depend on the filesystem cache. Load once at startup, hold it in module scope, and check at startup that it scores a known row to a known value — a warm-up that doubles as a deployment smoke test.

Feature stores and one definition per feature

What it is

A feature store holds feature definitions and their computed values, and serves them two ways: an offline store that answers 'what was this feature's value at time T' for building training sets, and an online store that answers 'what is it now' at low latency for serving.

Why it matters

It is the structural fix for training-serving skew. One definition, computed once, read by both paths — so the training set and the request are guaranteed to be talking about the same quantity rather than two independent implementations of a description.

In the real world: One shared definition of “spend in the last 30 days” used by training and by the live fraud check.

Visual example

Point-in-time correctness

Each label takes the last feature row at or before its own timestamp — never the latest one.

labels
userevent_tschurned
u-72026-03-101
u-32026-05-020
point-in-time join: feature values as of each label's own event_ts
feature history: spend_30d
uservalid_fromspend_30d
u-72026-02-014100
u-72026-04-01900
u-32026-04-152600
training row, correctly joined
userspend_30dchurned
u-741001
u-326000

u-7, done both ways

correct: event_ts 2026-03-10 -> latest row at or before it (valid_from 2026-02-01) -> 4,100

leaky: plain join on user_id -> the newest row, 900, recorded a month AFTER the churn

The leaky value is a consequence of churning: spend collapsed because the customer left

Why this matters

The leaky join makes 'low recent spend' look enormously predictive of churn, because for churned users it was measured afterwards. Offline AUC rises, live AUC does not move at all.

Common misconception

Wrong idea: joining features to labels by entity id is a lookup.

Correct idea: it is a query about the past. Without a timestamp cutoff, the join silently answers with the present, and the present contains the label.

# The two reads a feature store exists to provide.

# Offline, point-in-time correct: labels joined to feature values as of the event.
training = store.get_historical_features(
    entity_df=labels[['user_id', 'event_ts', 'churned']],   # event_ts is the cutoff
    features=['user:spend_30d', 'user:tickets_7d', 'user:tenure_months'],
).to_df()

# Online, same definitions, single-row latency.
values = store.get_online_features(
    features=['user:spend_30d', 'user:tickets_7d', 'user:tenure_months'],
    entity_rows=[{'user_id': 'u-7'}],
).to_dict()

A feature store does not by itself make a join point-in-time correct — you have to pass the event timestamp as the cutoff. A historical read that joins the *latest* feature value onto an old label is temporal leakage with extra infrastructure, and it is the easiest mistake to make with these APIs.

Latency budgets shape model choice

What it is

A serving endpoint has a budget — often 50ms at the 99th percentile for the whole request, of which the model may get 10-20ms after feature fetching, serialisation and network. Every candidate model has a cost at that percentile, and any candidate over budget is not a candidate.

Why it matters

Accuracy is only available inside the budget. A 500-tree ensemble that gains 0.01 AUC and costs 80ms is worse than useless in an endpoint with a 20ms allowance: it will time out, and a timeout is a missing prediction, which is usually far more damaging than a slightly worse one.

In the real world: A payment check with 50 ms to spare can't use a model that takes 300 ms, however accurate it is.

Visual example

Serving Pareto front

Accuracy against p99 latency: only the points left of the budget line can actually be deployed.

Candidate modelsPareto front
over budget — not deployable0204060800.720.740.760.780.80.820.84p99 latency per request (ms)Validation AUC20ms model budgetbest in budget

Diminishing returns past the elbow

1.2 -> 8ms: +0.045 AUC for 6.8ms — the whole useful gain lives here

14 -> 92ms: +0.016 AUC for 78ms — 6.6x the latency for a rounding error

chosen: 14ms / 0.796: inside budget with headroom for feature fetch and serialisation

A timeout costs a whole prediction; 0.016 AUC costs a fraction of one decision

Why this matters

The most accurate model in the sweep is not on the menu. Drawing the budget as a hard line turns a model-selection argument into a constrained choice with one obvious answer.

Common misconception

Wrong idea: pick the most accurate model, then optimise the serving path if latency becomes a problem.

Correct idea: the budget is a constraint, not a preference. Filter the candidate set by it before comparing accuracy, or you will spend the optimisation effort defending a 0.016 AUC gain.

import numpy as np, time

def latency_profile(model, X, n=2000):
    rows = X.sample(n, replace=True, random_state=0).to_dict('records')
    times = []
    for r in rows:
        frame = pd.DataFrame([r])
        t0 = time.perf_counter()
        model.predict_proba(frame)
        times.append((time.perf_counter() - t0) * 1000)
    t = np.array(times)
    p50, p95, p99 = np.percentile(t, [50, 95, 99])
    return {'p50': p50, 'p95': p95, 'p99': p99}

for name, m in candidates.items():
    prof = latency_profile(m, X_val)
    print(f"{name:<22} p50 {prof['p50']:5.1f}ms  p99 {prof['p99']:6.1f}ms")

Benchmarking with a large batch and dividing by the row count measures throughput, not single-request latency — per-row cost in a batch of 1,000 can be twenty times lower than for a batch of one, because fixed overheads amortise. Measure single rows, and measure the p99, because the average hides exactly the requests that will time out.

Tail latency, not average latency

What it is

Request latency is a long-tailed distribution. The mean is set by the common case; the p99 and p99.9 are set by garbage collection, cache misses, cold containers, a slow feature fetch and retries. Users and upstream timeouts experience the tail.

Why it matters

A page composed of ten model-backed calls has a good chance that at least one of them lands in the tail, so the tail becomes the typical page. Budgeting against the mean guarantees a steady rate of timeouts that averages do not show.

In the real world: If 1 in 100 checkouts waits 2 seconds for the fraud model, customers abandon their carts.

Visual example

Latency distribution

A 12ms mean and a 93ms p99: the timeout budget is decided entirely by the right tail.

times out at 50ms05010015000.050.10.150.20.25Request latency (ms)Share of requestsmean 12msp99 93ms1.6% of requests, 100% of the complaints

The same distribution, five summaries

p50: 9 ms — what a developer sees when testing by hand

p95: 34 ms — still inside a 50ms budget

p99: 93 ms — already timing out

p99.9: 148 ms — the retry storm lives here

A 10-call page: P(no call in the slow 1.6%) = 0.984^10 = 0.85, so 15% of pages are slow

Why this matters

Optimising the mean moves the tall part of this curve, which was never the problem. Cutting the tail means finding the mechanism — a cold cache, a GC pause, a retry — that produces the flat right-hand region.

Common misconception

Wrong idea: the model takes about 12ms, so a 50ms budget is comfortable.

Correct idea: 12ms is the mean. One request in sixty takes more than 50ms, which at 1,000 QPS is roughly a thousand failures an hour.

import numpy as np

rng = np.random.default_rng(0)
body = rng.gamma(shape=2.0, scale=3.0, size=100_000)           # the common case
tail = rng.gamma(shape=2.0, scale=3.0, size=100_000) + rng.exponential(60, 100_000)
is_slow = rng.random(100_000) < 0.02                            # 2% hit a slow path
latency = np.where(is_slow, tail, body)

for q in (50, 90, 95, 99, 99.9):
    print(f'p{q:<5} {np.percentile(latency, q):7.1f} ms')
print(f'mean  {latency.mean():7.1f} ms   over 50ms: {(latency > 50).mean():.2%}')

Percentiles do not average and do not add. You cannot compute a service's p99 by averaging its instances' p99s, and two sequential calls each at 20ms p99 do not give a 40ms p99 for the pair — it is better than that, but only if their slow paths are independent, which a shared cache or database makes false.

Model versioning and rollback

What it is

A deployed model is a versioned artifact in a registry, with an id, the training data snapshot, the code commit, its offline metrics, a stage (staging / production / archived) and a named owner. Rollback is redeploying the previous version's id, and it is tested before it is needed.

Why it matters

Every model will eventually need to be rolled back, and the moment that happens is the worst possible moment to discover that the previous artifact was overwritten, or that nobody knows which data it was trained on. Reverting the code does not revert the model.

In the real world: Switching back to yesterday's pricing model in minutes when the new one misbehaves.

Visual example

Model registry

A registry row is what makes a rollback a one-line operation instead of a retraining project.

the rollback target

(still on disk, still loadable)

meta.json -> data_version

how you reproduce a version at all

registry/
versiondata snapshotoffline AUCstage
20260919-1104snap-2026-09-150.801production
20260902-0930snap-2026-08-280.794previous
20260815-1712snap-2026-08-100.788archived
20260921-0842snap-2026-09-200.806staging

promote('20260902-0930')

seconds, no retraining

Four immutable versions; exactly one symlink decides what serves

The rollback drill, rehearsed

1: promote the previous version and confirm the golden sample scores match its recorded values

2: check the prediction distribution returns to the pre-deploy shape

3: only then investigate the bad version — with traffic already safe

An untested rollback path is not a rollback path

Why this matters

Rollback is the cheapest incident response available, and it only exists if the previous artifact, its data snapshot and its expected outputs were all retained before anything went wrong.

Common misconception

Wrong idea: reverting the deploy reverts the model.

Correct idea: the model is data, not code. Reverting the service leaves whatever artifact the old code loads — which may be the new model, or nothing at all.

import json, pathlib, shutil, datetime

REGISTRY = pathlib.Path('registry')

def register(pipeline, metrics, data_version, git_sha, owner):
    version = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%d%H%M%S')
    target = REGISTRY / version
    target.mkdir(parents=True)
    joblib.dump(pipeline, target / 'model.joblib')
    (target / 'meta.json').write_text(json.dumps({
        'version': version, 'metrics': metrics, 'data_version': data_version,
        'git_sha': git_sha, 'owner': owner, 'sklearn': sklearn.__version__,
    }, indent=2))
    return version

def promote(version):
    link = REGISTRY / 'production'
    if link.exists() or link.is_symlink():
        shutil.move(str(link), str(REGISTRY / 'previous'))
    link.symlink_to(version)              # rollback = promote(previous)

Overwriting model.joblib in place on each deploy. It is the same class of mistake as force-pushing over a release tag: the artifact that is currently serving is the only copy, and there is nothing to go back to. Version by immutable path, never by overwrite.

Canary releases for models

What it is

Route a small share of traffic to the new model, watch the guardrails, and increase the share in steps with a hold at each one: 1% for an hour, 5%, 25%, 50%, 100%. An automatic rollback rule is attached to each step before it starts.

Why it matters

A cutover exposes every user to an unvalidated model simultaneously. A canary bounds the blast radius to the current share, and — unlike shadow mode — it does produce a causal read, because the canary population was randomised.

In the real world: Sending 5% of traffic to a new recommendation model first, and watching sales before rolling it out.

Visual example

Canary ramp

Traffic share ramped in held steps, with one guardrail breach and an automatic revert.

Planned rampActual ramp
p99 breach -> auto rollback024681012020406080100Hours since releaseShare of traffic on the new model (%)fix, then re-ramp from 5%

What the 25% step caught

1%: p99 41ms, error rate 0.0004 — healthy, but only 1% of traffic

5%: p99 44ms — still healthy

25%: p99 78ms: the feature cache stopped fitting in memory at this volume

Exposure at rollback: 25% for one hour, not 100% for a day

Why this matters

The failure was load-dependent, so it could not appear at 1% and was certain to appear at 100%. Stepped ramps exist precisely to find this class of problem while it is still cheap.

Common misconception

Wrong idea: a clean canary at 1% means the model is safe to ship to everyone.

Correct idea: a clean canary means the model is safe at 1%. Resource contention, cache behaviour and downstream load are all functions of the share, so each step is a new test.

import hashlib

CANARY_SHARE = 0.05

def pick_model(user_id, champion, canary, salt='release-20260919'):
    h = hashlib.sha256(f'{salt}:{user_id}'.encode()).digest()
    in_canary = (int.from_bytes(h[:8], 'big') % 10_000) < CANARY_SHARE * 10_000
    return ('canary', canary) if in_canary else ('champion', champion)

GUARDRAILS = {
    'p99_latency_ms': ('max', 50),
    'error_rate': ('max', 0.001),
    'mean_score': ('within_pct_of_champion', 10),
    'null_feature_rate': ('max', 0.02),
}

def should_roll_back(canary_metrics, champion_metrics):
    breaches = []
    if canary_metrics['p99_latency_ms'] > 50:
        breaches.append('latency')
    if canary_metrics['error_rate'] > 0.001:
        breaches.append('errors')
    drift = abs(canary_metrics['mean_score'] - champion_metrics['mean_score'])
    if drift > 0.10 * champion_metrics['mean_score']:
        breaches.append('score_distribution')
    return breaches

A 1% canary held for an hour cannot detect a 2% conversion change — that is the sample-size arithmetic from the experimentation module, and it applies here unchanged. A canary is a check on *operational* health: latency, errors, null rates, prediction distribution. Business-metric effects need a properly powered A/B test, which is a separate, longer step.

Scaling inference: caching and batching

What it is

Two levers with different mechanisms. Caching skips work entirely, and is available when the same inputs recur and the prediction is stable for a known lifetime. Dynamic batching collects concurrent requests for a few milliseconds and scores them as one matrix, trading a little latency for a large throughput gain from vectorisation.

Why it matters

They are how a model that costs 8ms per row serves thousands of requests a second on modest hardware. Both are also the classic sources of subtle production bugs — a stale cache serving predictions from a retired model, or a batch window that pushes the p99 past the budget.

In the real world: Caching popular product recommendations keeps a sale-day traffic spike from overwhelming the servers.

Visual example

Dynamic batching

Batching trades queue latency for throughput: the win is nearly all captured by a batch of 32.

Throughputp99 latency (ms)
over the latency budget0100200300400500020406080100Batch sizeThroughput (thousand rows/sec)50ms budget (right axis)14x throughput, 18ms p99

Reading the tradeoff

1 -> 32: throughput x14 (0.8k -> 11.2k rows/s) for +10ms p99 — clearly worth it

32 -> 512: throughput x1.5 for +78ms p99 — clearly not

why: fixed per-call overhead (Python, validation, frame construction) amortises fast, then flattens

Both series share one y-axis here for comparison; read the labels, not the units

Why this matters

Batch size is a dial with a knee. Finding the knee empirically, against the actual latency budget, is a half-hour experiment that routinely multiplies capacity by an order of magnitude.

Common misconception

Wrong idea: bigger batches are always more efficient.

Correct idea: bigger batches are more efficient per row and slower per request, because a request waits for the batch to fill. Past the knee you are paying real latency for a throughput gain you cannot use.

import time, numpy as np

# Caching: key on the feature values AND the model version, with a TTL.
_cache = {}
TTL_S = 300

def score_cached(key, features, model, version):
    now = time.monotonic()
    hit = _cache.get((version, key))
    if hit and now - hit[1] < TTL_S:
        return hit[0]
    value = float(model.predict_proba(features)[0, 1])
    _cache[(version, key)] = (value, now)
    return value

# Batching: per-row cost collapses as the batch grows.
for size in (1, 8, 32, 128, 512):
    rows = X_val.sample(size, replace=True, random_state=0)
    t0 = time.perf_counter()
    pipe.predict_proba(rows)
    per_row = (time.perf_counter() - t0) * 1000 / size
    print(f'batch {size:>4}  {per_row:6.3f} ms/row')

Never key a cache on the entity id alone. Features change, and a user-id-keyed cache serves last hour's prediction for this hour's behaviour — which looks exactly like model staleness and is nearly impossible to diagnose from metrics. Key on the model version plus a hash of the feature values, and set a TTL shorter than the freshness the product needs.

The lifecycle, end to end

What it is

Problem framing and metric choice; data collection and a versioned snapshot; offline experimentation with a logged run history; a pipeline artifact registered with its metadata; a skew test against a golden sample; shadow mode; a canary ramp with guardrails; a powered A/B test; full rollout; monitoring with alerts and a retraining trigger; and a retirement plan.

Why it matters

Most of the failures in this module are transitions between these stages rather than defects within them. Writing the sequence down makes the handoffs explicit — and makes it obvious which stage a project has actually reached, as opposed to which one it is talking about.

In the real world: From data to training, deployment, monitoring and retraining: how a churn model stays useful for years.

STAGES = [
    ('frame',     'primary metric and guardrails written down, with a decision threshold'),
    ('data',      'versioned snapshot, leakage audit clean, point-in-time joins verified'),
    ('experiment','runs logged: data + code + params + seed + env + metrics'),
    ('package',   'one Pipeline artifact registered with meta.json and an owner'),
    ('skew test', 'golden sample scores identically through offline and serving paths'),
    ('shadow',    'live traffic scored, nothing acted on; distributions and latency checked'),
    ('canary',    '1/5/25/50/100% with automatic rollback on guardrail breach'),
    ('ab test',   'powered per the MDE calculation; primary metric only'),
    ('monitor',   'drift, prediction distribution, latency, delayed-label performance'),
    ('retire',    'successor promoted, artifact archived, alerts and jobs removed'),
]

for name, exit_criterion in STAGES:
    print(f'{name:<10} -> {exit_criterion}')

The stage teams skip is the skew test, because it feels redundant after a good offline evaluation — and it is the one that catches the failure mode with the largest expected cost. The stage teams forget entirely is retirement: a decommissioned model whose scheduled retraining job still runs quietly produces artifacts nobody validates, until one of them is promoted by mistake.