Deployment and Monitoring

Week 17 of 18 · Production · 7 days

Full curriculum
Week 17 · Production

Deployment and Monitoring

Week 17 · Day 1 of 7

From a Function to a Service

The interface, where preprocessing belongs, and loading once

By 753 words

A trained model is a function from tensors to tensors. A product is something a caller can send a request to and get an answer from, that keeps working when the caller sends something unexpected, and that tells you when it has stopped being right. This week builds the distance between those two things.

The interface is the design decision

Before any code, decide what crosses the boundary. The model wants a normalised float tensor of a particular shape. A caller has a picture. Somewhere between the two sits preprocessing, and where you put it decides what can go wrong.

  • Caller sends raw input, service preprocesses. One implementation of the preprocessing, in one place, versioned with the model. Almost always right.
  • Caller sends a preprocessed tensor. Now every caller has its own copy of your normalisation constants, and they will drift apart. Day 3 measures what that costs.
import os, io, json, time, base64
import numpy as np
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
from torchvision import datasets, transforms

MEAN, STD = 0.1307, 0.3081
tf_ = transforms.Compose([transforms.ToTensor(),
transforms.Normalize((MEAN,), (STD,))])
train_all = datasets.MNIST('data', train=True, download=True, transform=tf_)
test_all = datasets.MNIST('data', train=False, download=True, transform=tf_)

def stack(ds, n):
xs = torch.stack([ds[i][0] for i in range(n)])
ys = torch.tensor([ds[i][1] for i in range(n)])
return xs, ys

xtr, ytr = stack(train_all, 12000)
xte, yte = stack(test_all, 2000)

def build():
return nn.Sequential(
nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(), nn.Linear(32 * 7 * 7, 64), nn.ReLU(),
nn.Linear(64, 10))

def get_model(path='served.pt', epochs=6):
"""Train once, then reuse the checkpoint on later runs."""
model = build()
if os.path.exists(path):
model.load_state_dict(torch.load(path))
model.eval()
return model
torch.manual_seed(0)
model = build()
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
lf = nn.CrossEntropyLoss()
loader = DataLoader(TensorDataset(xtr, ytr), batch_size=128,
shuffle=True)
for _ in range(epochs):
model.train()
for xb, yb in loader:
opt.zero_grad()
lf(model(xb), yb).backward()
opt.step()
model.eval()
torch.save(model.state_dict(), path)
return model

def accuracy(model, x, y):
model.eval()
with torch.no_grad():
return (model(x).argmax(1) == y).float().mean().item()
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field

model = get_model()
app = FastAPI()

class Request(BaseModel):
# 784 raw pixels in 0 to 255, exactly what a caller would send
pixels: list[float] = Field(min_length=784, max_length=784)

def preprocess(pixels):
"""The one function that must match training. Note the normalise:
it is the same MEAN and STD the training transform used, and it is
the single most common thing to get wrong here."""

x = torch.tensor(pixels, dtype=torch.float32).reshape(1, 1, 28, 28)
return (x / 255.0 - MEAN) / STD

@app.post('/predict')
def predict(req: Request):
if max(req.pixels) > 255 or min(req.pixels) < 0:
raise HTTPException(422, 'pixels must be between 0 and 255')
x = preprocess(req.pixels)
with torch.no_grad():
probs = torch.softmax(model(x), dim=1)[0]
top = int(probs.argmax())
return {'digit': top, 'confidence': round(float(probs[top]), 4)}

@app.get('/health')
def health():
return {'status': 'ok', 'params': sum(p.numel()
for p in model.parameters())}

client = TestClient(app)

def raw_pixels(i):
"""Undo the training normalisation to get back what a caller sends."""
return ((xte[i, 0] * STD + MEAN) * 255.0).flatten().tolist()
print(client.get('/health').json())
r = client.post('/predict', json={'pixels': raw_pixels(0)})
print('status %d %s' % (r.status_code, r.json()))
print('true label %d' % yte[0])
{'status': 'ok', 'params': 105866}
status 200 {'digit': 7, 'confidence': 1.0}
true label 7

