PROMETHEUS OS
NATIVE AI OPERATING SYSTEM
[INIT] Prometheus OS loading...
[CORE] Neural interface initializing...
[AI] LLM router connecting...
[MEM] Memory graph loading...
[AGT] Agent chains on standby...
[SEC] Security protocols active...
[NET] Provider mesh established...
[UI] Workspace rendering...
[DONE] All systems nominal.
Welcome to PROMETHEUS OS.
READY · OFFLINE · SECURE · PRIVATE
🎨 THEME ENGINE
PROMETHEUS OS · 23 THEMES
Trial version: Dark + Light themes only.
Full theme access with PROMETHEUS OS license.
Dark theme active
Experience all 23 themes in PROMETHEUS OS

Capstone: Fine-Tune and Ship an Image Classifier

Advanced 18 min· 4 exercises

Fine-tune a pretrained ResNet-50 on the Oxford-IIIT Pet breeds, evaluate it with per-class metrics and a confusion matrix, analyse its errors, and export a verified ONNX model.

By the end of this lesson you will be able to
  • Build reproducible train, validation and test splits with augmentation that matches the pretrained weights' preprocessing.
  • Implement a mixed-precision fine-tuning loop with discriminative learning rates, cosine scheduling and validation-based checkpointing.
  • Compare a linear probe with full fine-tuning and justify the difference.
  • Compute and interpret per-class precision, recall, macro-F1 and a confusion matrix, and run a structured error analysis.
  • Export a PyTorch model to ONNX and verify numerical equivalence and latency with ONNX Runtime.

Before this: Convolutional Neural Networks, Training Deep Networks in Practice, Model Evaluation and Metrics, Computer Vision: Tasks, Metrics, Models and Deployment, Deploying Models: From Artifact to Production Service

Project brief

You are the ML engineer at a pet-insurance startup. Customers upload a photo of their cat or dog when they file a claim, and the claims team wants the breed pre-filled so they can price risk and catch obviously mismatched policies. Your job is to ship a breed classifier that runs on a CPU server, report honestly how good it is, and explain where it fails. This is the most common shape of real computer-vision work: nobody trains from scratch; you take a network pretrained on millions of images, adapt it to a few thousand labelled examples, measure it carefully, and package it so another team can run it.

By the end you will have a fine-tuned model, a confusion matrix and an error analysis, an exported ONNX file that gives the same answers as your PyTorch model, and a short technical report.

Learning goals

  • Build a reproducible image pipeline: deterministic train/validation/test splits, train-time augmentation, and eval-time preprocessing that matches the pretrained weights.
  • Fine-tune a pretrained CNN (ResNet-50) and optionally a Vision Transformer, comparing a linear probe against full fine-tuning.
  • Write a correct training loop with mixed precision, learning-rate scheduling, checkpointing on validation accuracy, and fixed random seeds.
  • Evaluate with accuracy, per-class precision and recall, macro-F1 and a confusion matrix, and do a structured error analysis.
  • Export to ONNX and prove numerical equivalence with ONNX Runtime.

Required background

Complete or review these first: convolutional-neural-networks (what convolutions, pooling and residual blocks do), training-deep-networks (optimizers, learning-rate schedules, regularization), model-evaluation-and-metrics (precision, recall, F1, confusion matrices), computer-vision (transfer learning in vision) and, for the export milestone, deploying-models.

Setup

Environment. Python 3.10 or newer with torch, torchvision, scikit-learn, matplotlib, onnx and onnxruntime. Recent PyTorch releases use a newer ONNX exporter that may also need the onnxscript package; install it if export asks for it. Pin the versions you used in a requirements.txt and record them in your report.

Hardware. A single GPU with 8 GB or more of memory trains ResNet-50 at batch size 32 comfortably in mixed precision. If you have no GPU, free hosted notebook services (Google Colab and Kaggle Notebooks are the best known) give time-limited access to a single entry-level GPU; quotas and the GPU model change, so save checkpoints to persistent storage every epoch. Apple-silicon Macs can use PyTorch's mps device. A CPU alone can do the linear-probe milestone overnight but is too slow for full fine-tuning.

Dataset. The Oxford-IIIT Pet Dataset (Parkhi, Vedaldi, Zisserman and Jawahar, CVPR 2012) from the Visual Geometry Group at Oxford: 37 breeds (12 cats, 25 dogs), roughly 200 images per breed, 7,349 images in total, with an official trainval split of 3,680 images and a test split of 3,669. Home page: https://www.robots.ox.ac.uk/~vgg/data/pets/. The dataset page states a Creative Commons Attribution-ShareAlike 4.0 licence; check it yourself before any commercial use, cite the paper in your report, and note that some images were collected from the web, so copyright in individual photos may sit with their owners. torchvision.datasets.OxfordIIITPet downloads it for you.

Memory check. ResNet-50 has about 25.6 million parameters, so its FP32 weights are about 25.6M×4 bytes≈10225.6\text{M} \times 4\ \text{bytes} \approx 102 MB. Training also stores gradients (another 102 MB) and two AdamW moment buffers (204 MB), about 410 MB before activations. Activations dominate: they scale with batch size and image resolution, which is why batch size is the first knob to turn when you run out of memory.

Milestone 1: Data pipeline and baseline sanity checks

Build the splits once and save the indices to disk so every experiment uses the same data. Hold out 15% of trainval as validation; the official test split is touched only at the very end.

import json, torch
from torch.utils.data import DataLoader, Subset
from torchvision import datasets, transforms

ROOT, SEED = "data", 0
MEAN, STD = (0.485, 0.456, 0.406), (0.229, 0.224, 0.225)  # ImageNet stats

train_tf = transforms.Compose([
    transforms.RandomResizedCrop(224, scale=(0.6, 1.0)),
    transforms.RandomHorizontalFlip(),
    transforms.ColorJitter(0.2, 0.2, 0.2),
    transforms.ToTensor(), transforms.Normalize(MEAN, STD)])
eval_tf = transforms.Compose([
    transforms.Resize(256), transforms.CenterCrop(224),
    transforms.ToTensor(), transforms.Normalize(MEAN, STD)])

base = datasets.OxfordIIITPet(ROOT, split="trainval", download=True)
g = torch.Generator().manual_seed(SEED)
perm = torch.randperm(len(base), generator=g).tolist()
n_val = int(0.15 * len(base))
split = {"val": perm[:n_val], "train": perm[n_val:]}
json.dump(split, open("split.json", "w"))

train_ds = Subset(datasets.OxfordIIITPet(ROOT, "trainval", transform=train_tf), split["train"])
val_ds = Subset(datasets.OxfordIIITPet(ROOT, "trainval", transform=eval_tf), split["val"])
test_ds = datasets.OxfordIIITPet(ROOT, "test", transform=eval_tf, download=True)
classes = base.classes
train_dl = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=2)
val_dl = DataLoader(val_ds, batch_size=64, num_workers=2)
test_dl = DataLoader(test_ds, batch_size=64, num_workers=2)
print(len(train_ds), len(val_ds), len(test_ds), len(classes))

Two separate dataset objects are created over the same files so that validation images get the deterministic eval_tf while training images get random augmentation; wrapping one dataset and changing its transform later is a classic bug that silently augments your validation set. Before training, plot a grid of 16 augmented training images with their labels, check the class counts are roughly balanced, and compute the majority-class baseline (about 1/37, or 2.7%). Any trained model must beat that by a wide margin.

Milestone 2: Linear probe, then full fine-tuning

A linear probe freezes the pretrained backbone and trains only a new final layer. It is fast, hard to overfit, and tells you how good the pretrained features already are. Full fine-tuning then unfreezes everything with a smaller learning rate for the backbone than for the new head (discriminative learning rates), because the pretrained weights are already close to useful and large updates would destroy them.

import torch.nn as nn
from torchvision import models

def build_model(num_classes=37, freeze_backbone=True):
    weights = models.ResNet50_Weights.DEFAULT
    model = models.resnet50(weights=weights)
    for p in model.parameters():
        p.requires_grad = not freeze_backbone
    model.fc = nn.Linear(model.fc.in_features, num_classes)  # new head, trainable
    return model

def make_optimizer(model, head_lr=1e-3, backbone_lr=1e-4, wd=0.05):
    head = list(model.fc.parameters())
    head_ids = {id(p) for p in head}
    backbone = [p for p in model.parameters()
                if p.requires_grad and id(p) not in head_ids]
    groups = [{"params": head, "lr": head_lr}]
    if backbone:
        groups.append({"params": backbone, "lr": backbone_lr})
    return torch.optim.AdamW(groups, weight_decay=wd)