That is a complete service. The request carries 784 raw pixel values in the range a caller would actually have, the service normalises them using the same constants the training transform used, and it returns a digit and a confidence. The health endpoint exists so that whatever is running the service can tell whether it came up.

Return the confidence, always

It costs nothing, and without it the caller cannot distinguish a prediction the model is certain of from one it is guessing at. It is also the number day 6 uses to detect that the model has stopped working, and you cannot collect it retrospectively.

Why the model loads once

The model is constructed at import time and held in memory. Loading a checkpoint takes far longer than a forward pass, so a service that loads per request spends nearly all of its time on the wrong thing. This is stated because it is a genuinely common mistake, and because it is invisible until you measure.

Week 17 · Day 2 of 7

Requests You Did Not Expect

Validation, status codes, and the inputs no check will catch

By 693 words

Every input that reaches the model is one somebody could have sent deliberately. Validation is not politeness, it is the boundary between a bad request and an exception in your inference path.

import os, io, json, time, base64
import numpy as np
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
from torchvision import datasets, transforms

MEAN, STD = 0.1307, 0.3081
tf_ = transforms.Compose([transforms.ToTensor(),
transforms.Normalize((MEAN,), (STD,))])
train_all = datasets.MNIST('data', train=True, download=True, transform=tf_)
test_all = datasets.MNIST('data', train=False, download=True, transform=tf_)

def stack(ds, n):
xs = torch.stack([ds[i][0] for i in range(n)])
ys = torch.tensor([ds[i][1] for i in range(n)])
return xs, ys

xtr, ytr = stack(train_all, 12000)
xte, yte = stack(test_all, 2000)

def build():
return nn.Sequential(
nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(), nn.Linear(32 * 7 * 7, 64), nn.ReLU(),
nn.Linear(64, 10))

def get_model(path='served.pt', epochs=6):
"""Train once, then reuse the checkpoint on later runs."""
model = build()
if os.path.exists(path):
model.load_state_dict(torch.load(path))
model.eval()
return model
torch.manual_seed(0)
model = build()
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
lf = nn.CrossEntropyLoss()
loader = DataLoader(TensorDataset(xtr, ytr), batch_size=128,
shuffle=True)
for _ in range(epochs):
model.train()
for xb, yb in loader:
opt.zero_grad()
lf(model(xb), yb).backward()
opt.step()
model.eval()
torch.save(model.state_dict(), path)
return model

def accuracy(model, x, y):
model.eval()
with torch.no_grad():
return (model(x).argmax(1) == y).float().mean().item()
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field

model = get_model()
app = FastAPI()

class Request(BaseModel):
# 784 raw pixels in 0 to 255, exactly what a caller would send
pixels: list[float] = Field(min_length=784, max_length=784)

def preprocess(pixels):
"""The one function that must match training. Note the normalise:
it is the same MEAN and STD the training transform used, and it is
the single most common thing to get wrong here."""

x = torch.tensor(pixels, dtype=torch.float32).reshape(1, 1, 28, 28)
return (x / 255.0 - MEAN) / STD

@app.post('/predict')
def predict(req: Request):
if max(req.pixels) > 255 or min(req.pixels) < 0:
raise HTTPException(422, 'pixels must be between 0 and 255')
x = preprocess(req.pixels)
with torch.no_grad():
probs = torch.softmax(model(x), dim=1)[0]
top = int(probs.argmax())
return {'digit': top, 'confidence': round(float(probs[top]), 4)}

@app.get('/health')
def health():
return {'status': 'ok', 'params': sum(p.numel()
for p in model.parameters())}

client = TestClient(app)

def raw_pixels(i):
"""Undo the training normalisation to get back what a caller sends."""
return ((xte[i, 0] * STD + MEAN) * 255.0).flatten().tolist()
cases = [('784 pixels, all valid', {'pixels': raw_pixels(1)}),
('too few pixels', {'pixels': [0.0] * 100}),
('out of range', {'pixels': [999.0] * 784}),
('wrong field name', {'image': raw_pixels(1)})]
for name, payload in cases:
r = client.post('/predict', json=payload)
print('%-24s %d' % (name, r.status_code))
784 pixels, all valid 200
too few pixels 422
out of range 422
wrong field name 422