Run the probe for about 5 epochs, then full fine-tuning for 10 to 15 epochs starting from the ImageNet weights. Optional comparison: swap in models.vit_b_16(weights=models.ViT_B_16_Weights.DEFAULT) and replace model.heads.head; ViT-B/16 has about 86 million parameters, so expect slower epochs and a stronger dependence on augmentation and learning-rate warmup.

Milestone 3: A correct training loop

import time

def run_epoch(model, loader, device, optimizer=None, scheduler=None, scaler=None):
    train = optimizer is not None
    model.train(train)
    loss_fn = nn.CrossEntropyLoss(label_smoothing=0.1 if train else 0.0)
    total, correct, loss_sum = 0, 0, 0.0
    for x, y in loader:
        x, y = x.to(device), y.to(device)
        with torch.set_grad_enabled(train), torch.autocast(
                device_type=device, dtype=torch.float16, enabled=(device == "cuda")):
            logits = model(x)
            loss = loss_fn(logits, y)
        if train:
            optimizer.zero_grad(set_to_none=True)
            scaler.scale(loss).backward()
            scaler.step(optimizer); scaler.update(); scheduler.step()
        loss_sum += loss.item() * len(y)
        correct += (logits.argmax(1) == y).sum().item(); total += len(y)
    return loss_sum / total, correct / total

def fit(model, epochs, device):
    torch.manual_seed(SEED)
    model.to(device)
    opt = make_optimizer(model)
    sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs * len(train_dl))
    scaler = torch.amp.GradScaler(enabled=(device == "cuda"))
    best = 0.0
    for ep in range(epochs):
        t0 = time.time()
        tr = run_epoch(model, train_dl, device, opt, sched, scaler)
        va = run_epoch(model, val_dl, device)
        print(f"ep {ep} train {tr} val {va} {time.time()-t0:.0f}s")
        if va[1] > best:
            best = va[1]; torch.save(model.state_dict(), "best.pt")
    return best

device = "cuda" if torch.cuda.is_available() else "cpu"
torch.manual_seed(SEED)  # seed before the new head is initialized
probe_acc = fit(build_model(freeze_backbone=True), epochs=5, device=device)
torch.manual_seed(SEED)
model = build_model(freeze_backbone=False)
ft_acc = fit(model, epochs=12, device=device)

Each call to fit tracks its own best epoch, so the fine-tuning run overwrites the probe's best.pt; make the file name an argument of fit if you want to keep both checkpoints.

Points to notice. model.train(False) switches batch normalization to its running statistics and disables dropout; forgetting it during evaluation gives noisy, pessimistic numbers. Label smoothing (0.1) replaces the one-hot target with 0.9 on the true class and spreads 0.1 over all classes, which reduces overconfidence on a small dataset. Mixed precision (autocast plus GradScaler) roughly halves activation memory on CUDA; the scaler multiplies the loss before the backward pass so small FP16 gradients do not underflow to zero. The scheduler steps once per batch because T_max is measured in batches. Checkpoint selection uses validation accuracy, never test accuracy.

Milestone 4: Evaluation and error analysis

Load best.pt, run the test set once, and compute the confusion matrix CC, where CijC_{ij} is the number of test images whose true class is ii and predicted class is jj. For class kk:

precisionk=Ckk∑iCik,recallk=Ckk∑jCkj,F1k=2 precisionk recallkprecisionk+recallk\text{precision}_k = \frac{C_{kk}}{\sum_i C_{ik}}, \qquad \text{recall}_k = \frac{C_{kk}}{\sum_j C_{kj}}, \qquad F1_k = \frac{2\,\text{precision}_k\,\text{recall}_k}{\text{precision}_k + \text{recall}_k}

Macro-F1 is the unweighted mean of F1kF1_k over all classes, so a weak breed cannot hide behind strong ones.

Worked example. Suppose three of the colour-pointed cat breeds have 100 test images each and the slice of the confusion matrix (rows = true, columns = predicted) is:

true \ predictedSiameseBirmanRagdoll
Siamese9253
Birman48115
Ragdoll21286

Birman recall is 81/100=0.8181/100 = 0.81. Birman precision is 81/(5+81+12)=81/98=0.82781/(5+81+12) = 81/98 = 0.827. Birman F1 is 2×0.827×0.81/(0.827+0.81)=0.8182 \times 0.827 \times 0.81 / (0.827 + 0.81) = 0.818. Doing the same for the others gives Siamese precision 92/98=0.93992/98 = 0.939, recall 0.920.92, F1 0.9290.929; Ragdoll precision 86/104=0.82786/104 = 0.827, recall 0.860.86, F1 0.8430.843. Accuracy over the slice is (92+81+86)/300=0.863(92+81+86)/300 = 0.863 and macro-F1 is (0.929+0.818+0.843)/3=0.864(0.929+0.818+0.843)/3 = 0.864. The story the matrix tells is more useful than the numbers: 27 of the 41 errors are Birman and Ragdoll mistaken for each other. Both are pale, semi-long-haired cats with dark points; Birmans always have white "gloves" on their paws, but so do mitted Ragdolls, and the paws are often out of frame anyway. That is a data finding, not a model bug.

import numpy as np
from sklearn.metrics import confusion_matrix, classification_report

@torch.no_grad()
def predict(model, loader, device):
    model.eval().to(device)
    ys, ps, confs = [], [], []
    for x, y in loader:
        prob = model(x.to(device)).softmax(1).cpu()
        ys.append(y); ps.append(prob.argmax(1)); confs.append(prob.max(1).values)
    return torch.cat(ys).numpy(), torch.cat(ps).numpy(), torch.cat(confs).numpy()

model.load_state_dict(torch.load("best.pt", map_location=device))
y, p, conf = predict(model, test_dl, device)
labels = list(range(len(classes)))
cm = confusion_matrix(y, p, labels=labels)
print(classification_report(y, p, labels=labels, target_names=classes, digits=3))
off = cm.copy(); np.fill_diagonal(off, 0)
for i, j in zip(*np.unravel_index(np.argsort(off, axis=None)[::-1][:8], off.shape)):
    print(f"{classes[i]:>28} -> {classes[j]:<28} {off[i, j]}")
wrong = np.where(y != p)[0]
print("most confident mistakes:", wrong[np.argsort(-conf[wrong])][:20])

For the error analysis, view the 20 most confident mistakes and tag each with a cause: look-alike breeds, label noise (the annotation looks wrong), unusual pose or occlusion, multiple animals, or the animal is small in the frame. Count the tags. Then check calibration: bin predictions by confidence and compare mean confidence with accuracy in each bin. Optionally, run Grad-CAM on a few mistakes to see which pixels drove the decision.

Milestone 5: Export to ONNX and verify

ONNX is an open format for computation graphs, and ONNX Runtime executes it on CPUs and many accelerators without PyTorch installed. Export in eval mode with a dynamic batch dimension, then prove the exported model matches.

import numpy as np, onnxruntime as ort

model.load_state_dict(torch.load("best.pt", map_location="cpu"))
model.eval().cpu()
dummy = torch.randn(1, 3, 224, 224)
torch.onnx.export(model, (dummy,), "pets_resnet50.onnx",
                  input_names=["image"], output_names=["logits"],
                  dynamic_axes={"image": {0: "batch"}, "logits": {0: "batch"}})

sess = ort.InferenceSession("pets_resnet50.onnx", providers=["CPUExecutionProvider"])
x = torch.randn(8, 3, 224, 224)
with torch.no_grad():
    ref = model(x).numpy()
out = sess.run(None, {"image": x.numpy()})[0]
print("max abs diff:", np.abs(ref - out).max())
print("argmax agree:", (ref.argmax(1) == out.argmax(1)).mean())

Recent PyTorch releases also offer (and in newer versions default to) an exporter built on torch.export, which describes variable dimensions with a dynamic_shapes argument; dynamic_axes is the classic form. If your version warns about or rejects it, follow the torch.onnx documentation for your release rather than guessing. A maximum absolute difference around 10−410^{-4} or smaller on the logits is normal floating-point noise; argmax agreement should be 100% on real test images. Ship the preprocessing contract with the model (resize 256, center crop 224, ImageNet mean and standard deviation, RGB channel order) in a small JSON file, because the most common production bug is preprocessing that differs from training. Measure CPU latency at batch size 1 (median and 95th percentile over 100 runs after 10 warm-up runs).

Evaluation criteria and target metrics

The targets below are this course's thresholds, chosen so that a correct pipeline should clear them; they are not published benchmark results. If you are far below them, suspect a bug (wrong normalization, evaluation in train mode, label misalignment) before you suspect the model.