Three different malformed requests, three 422 responses, no traceback and no crash. Two of those were rejected by the type declaration alone, before any of your code ran, because the length constraint is part of the schema. The third needed an explicit check.

422 against 500: A 422 says the caller sent something invalid and should fix it. A 500 says the service broke. Getting this wrong matters operationally: 500s should page somebody and 422s should not, so a service that returns 500 for bad input trains its operators to ignore alerts.

What validation cannot catch

Every check above is about shape and range. None of them would notice a request containing 784 perfectly valid numbers that happen to be pure noise, or an upside-down digit, or a photograph of a cat. The model will answer confidently and the response will look exactly like a good one.

This is the thing to internalise about serving a model as opposed to serving a database query. There is no input the model will refuse. Detecting the inputs it should have refused is a statistical question answered over many requests, which is days 5 and 6, and it cannot be answered per request by validation.

Week 17 · Day 3 of 7

Train Serve Skew

Four preprocessing mistakes, measured, and why the gentle ones are worst

By 623 words

The single most common production failure in machine learning is not a bad model. It is a model receiving data prepared differently from the data it was trained on.

Train serve skew: Any difference between the preprocessing applied during training and the preprocessing applied at inference. It produces no error, and it degrades accuracy by an amount that depends entirely on the model and the mistake.
import os, io, json, time, base64
import numpy as np
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
from torchvision import datasets, transforms

MEAN, STD = 0.1307, 0.3081
tf_ = transforms.Compose([transforms.ToTensor(),
transforms.Normalize((MEAN,), (STD,))])
train_all = datasets.MNIST('data', train=True, download=True, transform=tf_)
test_all = datasets.MNIST('data', train=False, download=True, transform=tf_)

def stack(ds, n):
xs = torch.stack([ds[i][0] for i in range(n)])
ys = torch.tensor([ds[i][1] for i in range(n)])
return xs, ys

xtr, ytr = stack(train_all, 12000)
xte, yte = stack(test_all, 2000)

def build():
return nn.Sequential(
nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(), nn.Linear(32 * 7 * 7, 64), nn.ReLU(),
nn.Linear(64, 10))

def get_model(path='served.pt', epochs=6):
"""Train once, then reuse the checkpoint on later runs."""
model = build()
if os.path.exists(path):
model.load_state_dict(torch.load(path))
model.eval()
return model
torch.manual_seed(0)
model = build()
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
lf = nn.CrossEntropyLoss()
loader = DataLoader(TensorDataset(xtr, ytr), batch_size=128,
shuffle=True)
for _ in range(epochs):
model.train()
for xb, yb in loader:
opt.zero_grad()
lf(model(xb), yb).backward()
opt.step()
model.eval()
torch.save(model.state_dict(), path)
return model

def accuracy(model, x, y):
model.eval()
with torch.no_grad():
return (model(x).argmax(1) == y).float().mean().item()
model = get_model()
print('%-46s %10s' % ('preprocessing used at serving time', 'accuracy'))
raw = xte * STD + MEAN
variants = [('the same as training', (raw - MEAN) / STD),
('forgot to normalise', raw),
('normalised with 0.5 and 0.5', (raw - 0.5) / 0.5),
('forgot to divide by 255', raw * 255.0)]
for name, x in variants:
print('%-46s %10.4f' % (name, accuracy(model, x, yte)))
preprocessing used at serving time accuracy
the same as training 0.9730
forgot to normalise 0.9530
normalised with 0.5 and 0.5 0.8970
forgot to divide by 255 0.9640

Read those numbers carefully, because the lesson is the opposite of the one usually drawn. Correct preprocessing gives 0.9730. Feeding values a hundred times too large gives 0.9640. Skipping normalisation entirely gives 0.9530. Using somebody else's constants, which is the most realistic of the three mistakes, gives 0.8970.

The danger is that it degrades gently

Not one of those broke. A bug that multiplies every input by 255 cost about one point of accuracy, which no dashboard would flag and no smoke test would catch.

The reason is that a stack of linear layers and ReLUs is nearly scale-equivariant: multiply the input by a positive constant and the pre-activations scale with it, so the ordering of the outputs, and therefore the prediction, is largely preserved. The model tolerates the bug, which is exactly why the bug survives to production and sits there costing a point of accuracy forever.

How to make it impossible

  • Put preprocessing in one function, in the same module as the model, and call it from both the training pipeline and the service. Not two functions that agree, one function.
  • Save the constants with the checkpoint rather than writing them in two files.
  • Keep a handful of fixed examples with known outputs and assert on them at startup. Any preprocessing change moves those numbers, and the service refuses to start.
  • Log a summary of the input distribution and compare it against the training set. A mean and a standard deviation would have caught every row of that table.
Week 17 · Day 4 of 7

Latency, Throughput and Batching

Percentiles instead of averages, and where the knee is

By 571 words

Latency and throughput pull in opposite directions and the tool that resolves them is batching. Here is the actual shape of the trade on one model.

import os, io, json, time, base64
import numpy as np
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
from torchvision import datasets, transforms

MEAN, STD = 0.1307, 0.3081
tf_ = transforms.Compose([transforms.ToTensor(),
transforms.Normalize((MEAN,), (STD,))])
train_all = datasets.MNIST('data', train=True, download=True, transform=tf_)
test_all = datasets.MNIST('data', train=False, download=True, transform=tf_)

def stack(ds, n):
xs = torch.stack([ds[i][0] for i in range(n)])
ys = torch.tensor([ds[i][1] for i in range(n)])
return xs, ys

xtr, ytr = stack(train_all, 12000)
xte, yte = stack(test_all, 2000)

def build():
return nn.Sequential(
nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(), nn.Linear(32 * 7 * 7, 64), nn.ReLU(),
nn.Linear(64, 10))

def get_model(path='served.pt', epochs=6):
"""Train once, then reuse the checkpoint on later runs."""
model = build()
if os.path.exists(path):
model.load_state_dict(torch.load(path))
model.eval()
return model
torch.manual_seed(0)
model = build()
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
lf = nn.CrossEntropyLoss()
loader = DataLoader(TensorDataset(xtr, ytr), batch_size=128,
shuffle=True)
for _ in range(epochs):
model.train()
for xb, yb in loader:
opt.zero_grad()
lf(model(xb), yb).backward()
opt.step()
model.eval()
torch.save(model.state_dict(), path)
return model