MetricMinimumStrong
Linear-probe test accuracy (ResNet-50)80%88%
Fine-tuned test accuracy88%92% or more
Macro-F1 on test0.870.91 or more
ONNX vs PyTorch argmax agreement100%100%
CPU latency, batch 1, medianmeasured and reportedunder 100 ms on a modern laptop CPU

Grading rubric

CriterionExcellent (4)Proficient (3)Developing (2)Insufficient (1)
Data pipeline (15%)Fixed, saved splits; correct eval transforms; augmentation visualizedCorrect splits and transformsSplits not saved or minor transform mismatchTest data used for tuning or leakage
Training (25%)Probe and fine-tune compared; seeds fixed; curves plotted; justified hyperparametersFine-tune works and meets minimum targetTrains but below target or unstableDoes not train or results not reproducible
Evaluation (20%)Per-class metrics, confusion matrix, calibration checkPer-class metrics and confusion matrixAccuracy onlyWrong or missing metrics
Error analysis (20%)Tagged causes with counts, examples, and a tested fixTagged causes with examplesAnecdotal look at errorsNone
Export (10%)ONNX verified numerically, latency measured, preprocessing contract shippedONNX verified numericallyExported but not verifiedNot exported
Report (10%)Clear, honest, limitations and licence notedComplete templateMissing sectionsMissing

Stretch goals

  • Test-time augmentation (average predictions over the image and its horizontal flip) and measure the gain.
  • Quantize the ONNX model to INT8 with ONNX Runtime's quantization tools and report the accuracy and latency trade-off.
  • Train a two-level model: cat versus dog first, then breed, and compare error patterns.
  • Add an "unknown" decision: reject images whose top probability is below a threshold chosen on validation, and report coverage versus accuracy.
  • Fine-tune ViT-B/16 or a ConvNeXt and compare accuracy per parameter and per millisecond.

Write-up template

  1. Problem and users. Who uses the prediction and what a mistake costs.
  2. Data. Source, licence, split sizes, how splits were made, class balance.
  3. Method. Architecture, pretrained weights, augmentation, optimizer, learning rates, schedule, epochs, hardware, wall-clock time, library versions.
  4. Results. Table of probe versus fine-tune on validation and test; learning curves.
  5. Error analysis. Top confused pairs, tagged error causes with counts, calibration.
  6. Deployment. ONNX file size, equivalence check, latency, preprocessing contract.
  7. Limitations and next steps. What the model should not be used for (for example, mixed breeds or species outside the 37 classes).

Common pitfalls

  • Evaluating with augmentation or in train mode, which makes validation noisy and pessimistic.
  • Choosing hyperparameters or checkpoints on the test set, which makes the reported number optimistic.
  • Using one learning rate for everything: too high wrecks pretrained features, too low leaves the new head undertrained.
  • Normalizing with the wrong statistics or feeding BGR images at inference.
  • Reporting only accuracy when the business cost is concentrated in a few classes.

In practice

Teams start with the strongest pretrained backbone their latency budget allows, run a linear probe as a smoke test, and fine-tune with conservative learning rates. Most of the improvement after that comes from data work driven by error analysis: fixing labels, collecting more examples of confused classes, and agreeing on what the model should do with out-of-scope images. Exported models are versioned together with their preprocessing and an equivalence test so that a change on either side is caught before release.

Key takeaways

  • Transfer learning from a pretrained backbone is the default for small vision datasets; a linear probe is the fastest health check.
  • Discriminative learning rates protect pretrained features while letting the new head learn quickly.
  • The confusion matrix shows which classes fail and why, which accuracy alone hides.
  • Error analysis turns mistakes into data work: look-alike classes, label noise and framing problems need different fixes.
  • An exported model is only shippable with a numerical equivalence check and a written preprocessing contract.

Terms in this lesson

Linear probe
Training only a new linear output layer on top of a frozen pretrained network to measure the quality of its features.
Discriminative learning rates
Using different learning rates for different parts of a network, typically smaller for pretrained layers and larger for new ones.
Label smoothing
Replacing one-hot training targets with a mixture of the one-hot vector and a uniform distribution to reduce overconfidence.
Confusion matrix
A table whose entry in row i and column j counts examples of true class i predicted as class j.
Macro-F1
The unweighted mean of per-class F1 scores, giving every class equal weight regardless of its size.
ONNX
Open Neural Network Exchange, an open file format for model computation graphs that many runtimes can execute.

Check your understanding

  1. 1.

    Your validation accuracy is 12% while training accuracy climbs past 90%. Which bug is most likely?

  2. 2.

    Why use a smaller learning rate for the pretrained backbone than for the new head?

  3. 3.

    For class k with C_kk = 45, column sum 60 and row sum 50, what are precision and recall?

  4. 4.

    Which check best shows that your ONNX export is correct?

  5. 5.

    Most of your errors are Birman predicted as Ragdoll and vice versa, and inspection shows the paws are usually out of frame. What is the best next step?

Exercises

Work each problem before opening the solution.

  1. Exercise 1Core

    Milestone 1 check. Your split has 3,680 trainval images and you hold out 15% for validation. How many images are in each split, and why must the validation dataset object be created separately from the training one?

    Show solution

    int(0.15 * 3680) = 552 validation images and 3680 - 552 = 3128 training images. A separate dataset object is needed because the transform is attached to the dataset: if both subsets wrap the same object, they share train_tf, so validation images get random crops, flips and colour jitter, which makes validation scores noisy and pessimistic and checkpoint selection unreliable.

  2. Exercise 2Core

    Milestone 3 check. Explain what each of these does in the training loop and what goes wrong if it is removed: (a) model.train(False) during validation, (b) scaler.scale(loss).backward(), (c) scheduler.step() inside the batch loop with T_max = epochs * len(train_dl).

    Show solution

    (a) Puts batch-norm layers in inference mode (using running mean and variance) and disables dropout. Without it, batch statistics of each validation batch are used and running statistics get updated by validation data, so scores are noisy and the model is slightly changed by evaluation. (b) Multiplies the loss by a scale factor before backpropagation so that small FP16 gradients do not underflow to zero; the scaler later unscales before the optimizer step and skips steps with infinities. Without scaling under FP16 autocast, some gradients become zero and training can stall. (c) Advances the cosine schedule once per batch. Because T_max is counted in batches, stepping once per epoch instead would leave the learning rate almost at its starting value for the whole run.

  3. Exercise 3Core

    Milestone 4 check (coding). Given a confusion matrix cm as a NumPy array, write a function that returns per-class precision, recall, F1 and macro-F1, treating 0/0 as 0. Test it on the 3x3 matrix from the lesson.

    Show solution
    import numpy as np
    
    def prf(cm):
        cm = np.asarray(cm, dtype=float)
        tp = np.diag(cm)
        pred = cm.sum(0); true = cm.sum(1)
        prec = np.divide(tp, pred, out=np.zeros_like(tp), where=pred > 0)
        rec = np.divide(tp, true, out=np.zeros_like(tp), where=true > 0)
        s = prec + rec
        f1 = np.divide(2 * prec * rec, s, out=np.zeros_like(tp), where=s > 0)
        return prec, rec, f1, f1.mean()
    
    print(prf([[92, 5, 3], [4, 81, 15], [2, 12, 86]]))
    

    Output: precision about [0.939, 0.827, 0.827], recall [0.92, 0.81, 0.86], F1 about [0.929, 0.818, 0.843], macro-F1 about 0.864, matching the worked example.

  4. Exercise 4Challenge

    Milestone 5 check. After export, PyTorch and ONNX Runtime agree to within 1e-5 on random tensors, yet the deployed service is 20 points less accurate than your test set. List three plausible causes and a test that would detect each.

    Show solution
    1. Preprocessing mismatch (resize/crop order, missing normalization, values in 0-255 instead of 0-1). Test: run the same raw JPEGs through the service's preprocessing and through eval_tf, and compare the resulting tensors element-wise.
    2. Channel order (BGR from OpenCV instead of RGB). Test: feed a pure red image and check which channel holds the high value after preprocessing; or compare accuracy with channels reversed.
    3. Class-index mapping (the service uses a differently sorted class list). Test: ship classes in the model's JSON contract and assert the service's list is identical; run 50 test images end to end and compare predicted names with the offline predictions. Other valid answers: distribution shift in real uploads (check with a labelled sample of production images), EXIF rotation not applied, or the wrong model file deployed (log and compare a hash).

Further reading

Go further

Sources

Fact-checked and last reviewed 2026-11-05

Stay in the loop
Prometheus OS™ · Native AI OS · Built for the future © 2025 - 2026