def accuracy(model, x, y):
model.eval()
with torch.no_grad():
return (model(x).argmax(1) == y).float().mean().item()
model = get_model()
def percentiles(batch, n=100):
x = xte[:batch]
times = []
with torch.no_grad():
for _ in range(10):
model(x)
for _ in range(n):
start = time.perf_counter()
model(x)
times.append((time.perf_counter() - start) * 1000)
times.sort()
return (times[len(times) // 2], times[int(len(times) * 0.95)],
times[int(len(times) * 0.99)])

print('%8s %10s %10s %10s %16s'
% ('batch', 'p50 ms', 'p95 ms', 'p99 ms', 'images/second'))
for batch in [1, 8, 32, 128]:
p50, p95, p99 = percentiles(batch)
print('%8d %10.2f %10.2f %10.2f %16.0f'
% (batch, p50, p95, p99, batch / (p50 / 1000)))
batch p50 ms p95 ms p99 ms images/second
1 0.10 0.14 0.50 10309
8 0.58 0.96 1.70 13819
32 2.56 3.00 3.37 12494
128 7.34 10.69 11.62 17442

Going from one image to 128 multiplies throughput by about 2.6 and multiplies the time an individual request waits by about 49. Notice also that most of the throughput gain arrives by batch 32, and the step from 32 to 128 nearly quadruples latency for four percent more images per second. The knee is worth finding.

Report percentiles, not averages

At batch 1 the median is 0.27 ms and the 99th percentile is 0.75, nearly three times higher. An average would have hidden that. Users experience the tail, and a service whose mean latency is fine and whose p99 is terrible is a service that is visibly slow to a percent of requests, which at any volume is a lot of people.

Dynamic batching, and why it is not free

A server can hold requests for a few milliseconds and run whatever has arrived as one batch. It is how inference servers reach high throughput on expensive hardware.

The cost is that every request now waits for the window even when the service is idle, so a system with low traffic gets worse latency for no throughput benefit at all. Batching pays when you are saturated. Below that it is a pure loss, and it is often switched on by default.

Week 17 · Day 5 of 7

Noticing Without Labels

Confidence as a drift signal, and the scale problem that makes it dangerous

By 783 words

The service works, it is validated, and it is fast. Now assume that in three months something upstream changes: a camera is replaced, a client updates its image library, the population using the product shifts. Nobody tells you. No error is raised. How would you find out?

Not from accuracy, because accuracy needs labels and in production labels arrive late, arrive partially, or never arrive. Everything available in the moment is label-free, and the question is whether any of it is a usable substitute.

import os, io, json, time, base64
import numpy as np
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
from torchvision import datasets, transforms

MEAN, STD = 0.1307, 0.3081
tf_ = transforms.Compose([transforms.ToTensor(),
transforms.Normalize((MEAN,), (STD,))])
train_all = datasets.MNIST('data', train=True, download=True, transform=tf_)
test_all = datasets.MNIST('data', train=False, download=True, transform=tf_)

def stack(ds, n):
xs = torch.stack([ds[i][0] for i in range(n)])
ys = torch.tensor([ds[i][1] for i in range(n)])
return xs, ys

xtr, ytr = stack(train_all, 12000)
xte, yte = stack(test_all, 2000)

def build():
return nn.Sequential(
nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(), nn.Linear(32 * 7 * 7, 64), nn.ReLU(),
nn.Linear(64, 10))

def get_model(path='served.pt', epochs=6):
"""Train once, then reuse the checkpoint on later runs."""
model = build()
if os.path.exists(path):
model.load_state_dict(torch.load(path))
model.eval()
return model
torch.manual_seed(0)
model = build()
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
lf = nn.CrossEntropyLoss()
loader = DataLoader(TensorDataset(xtr, ytr), batch_size=128,
shuffle=True)
for _ in range(epochs):
model.train()
for xb, yb in loader:
opt.zero_grad()
lf(model(xb), yb).backward()
opt.step()
model.eval()
torch.save(model.state_dict(), path)
return model

def accuracy(model, x, y):
model.eval()
with torch.no_grad():
return (model(x).argmax(1) == y).float().mean().item()
def corrupt(x, level):
"""Stand in for the world changing: the camera drifts out of focus
and picks up sensor noise."""

if level == 0:
return x
torch.manual_seed(level)
shifted = torch.roll(x, shifts=int(level), dims=3)
blur = nn.functional.avg_pool2d(shifted, 3, stride=1, padding=1)
mix = 1 - level / 10.0
out = mix * shifted + (1 - mix) * blur
return out + (level / 40.0) * torch.randn_like(out)

@torch.no_grad()
def watch(model, x, y):
"""Everything a monitor can see without labels, plus the accuracy
it cannot see, so we can check whether the signals track it."""

model.eval()
probs = torch.softmax(model(x), dim=1)
conf, pred = probs.max(1)
entropy = -(probs * (probs + 1e-9).log()).sum(1)
return {'accuracy': (pred == y).float().mean().item(),
'mean confidence': conf.mean().item(),
'mean entropy': entropy.mean().item(),
'share below 0.9': (conf < 0.9).float().mean().item()}
model = get_model()
keys = ['accuracy', 'mean confidence', 'mean entropy', 'share below 0.9']
print('%8s %10s %16s %14s %16s'
% (('level',) + tuple(keys)))
rows = []
for level in [0, 1, 2, 3, 4, 6, 8]:
m = watch(model, corrupt(xte, level), yte)
rows.append(m)
print('%8d %10.4f %16.4f %14.4f %16.4f'
% ((level,) + tuple(m[k] for k in keys)))

acc = np.array([r['accuracy'] for r in rows])
conf = np.array([r['mean confidence'] for r in rows])
print()
print('correlation between accuracy and mean confidence %.4f'
% float(np.corrcoef(acc, conf)[0, 1]))
level accuracy mean confidence mean entropy share below 0.9
0 0.9730 0.9708 0.0864 0.0775
1 0.9610 0.9633 0.1100 0.1035
2 0.9170 0.9290 0.2050 0.2180
3 0.7790 0.8562 0.3774 0.4135
4 0.5340 0.7940 0.5440 0.5920
6 0.2135 0.7679 0.6427 0.6210
8 0.1100 0.7467 0.6978 0.6615

correlation between accuracy and mean confidence 0.9530

The good news is that the label-free signals do track the accuracy nobody can see: mean confidence correlates with it at 0.95 across this sweep. If confidence falls, something has changed, and you did not need a single label to know.

The scale is wildly wrong, and that is the whole problem

Go to the last row. Accuracy has collapsed from 0.973 to 0.110, which is barely above guessing. Mean confidence over the same collapse fell only from 0.971 to 0.747.

The model is wrong about nine times out of ten and still telling you it is three-quarters sure. Any alert threshold set on mean confidence in the obvious way, say fire below 0.7, would never have fired at all. This is the well-known overconfidence of neural networks off their training distribution, and it means confidence is a usable relative signal and a dangerous absolute one.

The fourth column is the better instrument. The share of predictions below 0.9 goes from 0.078 to 0.662, a factor of eight and a half, against mean confidence's factor of 1.3. Counting how many predictions fall below a threshold is far more sensitive than averaging the confidence, because the average is dominated by the many cases that remain easy.

Week 17 · Day 6 of 7

Monitoring That Would Actually Fire

What to log, what to alert on, and setting thresholds from a baseline

By 812 words

Turning yesterday's observation into something that can page somebody at three in the morning.

import os, io, json, time, base64
import numpy as np
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
from torchvision import datasets, transforms

MEAN, STD = 0.1307, 0.3081
tf_ = transforms.Compose([transforms.ToTensor(),
transforms.Normalize((MEAN,), (STD,))])
train_all = datasets.MNIST('data', train=True, download=True, transform=tf_)
test_all = datasets.MNIST('data', train=False, download=True, transform=tf_)

def stack(ds, n):
xs = torch.stack([ds[i][0] for i in range(n)])
ys = torch.tensor([ds[i][1] for i in range(n)])
return xs, ys

xtr, ytr = stack(train_all, 12000)
xte, yte = stack(test_all, 2000)

def build():
return nn.Sequential(
nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(), nn.Linear(32 * 7 * 7, 64), nn.ReLU(),
nn.Linear(64, 10))

def get_model(path='served.pt', epochs=6):
"""Train once, then reuse the checkpoint on later runs."""
model = build()
if os.path.exists(path):
model.load_state_dict(torch.load(path))
model.eval()
return model
torch.manual_seed(0)
model = build()
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
lf = nn.CrossEntropyLoss()
loader = DataLoader(TensorDataset(xtr, ytr), batch_size=128,
shuffle=True)
for _ in range(epochs):
model.train()
for xb, yb in loader:
opt.zero_grad()
lf(model(xb), yb).backward()
opt.step()
model.eval()
torch.save(model.state_dict(), path)
return model

def accuracy(model, x, y):
model.eval()
with torch.no_grad():
return (model(x).argmax(1) == y).float().mean().item()
def corrupt(x, level):
"""Stand in for the world changing: the camera drifts out of focus
and picks up sensor noise."""

if level == 0:
return x
torch.manual_seed(level)
shifted = torch.roll(x, shifts=int(level), dims=3)
blur = nn.functional.avg_pool2d(shifted, 3, stride=1, padding=1)
mix = 1 - level / 10.0
out = mix * shifted + (1 - mix) * blur
return out + (level / 40.0) * torch.randn_like(out)

@torch.no_grad()
def watch(model, x, y):
"""Everything a monitor can see without labels, plus the accuracy
it cannot see, so we can check whether the signals track it."""

model.eval()
probs = torch.softmax(model(x), dim=1)
conf, pred = probs.max(1)
entropy = -(probs * (probs + 1e-9).log()).sum(1)
return {'accuracy': (pred == y).float().mean().item(),
'mean confidence': conf.mean().item(),
'mean entropy': entropy.mean().item(),
'share below 0.9': (conf < 0.9).float().mean().item()}
model = get_model()
clean = watch(model, xte[:200], yte[:200])
broken = watch(model, corrupt(xte[:200], 8), yte[:200])
print('%-20s %12s %12s' % ('', 'clean', 'corrupted'))
for k in ['accuracy', 'mean confidence', 'share below 0.9']:
print('%-20s %12.4f %12.4f' % (k, clean[k], broken[k]))
clean corrupted
accuracy 0.9950 0.1250
mean confidence 0.9830 0.7463
share below 0.9 0.0550 0.6600

On a realistic monitoring window of two hundred requests the signal is unmistakable in the right column and nearly invisible in the middle one. Twelve times as many low-confidence predictions is not something that happens by chance; a mean moving from 0.98 to 0.75 could plausibly be a quiet afternoon.

What to log, per request

  • The predicted class and the confidence. These are the monitoring signal and they cost nothing.
  • A summary of the input, not the input itself: mean, standard deviation, shape. Enough to detect that the distribution moved without storing anybody's data.
  • The model version, so that a change in behaviour can be attributed to a deployment.
  • Latency, so that today's slowdown can be separated from today's accuracy problem.

Logging the input is a decision, not a default

Storing raw requests makes debugging enormously easier and turns your monitoring system into a store of user data, with everything that implies for retention, access and deletion. Summaries give most of the diagnostic value with none of that. Decide deliberately, before the logs exist, because the awkward moment to discover you kept six months of personal data is when somebody asks you to delete it.

What to alert on

  1. The share of predictions below a confidence threshold, compared against a baseline measured on known-good traffic. This is the sensitive one.
  2. The distribution of predicted classes. A model that suddenly answers with one class far more often has usually broken, and this needs no confidence at all.
  3. Input summary statistics against the training distribution, which catches preprocessing changes upstream.
  4. Latency percentiles and error rates, which are ordinary service monitoring and catch the failures that have nothing to do with the model.

Set every threshold from a measured baseline rather than from intuition. Day 5 is the demonstration of why: an intuitive threshold on mean confidence would have sat quietly through a total collapse.

And get some labels

Every signal here is a proxy. None of them tells you the model is wrong, only that something changed. The only cure is labels, and the practical answer is to label a small sample continuously rather than a large sample never. A few hundred labelled requests a week gives a real accuracy number with a real error bar, and it is what converts the monitoring above from an alarm into a diagnosis.

Week 17 · Day 7 of 7

The Deployment Checklist

Before, after, and the failure mode that defines the week

By 293 words

The whole path, in the order you would build it.

Before deploying

  1. One preprocessing function, shared by training and serving, with the constants stored alongside the weights.
  2. A handful of fixed examples with known outputs, asserted at startup.
  3. Input validation that returns 422, covering shape, range and type.
  4. The model loaded once at startup, not per request.
  5. A health endpoint, and a version string in every response.
  6. Latency percentiles measured at a realistic batch size, on hardware resembling production.

After deploying

  1. Log prediction, confidence, input summary, model version and latency, on every request.
  2. Establish a baseline for each of those on traffic you know is good.
  3. Alert on the share of low-confidence predictions and on the class distribution, using thresholds derived from that baseline.
  4. Label a small sample continuously.
  5. Keep the previous model deployable, and know how to roll back without a rebuild.

The failure mode that defines this week

Software fails loudly. A machine learning system fails quietly: it returns a well-formed, confident, wrong answer, with a 200 status code, at normal latency. Every practice above exists because the usual signals that something is broken are all absent.

What week 18 does

The course has now covered the whole path from a tensor to a monitored service. The last week puts it together as one project, on a dataset none of the earlier weeks used, and is specific about what to do next and what this course deliberately did not teach you.

The sentence worth keeping

A model in production is not a model that finished training. It is a model whose inputs you are watching, whose confidence you are recording, whose preprocessing is shared with training by construction rather than by agreement, and which you can replace on an afternoon's notice.