AI / Computer Vision Engineer — Complete Learning Curriculum
Target Role: AI / Computer Vision Engineer
Duration: 20 weeks (adjustable — work at your own pace)
Goal: Reach interview-ready expertise that places you in the top 1% of candidates
What You Will Build
By the end of this curriculum you will have:
- Classical CV expertise — OpenCV pipelines, feature engineering, camera geometry
- Deep learning fluency — PyTorch and TensorFlow, from tensors to custom training loops
- SOTA CV knowledge — YOLOv8, Mask R-CNN, Vision Transformers, CLIP, SAM, Diffusion
- Production engineering skills — ONNX, TensorRT, FastAPI, Docker, cloud deployment
- System design capability — scalable, GPU/TPU-accelerated inference architectures
- Interview readiness — 200+ coding problems, system design walkthroughs, concept cheatsheets
Folder Structure
cv-engineer/
├── README.md ← You are here
├── phase-00-foundations/ ← Python, NumPy, Math for ML
├── phase-01-classical-cv-opencv/ ← OpenCV, filtering, features, tracking
├── phase-02-ml-fundamentals/ ← sklearn, data pipelines, evaluation
├── phase-03-deep-learning-pytorch/ ← tensors, autograd, training, DataLoaders
├── phase-04-deep-learning-tensorflow/ ← Keras API, tf.data, custom training
├── phase-05-cv-deep-learning/ ← CNNs, transfer learning, detection, segmentation
├── phase-06-sota-models/ ← ViT, CLIP, SAM, open-vocab detection, RT-DETR, VLMs
├── phase-07-mlops-deployment/ ← ONNX, FastAPI, Docker, cloud, MLflow
├── phase-08-capstone-projects/ ← 3 end-to-end real-world projects
├── system-design/ ← Scalable CV systems, GPU/TPU, distributed training
└── interview-prep/ ← Concepts, coding problems, behavioral
20-Week Schedule
| Week | Phase | Focus |
|---|---|---|
| 1 | 0 | Python advanced patterns + NumPy for image ops |
| 2 | 0 | Math for ML: linear algebra, calculus, probability |
| 3 | 1 | Image basics, color spaces, histograms |
| 4 | 1 | Filtering, morphology, edge detection |
| 5 | 1 | Feature detection (Harris, SIFT, ORB), optical flow, tracking |
| 6 | 1–2 | Camera calibration + ML fundamentals kickoff |
| 7 | 2 | Data preprocessing, evaluation metrics (mAP, IoU, AUC) |
| 8 | 3 | PyTorch: tensors, autograd, building nn.Module |
| 9 | 3 | Training loops, loss functions, optimizers |
| 10 | 3–4 | DataLoaders + TensorFlow/Keras API |
| 11 | 4–5 | TF custom training + CNN fundamentals |
| 12 | 5 | Transfer learning: ResNet, EfficientNet, MobileNet |
| 13 | 5 | Object detection: YOLOv8 + Faster R-CNN |
| 14 | 5 | Semantic segmentation: U-Net, DeepLabV3+ |
| 15 | 5–6 | Instance segmentation + Vision Transformers |
| 16 | 6 | CLIP, SAM & open-vocab detection, RT-DETR, VLMs, Diffusion basics |
| 17 | 7 | MLOps: ONNX export, TensorRT, FastAPI inference |
| 18 | 7 | Docker, cloud (AWS/GCP), MLflow |
| 19 | 8 | Capstone Project 1 + 2 |
| 20 | 8 + Prep | Capstone Project 3 + full interview prep review |
Each Lab Structure
Every lab contains the following files:
| File | Purpose |
|---|---|
README.md | Deep theory, math derivations, algorithm internals, interview Q&A |
lab.py | Guided exercise with # TODO markers — fill in the blanks |
solution.py | Complete, production-quality solution with inline commentary |
exploration.ipynb | Jupyter notebook for visual/interactive exploration (select phases) |
requirements.txt | Pinned pip dependencies |
DATASETS.md | Download links and expected directory layout (where applicable) |
Prerequisites
- Python 3.10+ installed
- Basic Python familiarity (functions, classes, loops)
- A Linux/macOS environment or WSL2 on Windows
- GPU optional but recommended for phases 3–8 (CUDA 12+, at least 8 GB VRAM)
- Alternatively: Google Colab (free T4) or Kaggle Notebooks (free P100)
Hardware Recommendations
| Tier | Setup | Best For |
|---|---|---|
| Minimal | CPU-only laptop | Phases 0–2, small experiments |
| Mid | NVIDIA RTX 3060+ (8 GB) | Phases 3–6 comfortably |
| Recommended | NVIDIA RTX 4090 / A100 (24+ GB) | Phase 5+ with large batch sizes |
| Cloud | Google Colab Pro / Kaggle / Lambda Labs | On-demand GPU, no hardware cost |
| Enterprise | TPU v4 (GCP) / AWS Trainium | Distributed training at scale |
System Design Philosophy
Throughout this curriculum, every non-trivial solution is built with production scalability in mind:
- Throughput — how many images/second can this pipeline handle?
- Latency — what is the P99 inference time?
- Hardware efficiency — are we saturating GPU/TPU? What's the memory footprint?
- Fault tolerance — what happens if a model server crashes?
- Observability — how do we monitor model drift in production?
Each phase-7 and capstone lab explicitly addresses these dimensions.
Interview Strategy
The interview prep is organized as a running thread — not a last-minute cram. Each lab's README.md ends with interview questions and expected depth of answer. The interview-prep/ folder provides:
- Concept cheatsheets — one-page deep-dives with formulas
- ML/CV coding questions — implement from scratch with test cases
- System design — full walkthroughs for 5 common CV system design problems
- Behavioral — STAR-format answers for research presentation, cross-team collaboration
Tools & Technologies Covered
Languages: Python 3.10+, shell scripting
CV: OpenCV 4.x, Pillow, scikit-image
ML: scikit-learn, XGBoost
DL: PyTorch 2.x, TensorFlow 2.x / Keras, torchvision, timm
Detection: Ultralytics YOLOv8, Detectron2, torchvision (Faster R-CNN, Mask R-CNN)
Segmentation:U-Net, DeepLabV3+, SAM (Meta AI)
SOTA: Hugging Face Transformers, CLIP (OpenAI), Diffusers
Deployment: ONNX, TensorRT 8+, FastAPI, Triton Inference Server
Containers: Docker, docker-compose, NVIDIA Container Toolkit
Cloud: AWS SageMaker, GCP Vertex AI, S3 / GCS
Tracking: MLflow, Weights & Biases (W&B)
Augmentation:Albumentations, torchvision.transforms v2
Hardware: CUDA, cuDNN, NCCL (multi-GPU), Google TPU (JAX/XLA overview)
Quick Start
# 1. Clone / navigate to the curriculum root
cd /path/to/AI-Engineer/cv-engineer
# 2. Create a virtual environment (one per phase recommended)
python -m venv .venv && source .venv/bin/activate
# 3. Install phase-0 dependencies
pip install -r phase-00-foundations/lab-01-python-advanced/requirements.txt
# 4. Open the first lab
code phase-00-foundations/lab-01-python-advanced/lab.py
# 5. Run it
python phase-00-foundations/lab-01-python-advanced/lab.py
Mindset: This curriculum treats you as a practitioner, not a student. Every concept is paired with code that ships. Every design decision is explained. Every interview question is answered with the depth of someone who has built real systems.
Phase 0 — Foundations
Duration: 2 weeks | Prerequisite: Basic Python (functions, loops, classes)
Why This Phase Exists
AI/CV engineering is applied mathematics implemented in code. Before training a single neural network, you need to fluently manipulate multi-dimensional arrays (images are just NumPy arrays), reason about memory layouts and broadcasting, and understand the math that underpins gradient descent, attention, and convolution.
Hiring managers can tell within 10 minutes of a coding interview whether a candidate has genuine foundations or just memorized API calls. This phase ensures your foundations are unshakeable.
Labs
| Lab | Topic | Key Skills |
|---|---|---|
| lab-01-python-advanced | Python internals & patterns | generators, decorators, dataclass, __slots__, context managers, type hints |
| lab-02-numpy-matplotlib | NumPy for image ops | broadcasting, fancy indexing, strides, image manipulation, visualization |
| lab-03-math-for-ml | Linear algebra + calculus + probability | SVD, eigendecomposition, chain rule, Bayes' theorem |
Learning Outcomes
After this phase you will be able to:
- Write idiomatic, performant Python that a senior engineer would not rewrite in a PR review
- Treat images as what they really are: 3D tensors of shape
(H, W, C) - Implement matrix operations from first principles without reaching for a library
- Derive the gradient of a loss function and explain why the chain rule makes backpropagation possible
Interview Relevance
Questions from this phase appear in every CV/ML interview, often as screening filters:
- "What is broadcasting in NumPy? What are the rules?"
- "Explain SVD and name 3 applications in computer vision."
- "What is the difference between a generator and an iterator in Python?"
- "How would you implement a decorator that caches function results?"
- "What is the chain rule and how does it relate to backpropagation?"
Warmup Guide — Foundations
Zero-to-expert primer for Phase 00: the Python, NumPy, and math substrate every later phase silently assumes. This is the only concept guide for this phase — read it before the three labs.
Table of Contents
- Chapter 1: The Python That ML Code Actually Uses
- Chapter 2: NumPy — Arrays as a Computational Model
- Chapter 3: Broadcasting — The Rule Everything Relies On
- Chapter 4: Linear Algebra for Vision
- Chapter 5: Calculus — Gradients Without Mysticism
- Chapter 6: Probability for ML Decisions
- Chapter 7: Plotting as a Debugging Instrument
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: The Python That ML Code Actually Uses
The subset of "advanced Python" that ML codebases lean on daily — learn these as reading skills first:
- Mutability and references: assignment never copies;
b = aaliases. The classic ML bugs: a mutable default argument (def f(x, history=[])) accumulating across calls, and in-place tensor/array mutation visible through another name. Knowcopyvsdeepcopyand when neither is needed. - Comprehensions and generators: dataset pipelines are generator chains — lazy, memory-bounded, single-pass (a consumed generator is empty; the "why is my second epoch empty" bug).
- Decorators:
@torch.no_grad(),@property,@lru_cache,@dataclass— a decorator is justf = wrapper(f); being able to write one demystifies half the framework magic you'll meet. - Context managers:
with= guaranteed setup/teardown — files, locks,autocast(),no_grad(). Implementing one (__enter__/__exit__or@contextmanager) is a lab exercise because you'll use twenty of them. - Dunder protocol:
__getitem__/__len__is the Dataset interface;__call__is why modules are callable;__repr__is your debugging UX. - Typing: modern ML code is type-hinted (
def forward(x: Tensor) -> Tensor); hints are documentation that tools check — write them from day one.
Chapter 2: NumPy — Arrays as a Computational Model
The mental model that transfers to PyTorch/TF unchanged:
- An
ndarrayis a flat memory buffer + shape + strides + dtype. Strides (bytes to step per dimension) explain everything "weird": transpose is free (swap strides, no data movement), slicing returns views (mutate the slice, mutate the original — intentional and bug-prone), and some operations needcontiguousmemory (a copy) before they can run. - Vectorization is not optional: a Python loop over pixels runs ~100–1000× slower
than the equivalent array expression, because the loop pays interpreter+dispatch cost
per element while NumPy pays it per array. The phase's core skill is re-expressing
loops as array operations (elementwise ops, reductions with
axis=, fancy indexing,where). - dtype discipline: images arrive as
uint8(0–255); doing math inuint8overflows silently (200 + 100 = 44); convert tofloat32for processing, clip+convert back for display. This single habit prevents the most common beginner-CV bug family. - Axis fluency: an image batch is
(N, H, W, C)(or(N, C, H, W)in PyTorch);mean(axis=(1,2))is per-image-per-channel mean. Reading shapes aloud and tracking them through operations is the discipline;assert x.shape == (...)liberally in lab code.
Chapter 3: Broadcasting — The Rule Everything Relies On
The rule, exactly: align shapes right-to-left; dimensions are compatible if equal or if either is 1 (the 1 stretches, without copying); missing leading dimensions are treated as 1.
(N, H, W, C) op (C,) → per-channel (mean subtraction!)
(N, H, W, C) op (H, W, 1) → per-pixel mask across channels
(N, 1, D) op (1, M, D) → (N, M, D): all-pairs — the distance-matrix trick
Broadcasting is how normalization, masking, distance matrices, and attention scores
are written without loops — and mis-broadcasting is the classic silent bug: an
(N,) vector where (N, 1) was needed produces an (N, N) matrix and no error,
just wrong numbers downstream. Defense: keep dims explicit (keepdims=True,
x[:, None]), and assert shapes. The lab drills exactly this.
Chapter 4: Linear Algebra for Vision
What you need, with its CV meaning attached:
- Matrix multiply as transformation: rotation/scale/shear are 2×2 matrices on pixel coordinates; with homogeneous coordinates (append a 1), translation and perspective join — a 3×3 homography warps one image plane onto another (Phase 01's calibration and stitching live here).
- Dot product as similarity: $u \cdot v = |u||v|\cos\theta$ — embeddings, template matching, attention — one geometric idea everywhere (the LLM track's Phase 02 builds its whole field on it).
- Eigenvectors/SVD as structure: PCA (top eigenvectors of the covariance = directions of variance) for dimensionality reduction and whitening; SVD's low-rank approximation underlies compression and LoRA-style ideas later. Compute one PCA by hand-ish (cov → eig → project) in the lab; never again.
- Norms and distances: L2 (Euclidean — sensitive to scale: standardize first!), L1 (robust), cosine (scale-invariant). Choosing the metric is choosing what "similar" means — a recurring design decision from k-NN to retrieval.
Chapter 5: Calculus — Gradients Without Mysticism
- The gradient $\nabla f$ points uphill; learning is "step downhill": $\theta \mathrel{-}= \eta \nabla_\theta \mathcal{L}$. All of deep learning's optimization is this sentence plus engineering.
- The chain rule is the entire mechanism of backpropagation: $\frac{\partial \mathcal{L}}{\partial x} = \frac{\partial \mathcal{L}}{\partial y}\frac{\partial y}{\partial x}$ composed along the computation graph. In the lab you compute a two-layer network's gradients by hand once — after which autograd (Phase 03) is bookkeeping, not magic.
- Numerical gradient checking — $(f(x{+}\epsilon) - f(x{-}\epsilon))/2\epsilon$ — is the verification tool for every hand-derived gradient and a professional habit (every custom op in every later track gets gradchecked).
- Functions to know by derivative: sigmoid ($\sigma' = \sigma(1{-}\sigma)$ — saturates: the vanishing-gradient seed), ReLU (kills negatives — dead-neuron caveat), softmax+ cross-entropy (combined derivative is the beautifully simple $p - y$ — derive it once in the lab; it explains why that pairing is universal).
Chapter 6: Probability for ML Decisions
- Distributions as models of uncertainty: Gaussian (noise, init), Bernoulli/ categorical (labels, predictions). A classifier's softmax output is a categorical distribution — treating it as calibrated truth is a known mistake (Phase 02's evaluation chapter picks this up).
- Bayes' rule read as belief update: posterior ∝ likelihood × prior — the lens for understanding class imbalance (a 99%-accurate detector on a 1%-prevalence class may be worthless: precision/recall, Phase 02), and for generative-vs-discriminative framing.
- Expectation and variance as the vocabulary of estimators: training loss is an expectation estimated on mini-batches (hence gradient noise, hence batch-size effects later); evaluation scores carry sampling variance (hence error bars — a theme this curriculum hammers in every track).
- Sampling:
np.random.Generatorwith explicit seeds; reproducibility is a Phase-00 habit, not an afterthought.
Chapter 7: Plotting as a Debugging Instrument
Matplotlib here is not for papers — it's an instrument:
- The four debugging plots you'll make a thousand times: the image itself
(
imshow— with correct dtype/range andcmap='gray'when single-channel; half of "my preprocessing is broken" is diagnosed by looking), histograms (pixel/ activation/gradient distributions — saturation and dead units are visible), loss curves (train vs val on one axes, log-scale y when appropriate), and scatter/embedding plots (PCA projections — Ch. 4). - Mechanics worth automating early: subplot grids for batch samples, consistent
figure-saving (
savefigwith dpi and tight bbox) into a run directory — artifacts outlive terminal scrollback. - The habit: plot at every pipeline stage when something is wrong — raw → decoded → resized → normalized → augmented. The bug is visible at exactly one boundary.
Lab Walkthrough Guidance
Order: Lab 01 (Python) → Lab 02 (NumPy/Matplotlib) → Lab 03 (Math).
- Lab 01: implement the decorator, context manager, and a
Dataset-shaped class (__getitem__/__len__) — these three patterns are the rest of the curriculum's plumbing. Test the mutable-default and generator-exhaustion traps deliberately. - Lab 02: the vectorization drill — take provided loop implementations
(normalization, distance matrix, image tiling) and re-express them array-wise;
benchmark each (×100+ speedups make the point viscerally); finish with the
broadcasting-bug hunt (find the
(N,)vs(N,1)defect by shape-asserting). - Lab 03: hand-derive and gradcheck the two-layer network; derive softmax+CE to $p - y$; PCA from covariance on a real small dataset with the scatter plot.
Success Criteria
You are ready for Phase 01 when you can, from memory:
- Explain views vs copies via strides, and the uint8-overflow habit.
- State the broadcasting rule exactly and produce the all-pairs distance trick.
- Write a decorator and a context manager without reference.
- Hand-compute a chain-rule gradient and verify it numerically.
- Derive softmax+cross-entropy's $p - y$ gradient.
- Name the four debugging plots and what each reveals.
Interview Q&A
Q: Why is the NumPy version 500× faster than your loop — and when would the loop win? The loop pays Python interpreter dispatch, boxing, and dynamic typing per element; NumPy pays it once per array, executing a compiled C kernel over contiguous memory with SIMD. The loop "wins" only when the work is inherently scalar/sequential (early-exit searches, complex per-element control flow) — at which point the real answer is numba/Cython or restructuring the algorithm, not the loop.
Q: a = b[2:5]; a[0] = 99 — what happened to b, and why is it designed that way?
b[2] is now 99: basic slicing returns a view (same buffer, adjusted
offset/strides) — zero-copy by design, because copying every slice of large arrays
would be ruinous. Fancy indexing (b[[2,3,4]]) returns a copy — knowing which
operations view vs copy is the difference between fast code and haunted code.
Q: Your loss is NaN at step 3. Walk through it with Phase-00 tools only. Bisect with plots and asserts: histogram the inputs (uint8 not normalized? inf from a bad decode?), check the forward intermediates for overflow (float32 ranges, log of zero — clip/eps), gradcheck the hand-written layer if any, and shrink LR to test divergence-vs-bug (NaN at step 3 with sane data is usually log(0)/div-by-zero in the loss, not optimization). The method — plot distributions at each stage, assert shapes and ranges — is exactly what Phase 00 trains.
References
- NumPy: the absolute basics + broadcasting docs
- Harris et al., Array programming with NumPy (Nature 2020) — the strides/views design, from the source
- Python Data Model docs — the dunder protocol
- Fluent Python (Ramalho) — ch. on decorators, context managers, sequences
- Matrix Calculus for Deep Learning — Parr & Howard
- 3Blue1Brown, Essence of Linear Algebra and Essence of Calculus — the geometric intuition track
- Matplotlib: Anatomy of a Figure
Lab 01 — Python Advanced Patterns
Phase: 0 — Foundations | Difficulty: ⭐⭐☆☆☆
Concept Overview
1. Generators & Iterators
Why it matters for CV/ML: Large image datasets don't fit in RAM. Generators let you stream data one batch at a time — this is exactly what DataLoader does internally.
A generator is a function that yields values lazily. When called, it returns a generator object — an iterator that computes each value on demand.
def stream_images(folder):
for path in Path(folder).rglob("*.jpg"):
yield cv2.imread(str(path)) # loads ONE image, then pauses
Under the hood, Python suspends the function's stack frame at each yield and resumes it on the next next() call. Memory usage stays constant regardless of dataset size.
Iterator protocol: any object implementing __iter__ and __next__. Generator functions implement this automatically.
class InfiniteCounter:
def __init__(self): self.n = 0
def __iter__(self): return self
def __next__(self): self.n += 1; return self.n
Generator expressions (memory-efficient alternative to list comprehensions):
total_pixels = sum(img.size for img in stream_images("data/"))
2. Decorators
Why it matters: Decorators appear everywhere in ML code — @torch.no_grad(), @tf.function, @app.route, @lru_cache. Understanding them lets you write and debug production ML systems.
A decorator is a higher-order function — it takes a function and returns a new function:
def timer(func):
import time
def wrapper(*args, **kwargs):
t0 = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.perf_counter()-t0:.4f}s")
return result
return wrapper
@timer
def run_inference(model, image): ...
@timer is syntactic sugar for run_inference = timer(run_inference).
Decorators with arguments require an extra layer of nesting:
def retry(max_attempts=3):
def decorator(func):
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try: return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1: raise
return wrapper
return decorator
@retry(max_attempts=5)
def fetch_batch_from_s3(bucket, key): ...
functools.wraps preserves the original function's __name__, __doc__, etc. Always use it.
3. Dataclasses
Why it matters: Model configs, training hyperparameters, and dataset metadata need structured containers. dataclass beats plain dicts (no typos, IDE autocompletion, type hints).
from dataclasses import dataclass, field
from typing import Optional, Tuple
@dataclass
class TrainingConfig:
model_name: str = "resnet50"
num_classes: int = 80
learning_rate: float = 1e-4
batch_size: int = 32
image_size: Tuple[int, int] = (640, 640)
augmentations: list = field(default_factory=list)
pretrained_weights: Optional[str] = None
def __post_init__(self):
assert self.learning_rate > 0, "LR must be positive"
Generated automatically: __init__, __repr__, __eq__. Optional: __hash__, ordering, frozen (immutable) instances.
4. __slots__
Why it matters: CV pipelines process millions of objects (bounding boxes, detections, keypoints). __slots__ eliminates the per-instance __dict__, saving ~40-60 bytes per object — critical at scale.
class BoundingBox:
__slots__ = ('x1', 'y1', 'x2', 'y2', 'confidence', 'class_id')
def __init__(self, x1, y1, x2, y2, conf, cls):
self.x1, self.y1, self.x2, self.y2 = x1, y1, x2, y2
self.confidence, self.class_id = conf, cls
# With __slots__: ~56 bytes/instance
# Without: ~152 bytes/instance
5. Context Managers
Why it matters: GPU memory, file handles, model inference sessions, database connections — all need deterministic cleanup. Context managers are the Pythonic way.
from contextlib import contextmanager
import torch
@contextmanager
def inference_mode(model):
"""Switch model to eval mode and disable grad tracking."""
model.eval()
try:
with torch.no_grad():
yield model
finally:
model.train()
with inference_mode(my_model) as model:
output = model(input_tensor)
# model is back in training mode here
The __enter__ / __exit__ protocol (class-based) vs @contextmanager (generator-based) — know both.
6. Type Hints & Protocols
Why it matters: Type hints are now standard in ML libraries (PyTorch 2.x, TF2, sklearn). They catch bugs at static analysis time and serve as self-documentation.
from typing import Union, List, Dict, Callable
from pathlib import Path
ImageArray = "np.ndarray" # shape (H, W, C), dtype uint8
def preprocess(
image: ImageArray,
size: tuple[int, int] = (224, 224),
normalize: bool = True,
) -> "torch.Tensor": # shape (3, H, W), dtype float32
...
Protocols (structural subtyping — "duck typing with types"):
from typing import Protocol
class Backbone(Protocol):
def forward(self, x: "torch.Tensor") -> "torch.Tensor": ...
def freeze(self) -> None: ...
7. functools, itertools, collections
These stdlib modules are heavily used in data pipeline code:
from functools import lru_cache, partial, reduce
from itertools import islice, chain, product
from collections import defaultdict, Counter, deque
# Cached feature extractor
@lru_cache(maxsize=1024)
def get_class_weights(dataset_name: str) -> dict: ...
# Sliding window over frame stream
def sliding_window(iterable, n):
d = deque(maxlen=n)
for item in iterable:
d.append(item)
if len(d) == n:
yield tuple(d)
Interview Questions
Q: What is the difference between a generator and a list comprehension? When would you use each?
A: Both produce sequences, but a list comprehension materializes all elements into memory immediately (O(n) space), while a generator yields one element at a time (O(1) space). Use generators when the dataset is large (streaming image batches from disk), when you only need one element at a time, or when the sequence is infinite. Use list comprehensions when you need random access or multiple passes over the data.
Q: Explain how @torch.no_grad() works as a decorator AND as a context manager.
A: torch.no_grad is a class that implements both __call__ (making it a decorator) and __enter__/__exit__ (making it a context manager). When used as a decorator, it wraps the function with gradient tracking disabled for the duration of the call. As a context manager, it disables/re-enables gradient tracking for the with block. Internally, it pushes/pops a "no gradient" flag onto PyTorch's autograd context stack.
Q: How would you implement a thread-safe LRU cache for model predictions?
A: Use functools.lru_cache for single-threaded code. For multi-threaded inference servers (FastAPI with asyncio), use a dictionary protected by asyncio.Lock or threading.Lock, or use cachetools.TTLCache with a lock. Cache keys should be derived from a hash of the input (e.g., MD5 of image bytes) to handle array inputs which aren't hashable by default.
Common Python Pitfalls in ML Code
# WRONG: mutable default argument — shared across all calls!
def augment(image, transforms=[]):
transforms.append(Resize(224)) # appends on every call after first!
...
# CORRECT: use None sentinel
def augment(image, transforms=None):
transforms = transforms or []
...
# WRONG: late binding in closures
fns = [lambda x: x * i for i in range(5)]
fns[0](1) # returns 4, not 0! 'i' is captured by reference
# CORRECT: use default argument to capture value
fns = [lambda x, i=i: x * i for i in range(5)]
Lab 02 — NumPy & Matplotlib for Computer Vision
Phase: 0 — Foundations | Difficulty: ⭐⭐⭐☆☆
Files: lab.py, solution.py, exploration.ipynb
Concept Overview
Images as NumPy Arrays
Every image processing operation in computer vision reduces to tensor arithmetic. OpenCV, PyTorch, TensorFlow, and scikit-image all represent images as NumPy arrays (or wrappers around them). Understanding NumPy deeply means you can debug shape mismatches, write efficient preprocessing, and avoid silent numerical bugs.
Image shape conventions:
| Library | Shape | Channel order | Dtype |
|---|---|---|---|
| OpenCV | (H, W, C) | BGR | uint8 |
| PyTorch | (C, H, W) | RGB | float32 [0,1] |
| TensorFlow/Keras | (H, W, C) | RGB | float32 [0,1] |
| Matplotlib | (H, W, C) | RGB | uint8 or float32 |
Converting between formats is a constant task:
# OpenCV BGR → PyTorch tensor (C, H, W) float32
import cv2, numpy as np
img_bgr = cv2.imread("image.jpg") # (H, W, 3) uint8 BGR
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) # RGB
img_f32 = img_rgb.astype(np.float32) / 255.0 # [0, 1]
tensor = img_f32.transpose(2, 0, 1) # (C, H, W)
# Or: np.moveaxis(img_f32, -1, 0)
Broadcasting
Broadcasting is NumPy's rule for performing operations on arrays with different shapes. The rule is:
Two dimensions are compatible if they are equal, or one of them is 1.
Dimensions are compared element-wise from the right (trailing dimensions).
Shape A: (H, W, 3)
Shape B: (3,) ← treated as (1, 1, 3)
Result: (H, W, 3) ← each pixel's 3 channels scaled by B
Real-world example — channel-wise normalization:
mean = np.array([0.485, 0.456, 0.406]) # shape (3,) — ImageNet mean
std = np.array([0.229, 0.224, 0.225]) # shape (3,)
image_normalized = (image_f32 - mean) / std
# image_f32: (H, W, 3), mean: (3,) → broadcasts to (H, W, 3) ✓
Broadcasting rules step-by-step:
- If shapes have different number of dimensions, prepend 1s to the smaller shape.
- Dimensions must be equal or one must be 1.
- Size-1 dimensions are "stretched" to match the other array.
Memory Layout: C-contiguous vs Fortran-contiguous
Why this matters: CUDA kernels, ONNX runtimes, and C extensions expect contiguous arrays. Non-contiguous arrays (from slicing or transpose) can silently cause performance degradation or errors.
# C-contiguous (row-major): default, elements stored row by row
img = np.zeros((480, 640, 3), dtype=np.uint8) # C-contiguous
img.strides # (640*3, 3, 1) = (1920, 3, 1) bytes
# After transpose: (3, 480, 640) — no longer C-contiguous!
t = img.transpose(2, 0, 1)
t.flags['C_CONTIGUOUS'] # False!
# Fix: make contiguous copy
t_contiguous = np.ascontiguousarray(t)
# Or: t.copy()
Strides: A stride is the number of bytes to step in a given dimension.
Array shape (4, 3) of int32 (4 bytes):
Row stride: 3 * 4 = 12 bytes (step 12 bytes to move to next row)
Column stride: 1 * 4 = 4 bytes (step 4 bytes to move to next col)
Understanding strides enables zero-copy operations like np.lib.stride_tricks.sliding_window_view.
Fancy Indexing & Boolean Masking
These are the backbone of ROI extraction, masking, and conditional image editing:
# Boolean masking: select all red-ish pixels (HSV space)
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
mask = (hsv[:,:,0] > 0) & (hsv[:,:,0] < 30) & (hsv[:,:,1] > 100)
red_pixels = img[mask] # shape: (N, 3) — flattened selected pixels
img[mask] = [0, 255, 0] # paint them green
# Advanced indexing: batch ROI extraction
rois = img[y1:y2, x1:x2] # slice (view, not copy)
boxes = np.array([[0,0,50,50],[100,100,200,200]]) # (N, 4)
# For multiple ROIs, iterate or use torchvision.ops.roi_align
Key NumPy Functions for CV
| Function | Use Case |
|---|---|
np.clip | Prevent overflow after arithmetic (e.g., after adding noise) |
np.pad | Add padding before convolution |
np.roll | Circular shift (useful for augmentation) |
np.einsum | Efficient batched dot products, attention scores |
np.linalg.svd | PCA, image compression, denoising |
np.fft.fft2 | Frequency domain analysis, filtering |
np.lib.stride_tricks.sliding_window_view | Efficient convolution preprocessing |
Matplotlib for CV Visualization
import matplotlib.pyplot as plt
# Show image correctly (OpenCV is BGR, matplotlib expects RGB)
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
axes[0].imshow(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB))
axes[0].set_title("Original")
# Show grayscale
axes[1].imshow(gray, cmap='gray')
# Show heatmap / attention map
axes[2].imshow(heatmap, cmap='jet', alpha=0.5)
# Draw bounding boxes
import matplotlib.patches as patches
rect = patches.Rectangle((x1,y1), x2-x1, y2-y1,
linewidth=2, edgecolor='red', facecolor='none')
axes[0].add_patch(rect)
plt.tight_layout()
plt.savefig("output.png", dpi=150, bbox_inches='tight')
SVD and PCA for Images
Singular Value Decomposition (SVD) of a matrix M:
$$M = U \Sigma V^T$$
where:
- $U$ — left singular vectors (shape $m \times m$, orthonormal)
- $\Sigma$ — diagonal matrix of singular values (sorted descending)
- $V^T$ — right singular vectors (shape $n \times n$, orthonormal)
For a grayscale image M of shape $(H, W)$, the rank-$k$ approximation retains only the top $k$ singular values:
$$M_k = \sum_{i=1}^{k} \sigma_i u_i v_i^T$$
This is image compression. The compressed image uses $k(H + W + 1)$ numbers instead of $H \times W$.
CV Applications of SVD:
- PCA for face recognition (Eigenfaces)
- Background subtraction (low-rank + sparse decomposition)
- Denoising (truncate small singular values = noise)
- Essential/Fundamental matrix computation in stereo vision
Interview Questions
Q: What is the difference between np.copy() and np.view()? Why does this matter in ML pipelines?
A: .copy() allocates new memory. A view (from slicing or reshape when possible) shares memory with the original array — modifying the view modifies the original. This matters because in-place augmentations on views can corrupt original data if you're not careful. Always call .copy() when you intend to modify a subset of a batch.
Q: A model expects input shape (N, C, H, W) float32 in [0, 1]. You receive a batch of OpenCV images (list of (H, W, 3) uint8 BGR). Write the conversion.
batch = np.stack([
cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
for img in images
]) # (N, H, W, C)
batch = batch.transpose(0, 3, 1, 2) # (N, C, H, W)
# Or: np.moveaxis(batch, -1, 1)
Q: Why should you call np.ascontiguousarray() before passing to a C extension or CUDA kernel?
A: Non-contiguous arrays (e.g., after transpose) have irregular strides. C/CUDA code assumes row-major contiguous layout. Passing a non-contiguous array silently produces wrong results or causes a segfault. np.ascontiguousarray() creates a contiguous copy only if needed (no-op for already-contiguous arrays).
Pandas for CV Data Management
Pandas is the standard tool for managing datasets, annotations, experiment results, and metrics in CV pipelines. You will use it constantly in real projects.
Why Pandas in CV?
- Load and filter annotation CSVs (COCO, Open Images, custom)
- Track per-image / per-class metrics across experiments
- Join predictions with ground truth for error analysis
- Export benchmark results for reporting
Core Operations
import pandas as pd
import numpy as np
# ── Loading annotation files ──────────────────────────────────────────────────
# Many datasets ship as CSV or can be converted to one
df = pd.read_csv("annotations.csv")
# Common columns: image_id, class_name, xmin, ymin, xmax, ymax, confidence
# ── Exploring the dataset ─────────────────────────────────────────────────────
print(df.shape) # (N, cols)
print(df.dtypes) # column types
print(df.head()) # first 5 rows
print(df["class"].value_counts()) # class distribution
# ── Filtering ─────────────────────────────────────────────────────────────────
# Only high-confidence detections
high_conf = df[df["confidence"] > 0.5]
# Specific classes
persons = df[df["class"] == "person"]
# Multiple conditions (use & not 'and')
filtered = df[(df["confidence"] > 0.3) & (df["class"].isin(["car", "truck"]))]
# ── Computing bounding box area ───────────────────────────────────────────────
df["area"] = (df["xmax"] - df["xmin"]) * (df["ymax"] - df["ymin"])
df["aspect_ratio"] = (df["xmax"] - df["xmin"]) / (df["ymax"] - df["ymin"])
# ── Per-class statistics ──────────────────────────────────────────────────────
stats = df.groupby("class").agg(
count=("image_id", "count"),
mean_conf=("confidence", "mean"),
mean_area=("area", "mean"),
)
print(stats)
# ── Per-image metrics ─────────────────────────────────────────────────────────
per_image = df.groupby("image_id").agg(
n_objects=("class", "count"),
classes=("class", lambda x: list(x.unique())),
)
# ── Joining predictions with ground truth ────────────────────────────────────
preds_df = pd.read_csv("predictions.csv") # image_id, class, confidence, bbox...
gt_df = pd.read_csv("ground_truth.csv")
merged = pd.merge(preds_df, gt_df, on="image_id", suffixes=("_pred", "_gt"))
# ── Saving results ────────────────────────────────────────────────────────────
metrics = pd.DataFrame([
{"model": "YOLOv8", "mAP@50": 0.723, "latency_ms": 12.1},
{"model": "DETR", "mAP@50": 0.748, "latency_ms": 38.4},
{"model": "FCOS", "mAP@50": 0.701, "latency_ms": 18.7},
])
metrics.to_csv("outputs/experiment_results.csv", index=False)
print(metrics.to_string(index=False))
Pandas + NumPy Bridge
# Convert DataFrame column to NumPy array for math
scores = df["confidence"].to_numpy() # 1D array
boxes = df[["xmin", "ymin", "xmax", "ymax"]].to_numpy() # (N, 4) array
# Convert NumPy results back to DataFrame
iou_matrix = compute_iou(boxes_pred, boxes_gt) # (M, N) array
iou_df = pd.DataFrame(iou_matrix, columns=gt_ids, index=pred_ids)
Typical CV Evaluation Workflow with Pandas
# After running inference on a validation set:
results = []
for image_id, pred_boxes, pred_scores, pred_classes, gt_boxes, gt_classes in val_results:
for box, score, cls in zip(pred_boxes, pred_scores, pred_classes):
results.append({
"image_id": image_id,
"class": cls,
"confidence": score,
"xmin": box[0], "ymin": box[1], "xmax": box[2], "ymax": box[3],
})
df = pd.DataFrame(results)
# Sort by confidence descending (needed for mAP calculation)
df = df.sort_values("confidence", ascending=False)
# Per-class AP
for cls in df["class"].unique():
cls_df = df[df["class"] == cls]
# compute precision/recall curve, integrate for AP
Interview Questions
Q: You have a CSV with 1M detection predictions and you need the top-100 highest confidence detections per class. How do you do it efficiently in pandas?
top100 = (
df.sort_values("confidence", ascending=False)
.groupby("class")
.head(100)
.reset_index(drop=True)
)
Q: How do you find images in your dataset that have no annotations (hard negatives)?
all_image_ids = pd.read_csv("images.csv")["image_id"]
annotated_ids = df["image_id"].unique()
hard_negatives = all_image_ids[~all_image_ids.isin(annotated_ids)]
Q: What is the difference between loc and iloc?
A: loc selects by label (index name or column name); iloc selects by integer position (0-based row/column index). Use iloc when you need positional slicing, loc when filtering by value or named index.
Lab 03 — Math for ML: Linear Algebra, Calculus & Probability
Phase: 0 — Foundations | Difficulty: ⭐⭐⭐⭐☆
Files: lab.py, solution.py
Linear Algebra
Dot Product & Matrix Multiplication
The forward pass of every neural network is a series of matrix multiplications:
$$\mathbf{y} = \mathbf{W}\mathbf{x} + \mathbf{b}$$
For a batch of inputs $X \in \mathbb{R}^{N \times D_{in}}$ and weight matrix $W \in \mathbb{R}^{D_{in} \times D_{out}}$:
$$Y = XW + \mathbf{b} \quad \in \mathbb{R}^{N \times D_{out}}$$
Geometric interpretation: A linear layer projects inputs from $\mathbb{R}^{D_{in}}$ to $\mathbb{R}^{D_{out}}$ — a change of basis. The weight matrix $W$ encodes this transformation.
Eigendecomposition
For a square matrix $A$: $$A\mathbf{v} = \lambda \mathbf{v}$$
$\mathbf{v}$ is an eigenvector, $\lambda$ is the corresponding eigenvalue.
The full decomposition: $A = Q \Lambda Q^{-1}$ where $Q$ is the matrix of eigenvectors and $\Lambda$ is the diagonal matrix of eigenvalues.
For symmetric matrices (covariance matrices are always symmetric): $A = Q \Lambda Q^T$ (orthogonal $Q$).
CV Applications:
- PCA (Eigenfaces): Eigenvectors of the face covariance matrix are "eigenfaces". The top $k$ eigenvectors capture the most variance in face images.
- Harris corner detector: Uses eigenvalues of the structure tensor $M$:
- $\lambda_1 \approx \lambda_2 \gg 0$: corner
- $\lambda_1 \gg \lambda_2 \approx 0$: edge
- $\lambda_1 \approx \lambda_2 \approx 0$: flat region
Singular Value Decomposition (SVD)
$$M = U \Sigma V^T$$
Unlike eigendecomposition, SVD works for any matrix (not just square/symmetric).
- $U \in \mathbb{R}^{m \times m}$: left singular vectors (orthonormal)
- $\Sigma \in \mathbb{R}^{m \times n}$: diagonal, singular values $\sigma_1 \geq \sigma_2 \geq \ldots \geq 0$
- $V^T \in \mathbb{R}^{n \times n}$: right singular vectors (orthonormal)
Relationship to eigendecomposition:
- Columns of $U$ = eigenvectors of $MM^T$
- Columns of $V$ = eigenvectors of $M^TM$
- $\sigma_i = \sqrt{\lambda_i(M^TM)}$
Low-rank approximation: $M_k = U_k \Sigma_k V_k^T$ minimizes the Frobenius norm $|M - M_k|_F$ over all rank-$k$ matrices. (Eckart–Young theorem)
Norms
| Norm | Formula | Use in CV/ML |
|---|---|---|
| L1 ($\ell_1$) | $\sum_i | x_i |
| L2 ($\ell_2$) | $\sqrt{\sum_i x_i^2}$ | Ridge regression, weight decay, distance between embeddings |
| L∞ | $\max_i | x_i |
| Frobenius | $\sqrt{\sum_{i,j} A_{ij}^2}$ | Matrix regularization |
L2 normalization of feature vectors (used before cosine similarity): $$\hat{\mathbf{v}} = \frac{\mathbf{v}}{|\mathbf{v}|_2}$$
Then cosine similarity: $\cos(\theta) = \hat{\mathbf{u}} \cdot \hat{\mathbf{v}}$ — no division needed.
Covariance Matrix & PCA
For a dataset $X \in \mathbb{R}^{N \times D}$ (zero-centered):
$$\Sigma = \frac{1}{N-1} X^T X \in \mathbb{R}^{D \times D}$$
PCA: Eigendecompose $\Sigma = Q \Lambda Q^T$, project $X$ onto top-$k$ eigenvectors:
$$Z = X Q_k \in \mathbb{R}^{N \times k}$$
This is equivalent to: $X = U \Sigma V^T \Rightarrow$ top-$k$ principal components are columns of $V$ (or rows of $V^T$).
Calculus for Neural Networks
Chain Rule (The Core of Backpropagation)
For a composition $f(g(x))$: $$\frac{d}{dx}f(g(x)) = f'(g(x)) \cdot g'(x)$$
For multivariable functions (the chain rule that powers backprop): $$\frac{\partial L}{\partial x} = \frac{\partial L}{\partial y} \cdot \frac{\partial y}{\partial x}$$
Backpropagation example:
Given: $L = \text{MSE}(\mathbf{y}, \hat{\mathbf{y}})$, $\hat{\mathbf{y}} = \sigma(\mathbf{z})$, $\mathbf{z} = W\mathbf{x} + \mathbf{b}$
$$\frac{\partial L}{\partial W} = \frac{\partial L}{\partial \hat{y}} \cdot \frac{\partial \hat{y}}{\partial z} \cdot \frac{\partial z}{\partial W}$$
Each term:
- $\frac{\partial L}{\partial \hat{y}} = \frac{2}{N}(\hat{y} - y)$ (MSE gradient)
- $\frac{\partial \hat{y}}{\partial z} = \sigma(z)(1-\sigma(z))$ (sigmoid gradient)
- $\frac{\partial z}{\partial W} = \mathbf{x}^T$ (linear layer gradient)
Gradient Descent
$$W \leftarrow W - \eta \cdot \nabla_W L$$
Why it works: The gradient $\nabla_W L$ points in the direction of steepest ascent. Subtracting it moves toward a local minimum.
Variants:
- SGD: Update with one sample (or mini-batch). Noisy but escapes local minima.
- Momentum: $v \leftarrow \beta v + (1-\beta)\nabla L$, $W \leftarrow W - \eta v$
- Adam: Adaptive learning rate per parameter. $m \leftarrow \beta_1 m + (1-\beta_1)\nabla L$, $v \leftarrow \beta_2 v + (1-\beta_2)\nabla L^2$. Corrected: $\hat{m} = m/(1-\beta_1^t)$, $W \leftarrow W - \eta \hat{m}/(\sqrt{\hat{v}}+\epsilon)$
Convolution (Mathematical Definition)
Discrete 2D convolution of image $I$ with kernel $K$: $$(I * K)[i,j] = \sum_m \sum_n I[i-m, j-n] \cdot K[m,n]$$
In deep learning, what's called "convolution" is actually cross-correlation: $$(I \star K)[i,j] = \sum_m \sum_n I[i+m, j+n] \cdot K[m,n]$$
(The kernel is not flipped, unlike true convolution. The network learns to flip weights during training if needed.)
Output size formula: For input $(H, W)$, kernel $(k, k)$, padding $p$, stride $s$: $$H_{out} = \lfloor \frac{H + 2p - k}{s} \rfloor + 1$$
Probability for ML
Bayes' Theorem
$$P(A|B) = \frac{P(B|A) \cdot P(A)}{P(B)}$$
In ML terms: $$P(\text{class}|\text{image}) = \frac{P(\text{image}|\text{class}) \cdot P(\text{class})}{P(\text{image})}$$
- $P(\text{class})$: prior — class distribution in training data
- $P(\text{image}|\text{class})$: likelihood — how likely is this image from this class
- $P(\text{class}|\text{image})$: posterior — what the classifier outputs
Class imbalance = a poorly calibrated prior. Techniques to fix: class weights in loss, oversampling, focal loss.
Entropy, Cross-Entropy, KL Divergence
Entropy (information content of distribution $P$): $$H(P) = -\sum_i P(i) \log P(i)$$
Cross-entropy (how well $Q$ approximates $P$): $$H(P, Q) = -\sum_i P(i) \log Q(i)$$
Classification loss = cross-entropy between one-hot label $P$ and softmax output $Q$: $$L = -\sum_i y_i \log \hat{p}i$$ For single label (one-hot): $L = -\log \hat{p}{true}$
KL Divergence (asymmetric "distance" between distributions): $$D_{KL}(P | Q) = \sum_i P(i) \log \frac{P(i)}{Q(i)} = H(P,Q) - H(P)$$
Used in VAEs, knowledge distillation, and feature alignment.
Interview Questions
Q: Explain SVD and name 3 applications in computer vision.
A: SVD decomposes matrix $M = U\Sigma V^T$ into two orthogonal bases and a diagonal scaling. Applications: (1) Image compression — rank-$k$ approximation via top $k$ singular values. (2) PCA / Eigenfaces — SVD of the centered data matrix gives principal components. (3) Essential/Fundamental matrix computation — the 8-point algorithm solves for the Fundamental matrix $F$ via SVD; the essential matrix $E$ is further constrained by enforcing the two smallest singular values are equal and the largest is 1.
Q: How does gradient descent find a minimum? What can go wrong?
A: Gradient descent iteratively moves parameters in the direction opposite to the gradient, shrinking loss. Problems: (1) Local minima / saddle points — in high-dimensional spaces, saddle points are more common than local minima; stochastic noise helps escape them. (2) Vanishing gradients — gradients near zero prevent early layers from learning; fixed by ReLU activations and residual connections. (3) Exploding gradients — large gradients cause divergence; fixed by gradient clipping. (4) Poor learning rate — too high diverges, too low is very slow; adaptive optimizers (Adam) mitigate this.
Q: Why is cross-entropy used as the classification loss rather than MSE?
A: Cross-entropy aligns with the probabilistic interpretation (maximizing log-likelihood of correct class). For classification, MSE penalizes equally everywhere, but we want to penalize confident wrong predictions very heavily. Cross-entropy with softmax: gradient is $\hat{p} - y$ — simple and well-scaled. MSE with sigmoid produces vanishing gradients when predictions are saturated (near 0 or 1), making early training very slow.
Phase 1 — Classical Computer Vision with OpenCV
Duration: 3 weeks | Prerequisite: Phase 0 complete
Why Classical CV Still Matters
Deep learning hasn't replaced classical computer vision — it runs alongside it. Production systems use classical algorithms for:
- Pre/post-processing: Gaussian blur before edge detection, morphological ops to clean segmentation masks, NMS to deduplicate detection outputs
- Real-time constraints: Harris corners and ORB run in microseconds; a full neural net cannot
- Geometric reasoning: Camera calibration, stereo vision, homography estimation are inherently geometric — you can't just "throw a neural net at them"
- Interpretability: When a classical algorithm fails, you can inspect every intermediate step
Every CV engineer is expected to understand these primitives deeply.
OpenCV Architecture
OpenCV (Open Source Computer Vision Library) is a C++ library with Python bindings. Key architectural points:
- Default color order: BGR (not RGB) — OpenCV was written when cameras used BGR. This bites everyone eventually.
- Images are NumPy arrays in Python:
cv2.imread()returnsnp.ndarray— no special types. - In-place vs copy: Many functions have
dstparameter. WhenNone, a new array is allocated. - Data types matter: Many functions expect
uint8(0–255); filters needfloat32orfloat64; always checkimg.dtype.
Labs
| Lab | Topic | Key APIs |
|---|---|---|
| lab-01-image-basics | Color spaces, histograms, pixel ops | cv2.imread, cvtColor, calcHist, equalizeHist |
| lab-02-filtering-morphology | Spatial filtering, edge detection | GaussianBlur, Canny, morphologyEx, findContours |
| lab-03-feature-detection | Keypoints, descriptors, matching | SIFT, ORB, BFMatcher, findHomography |
| lab-04-optical-flow-tracking | Motion estimation, object tracking | calcOpticalFlowPyrLK, calcOpticalFlowFarneback, TrackerCSRT |
| lab-05-camera-calibration | Camera geometry, calibration | calibrateCamera, undistort, solvePnP |
Learning Outcomes
- Read, write, and display images correctly (avoiding the BGR/RGB trap)
- Implement a full image processing pipeline: load → preprocess → detect → filter → output
- Match keypoints between images and estimate a homography transformation
- Track an object across video frames using both classical and optical flow methods
- Calibrate a camera using a chessboard pattern and undistort images
Interview Relevance
- "What is the difference between Gaussian blur and median filter? When would you use each?"
- "Explain how SIFT achieves scale and rotation invariance."
- "Walk me through how Canny edge detection works, step by step."
- "What is a homography? What are its degrees of freedom?"
- "How does camera calibration work? What is the intrinsic matrix?"
- "What are the limitations of classical optical flow?"
Warmup Guide — Classical CV & OpenCV
Zero-to-expert primer for Phase 01: images as arrays, filtering and morphology, features and matching, optical flow, and camera geometry — the pre-deep-learning toolkit that still does half the work in production vision systems.
Table of Contents
- Chapter 1: What an Image Is
- Chapter 2: Color Spaces and Why BGR Will Bite You
- Chapter 3: Convolution and Filtering
- Chapter 4: Edges, Gradients, and Canny
- Chapter 5: Thresholding and Morphology
- Chapter 6: Features — Detection, Description, Matching
- Chapter 7: Optical Flow and Tracking
- Chapter 8: Camera Geometry and Calibration
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: What an Image Is
A digital image is a sampled, quantized measurement of light: an (H, W) array of
intensities (grayscale) or (H, W, 3) of color samples, almost always uint8
(0–255). Phase 00's dtype discipline applies with force: process in float, store and
display in uint8, and remember that OpenCV indexes (row, col) = (y, x) — the
coordinate-order bug is the field's hazing ritual.
Resolution, sampling, and aliasing: downsample without first low-pass filtering and
high frequencies fold into artifacts (jagged edges, moiré) — which is why
cv2.resize's interpolation choice matters (INTER_AREA for shrinking, LINEAR/CUBIC for
enlarging), and why "thumbnail then detect" pipelines quietly destroy small objects.
Chapter 2: Color Spaces and Why BGR Will Bite You
- OpenCV loads BGR, everything else expects RGB — the red-faces-look-blue bug that
every practitioner ships exactly once.
cv2.cvtColor(img, cv2.COLOR_BGR2RGB)at the boundary, always. - HSV separates what color (hue) from how colorful (saturation) and how bright (value) — color-based segmentation thresholds in HSV survive lighting changes that destroy RGB thresholds. The classic "track the orange ball" is an HSV inRange + morphology pipeline.
- Grayscale is a weighted sum (≈ 0.299R + 0.587G + 0.114B — human luminance sensitivity), and most classical algorithms run on it: features, edges, and flow are geometry, not color.
- Lighting robustness in general — histogram equalization and CLAHE (local, contrast-limited — the practical one) — is a preprocessing decision you make looking at histograms (Phase 00 Ch. 7).
Chapter 3: Convolution and Filtering
The single most important operation in vision, classical or deep: slide a small kernel over the image; each output pixel is the kernel-weighted sum of its neighborhood.
- Gaussian blur: kernel = sampled 2-D Gaussian — the principled smoother (separable into two 1-D passes: O(k) not O(k²) per pixel — the optimization to know). σ controls scale; blur-before-downsample is anti-aliasing (Ch. 1).
- Median filter: not a convolution (order statistic) — removes salt-and-pepper noise while preserving edges, which Gaussian can't.
- Bilateral filter: Gaussian in space × Gaussian in intensity difference — smooths regions, preserves edges; the "beauty filter" primitive.
- Border handling (replicate/reflect/constant) changes results at the edges — an explicit parameter, not a footnote, when outputs feed measurement.
- The bridge to Phase 03+: a CNN is this exact operation with learned kernels stacked deep. Hand-designing kernels here (blur, sharpen, Sobel) is what makes "the network learns its own Sobel" meaningful later.
Chapter 4: Edges, Gradients, and Canny
- Image gradient = intensity change per pixel: Sobel kernels approximate $\partial I/\partial x, \partial I/\partial y$; magnitude $\sqrt{G_x^2 + G_y^2}$ and orientation $\arctan(G_y/G_x)$ — the raw material of edges, HOG, and SIFT alike.
- Canny is the principled pipeline, worth knowing stage-by-stage: Gaussian smooth → Sobel gradients → non-maximum suppression along gradient direction (thin ridges to 1-px edges) → hysteresis double-threshold (strong edges seed; weak edges kept only if connected to strong) — the two-threshold idea (high precision seeds + recall by connectivity) is a design pattern you'll reuse outside vision.
- Tuning reality: Canny's thresholds depend on image contrast — auto-tuning from median intensity (the 0.66×/1.33× heuristic) is the practical move.
Chapter 5: Thresholding and Morphology
The workhorse segmentation stack for controlled imaging (documents, industrial inspection, microscopy):
- Otsu's threshold: choose the global threshold minimizing intra-class variance of the resulting histogram split — optimal for bimodal histograms, automatic, and the reason you look at the histogram first.
- Adaptive thresholding: per-neighborhood thresholds — survives illumination gradients that kill any global value (uneven document lighting).
- Morphology on the resulting binary masks: erosion (shrink — remove specks), dilation (grow — fill pinholes), opening (erode→dilate: remove small noise, keep shape), closing (dilate→erode: seal gaps). With a structuring element whose shape means something (horizontal kernel → extract table lines).
- Then connected components / contours (
findContours) → per-object measurements (area, centroid, bounding box) — a complete measurement pipeline with no learning, which is exactly what you deploy when the camera and lighting are yours to control. Knowing when this stack beats a neural network is a senior judgment call (it's faster, explainable, and data-free — and brittle to scene change; say both).
Chapter 6: Features — Detection, Description, Matching
The local-feature pipeline: find repeatable points → describe their neighborhoods → match across images. It powers stitching, SLAM, and registration to this day.
- Detection: corners are points where the local gradient structure is strong in
two directions — Harris formalizes this via the structure tensor's eigenvalues;
Shi-Tomasi's
goodFeaturesToTrackis the practical variant; FAST is the speed-optimized comparison-based one. - Description: SIFT's neighborhood histogram-of-gradients descriptor, normalized for rotation (dominant orientation) and scale (detected in scale space) — 128-D float, the gold standard; ORB (FAST + rotated BRIEF binary descriptor) — 256-bit, Hamming-matchable, ~100× faster, patent-free — the production default when SIFT is too slow.
- Matching: brute-force nearest descriptor + Lowe's ratio test (best/second-best < 0.75 — reject ambiguous matches; this one heuristic removes most garbage), then RANSAC to fit the geometric model (homography/fundamental matrix) while rejecting outliers: sample minimal sets, fit, count inliers, keep the consensus. RANSAC's pattern — hypothesize from minimal data, verify by consensus — is another exportable design idea.
Chapter 7: Optical Flow and Tracking
- The brightness-constancy assumption: a moving pixel keeps its intensity; first-order expansion gives one equation per pixel with two unknowns (u, v) — the aperture problem (through a small window, only motion along the gradient is observable).
- Lucas-Kanade resolves it by assuming constant flow in a window and solving the
overdetermined system — valid where the structure tensor is well-conditioned, i.e.,
at corners (Ch. 6's detector is LK's natural partner:
goodFeaturesToTrack→calcOpticalFlowPyrLK). Pyramidal LK handles large motions coarse-to-fine. - Dense flow (Farnebäck classically; RAFT-class networks today) computes flow everywhere — for motion segmentation and video features, at real cost.
- Tracking as the system built on top: detect → associate across frames (IoU or appearance) → smooth with a motion model (Kalman filter: predict-correct under Gaussian assumptions) → handle birth/death of tracks. SORT is exactly this composition and remains the baseline tracker; the lab's tracking exercise is its reduced form. Flow tells you pixel motion; tracking maintains object identity — different problems, frequently confused.
Chapter 8: Camera Geometry and Calibration
The math that turns pixels into measurements:
- Pinhole model: a 3-D point projects through the intrinsics matrix K (focal
lengths fx, fy; principal point cx, cy) after the extrinsics [R|t] place it in
camera coordinates: $x \sim K[R|t]X$. Homogeneous coordinates make projection
linear; the
~hides the perspective divide. - Lens distortion (radial k1, k2…, tangential p1, p2) bends the ideal model — straight lines bow; undistortion applies the inverse mapping.
- Calibration (Zhang's method,
cv2.calibrateCamera): images of a checkerboard of known geometry → solve for K, distortion, and per-view extrinsics; reprojection error (~<0.5 px when healthy) is the quality metric — and the lab's acceptance test. - What calibration buys: metric measurement (pixels → angles/sizes), undistorted inputs for geometry-sensitive downstream (lane detection, AR), stereo rectification → depth, and homography-based plane measurements (bird's-eye view). Cameras are sensors; calibration is their datasheet — uncalibrated "measurement" pipelines are vibes.
Lab Walkthrough Guidance
Order: labs 01→05 as numbered — each consumes the previous chapter's tools.
- Lab 01 (image basics): drill the BGR/RGB and (y, x) conventions deliberately; build the float-process/uint8-display habit; histograms before and after every operation.
- Lab 02 (filtering/morphology): implement convolution yourself once (then use
cv2.filter2D); verify Gaussian separability numerically; build the threshold→morphology→contours measurement pipeline on a document or coins image — count and measure objects, end to end. - Lab 03 (features): ORB + ratio test + RANSAC homography → stitch two photos of your desk. Inspect rejected matches — the ratio test's victims teach more than its survivors.
- Lab 04 (flow/tracking): corners + pyramidal LK on a video; then the Kalman-smoothed track on a moving object; break brightness constancy (lighting change) and watch flow fail — knowing the assumption by its failure.
- Lab 05 (calibration): print a checkerboard, calibrate your webcam, report reprojection error, undistort, and measure a known object on a plane via homography — closing the pixels-to-millimeters loop personally.
Success Criteria
You are ready for Phase 02 when you can, from memory:
- State the uint8/float, BGR/RGB, and (y, x) conventions and the bug each causes.
- Explain Gaussian separability's O(k²)→O(k) and when median/bilateral beat Gaussian.
- Walk Canny's four stages and the hysteresis design pattern.
- Build (on paper) the Otsu→morphology→contours measurement pipeline and say when it beats deep learning.
- Explain ratio test + RANSAC and the hypothesize-verify pattern.
- State brightness constancy, the aperture problem, and why LK tracks corners.
- Write the pinhole projection and define reprojection error.
Interview Q&A
Q: When would you choose classical CV over a neural network today? Controlled imaging (fixed camera, lighting, parts): threshold+morphology+contours is faster (sub-ms on CPU), explainable, tunable without data collection, and certifiable — industrial inspection runs on it. Also: geometry tasks (calibration, stitching, AR-plane math) are solved classically, and hybrid systems are the real answer — a detector finds the part, classical geometry measures it. The wrong choice is classical methods on in-the-wild imagery — that's what learned features fixed.
Q: Your feature matcher works on photos but fails between a photo and a rendering of the same scene. Why? Descriptors encode local appearance (gradient statistics); rendering changes texture, noise, and shading statistics even with identical geometry — descriptors land far apart despite corresponding points. Classical fixes: match on structure (edges/lines), heavier normalization; the modern fix: learned descriptors/matchers (SuperPoint/ SuperGlue-class) trained for cross-domain invariance. The diagnosis being tested: detection repeatability vs descriptor invariance are separate failure axes.
Q: Explain RANSAC to a junior, including its knobs. Fit a model when most data is good but some is garbage: repeatedly sample the minimum points to define the model (4 correspondences for a homography), fit, count how many other points agree within a tolerance (inliers), keep the best consensus, refit on its inliers. Knobs: the inlier tolerance (your noise model), iterations (from the outlier rate: enough trials that ≥1 all-inlier sample is near-certain), and the minimal-set size (the model's freedom). It fails when outliers are structured (a second valid motion) — at which point you want robust multi-model methods, or to ask why your data has two stories.
References
- Szeliski, Computer Vision: Algorithms and Applications (2nd ed., free online) — ch. 2–9 are this phase
- OpenCV-Python tutorials — work alongside each lab
- Canny, A Computational Approach to Edge Detection (1986)
- Lowe, Distinctive Image Features from Scale-Invariant Keypoints (2004) — SIFT + the ratio test
- Rublee et al., ORB: an efficient alternative to SIFT or SURF (2011)
- Fischler & Bolles, RANSAC (1981)
- Lucas & Kanade (1981) and Bouguet, Pyramidal Implementation of the Lucas-Kanade Feature Tracker
- Zhang, A Flexible New Technique for Camera Calibration (2000)
- Bewley et al., Simple Online and Realtime Tracking (SORT) (2016) — arXiv:1602.00763
Lab 01 — Image Basics: Color Spaces, Histograms, Pixel Operations
Phase: 1 — Classical CV | Difficulty: ⭐⭐☆☆☆
Color Spaces
An image is a function $I: \mathbb{R}^2 \rightarrow \mathbb{R}^C$ mapping spatial coordinates to color values. Different color spaces parameterize color differently, and each color space is suited to different tasks.
BGR / RGB
The default representation. Each pixel is a 3-tuple $(B, G, R)$ or $(R, G, B)$ in [0, 255].
OpenCV always uses BGR — cv2.imread returns BGR. Convert to RGB before showing with matplotlib or passing to PyTorch models.
HSV (Hue, Saturation, Value)
$$H \in [0, 179], \quad S \in [0, 255], \quad V \in [0, 255]$$
(OpenCV uses H range of 0–179 to fit in uint8; multiply by 2 for 0–360°)
- Hue: color (red=0°, green=120°, blue=240°)
- Saturation: color purity (0=gray, 255=fully saturated)
- Value: brightness
Why HSV matters: Color-based object segmentation is trivially easy in HSV. To detect red objects:
mask = cv2.inRange(hsv, (0, 100, 100), (10, 255, 255))
In RGB, "red" spans a complex 3D region. In HSV, it's a simple range on a single channel.
LAB (L*a*b*)
- L: perceptual lightness (0=black, 100=white)
- a: green (−) to red (+) axis
- b: blue (−) to yellow (+) axis
Why LAB matters:
- Perceptually uniform: Euclidean distance in LAB correlates with human-perceived color difference
- Separates luminance from chrominance: The L channel is a pure grayscale image unaffected by color
- Used in skin detection: skin tones cluster in a small region of the a*b* plane
YCrCb (Luminance + Chroma)
JPEG and video codecs store images in YCrCb. The Y channel carries most visual information (human eyes are more sensitive to luminance than chroma), enabling chroma subsampling (4:2:0 or 4:2:2) for compression.
Grayscale
$$\text{Gray} = 0.114 \cdot B + 0.587 \cdot G + 0.299 \cdot R$$ (BT.601 standard — not a simple average! Green is weighted most heavily because human eyes are most sensitive to green light.)
Histograms
A histogram counts how many pixels have each intensity value. For an 8-bit image, there are 256 bins.
Uses:
- Exposure analysis: a histogram bunched left = underexposed, right = overexposed
- Thresholding: Otsu's method finds the optimal threshold by maximizing inter-class variance
- Image matching: histogram similarity as a lightweight retrieval metric
- Normalization: histogram equalization improves contrast for dark images
Histogram Equalization: Spreads the histogram to cover the full range. The equalization function is the CDF (cumulative distribution function): $$h_{eq}(v) = \text{round}\left(\frac{\text{CDF}(v) - \text{CDF}{min}}{(H \times W) - \text{CDF}{min}} \times 255\right)$$
CLAHE (Contrast Limited Adaptive Histogram Equalization): Applies equalization to small tiles, limiting the amplification of noise. Better than global equalization for images with varied lighting conditions. Widely used in medical imaging.
Morphological Operations
Morphology operates on binary images using a structuring element (SE, similar to a kernel):
| Operation | Definition | Use case |
|---|---|---|
| Erosion | A pixel survives only if all pixels in SE are foreground | Remove small noise, thin objects |
| Dilation | A pixel becomes foreground if any pixel in SE is foreground | Fill holes, thicken objects |
| Opening | Erosion then dilation | Remove small isolated foreground regions (noise) |
| Closing | Dilation then erosion | Fill small holes in foreground regions |
| Gradient | Dilation − Erosion | Extract object edges/borders |
| Top-hat | Image − Opening | Highlight bright details on dark background |
Otsu's Thresholding
Finds the optimal global threshold $t^*$ that minimizes intra-class variance (equivalently, maximizes inter-class variance):
$$\sigma_B^2(t) = \omega_0(t)\omega_1(t)[\mu_0(t) - \mu_1(t)]^2$$
where $\omega_0, \omega_1$ are class probabilities and $\mu_0, \mu_1$ are class means.
When to use: Works well when the histogram is bimodal. Fails on unimodal histograms or images with spatially varying illumination (use CLAHE + Otsu or adaptive thresholding instead).
Interview Questions
Q: Why does OpenCV use BGR instead of RGB?
A: Historical artifact — early BGR cameras and the Windows BITMAP format stored channels in BGR order. OpenCV was built in the Windows era and never changed the default. Always convert with cv2.cvtColor(img, cv2.COLOR_BGR2RGB) before displaying with matplotlib or passing to PyTorch/TF models (which expect RGB).
Q: In HSV, why is detecting a color range simpler than in RGB?
A: In RGB, a "red" object under varying illumination spans a 3D ellipsoid in color space. In HSV, illumination changes affect only V (value), and color is captured by H (hue). So the same object under different lighting conditions falls in a narrow H range — you only need to threshold one channel for color, and can use V for illumination invariance. This is why all real-time color tracking systems (robot competitions, simple object trackers) use HSV.
Q: What is CLAHE and when would you use it over regular histogram equalization?
A: CLAHE (Contrast Limited Adaptive HE) divides the image into tiles and equalizes each tile independently. It limits the amplification factor (clip limit) to prevent noise amplification. Use it over global equalization when: (1) the image has spatially varying illumination (faces in shadow, medical X-rays), (2) you need to preserve relative contrast, or (3) the image has large uniform regions that would dominate global HE.
Lab 02 — Spatial Filtering, Edge Detection & Morphology
Phase: 1 — Classical CV | Difficulty: ⭐⭐⭐☆☆
Spatial Filtering (Convolution-based)
A spatial filter (or kernel) modifies each pixel based on its neighborhood. This is the same convolution operation used in CNNs — understanding it classically makes deep learning more intuitive.
Gaussian Blur
$$G(x, y) = \frac{1}{2\pi\sigma^2} e^{-\frac{x^2+y^2}{2\sigma^2}}$$
Properties:
- Separable: $G_{2D} = G_{1D} \otimes G_{1D}^T$ → apply 1D horizontally then vertically (reduces ops from $O(k^2)$ to $O(2k)$ per pixel)
- Isotropic: same blur in all directions
- Removes high-frequency noise (blurs sharp edges too)
Sigma vs kernel size: $\sigma$ controls the spread. Kernel size should be $\geq 6\sigma + 1$ (to capture ~99.7% of the Gaussian). In OpenCV: cv2.GaussianBlur(img, (ksize, ksize), sigma) — if sigma=0, it's inferred from ksize.
Median Filter
Replaces each pixel with the median of its neighborhood. Non-linear — not expressible as a convolution.
Key advantage: Robust to outliers (salt-and-pepper noise). A single extreme pixel value doesn't affect the median. Gaussian blur would smear it.
Disadvantage: Computationally expensive (O(k² log k²) per pixel). Edges are better preserved than Gaussian.
Bilateral Filter
Combines spatial proximity (like Gaussian) with color/intensity similarity:
$$I_{filtered}(x) = \frac{1}{W} \sum_{x' \in \Omega} I(x') \cdot G_s(x'-x) \cdot G_r(I(x')-I(x))$$
- $G_s$: spatial Gaussian (penalizes distant pixels)
- $G_r$: range Gaussian (penalizes pixels with different intensity)
Effect: Smooths flat regions (same intensity) while preserving edges (large intensity difference). Used in portrait photography, HDR tone mapping.
Gradient-based Edge Detection
Image Gradients
The gradient of a continuous image $I$: $$\nabla I = \left(\frac{\partial I}{\partial x}, \frac{\partial I}{\partial y}\right)$$
Discrete approximations (Sobel operators): $$S_x = \begin{bmatrix} -1 & 0 & +1 \ -2 & 0 & +2 \ -1 & 0 & +1 \end{bmatrix}, \quad S_y = S_x^T$$
Gradient magnitude: $||\nabla I|| = \sqrt{G_x^2 + G_y^2}$ (approximated as $|G_x| + |G_y|$ for speed)
Gradient direction: $\theta = \arctan(G_y / G_x)$
Laplacian (second derivative, detects zero-crossings = edges): $$\nabla^2 I = \frac{\partial^2 I}{\partial x^2} + \frac{\partial^2 I}{\partial y^2}$$
LoG (Laplacian of Gaussian): Gaussian blur then Laplacian. Equivalent to the Mexican hat wavelet.
Canny Edge Detection — Step by Step
Canny is the gold standard classical edge detector. Steps:
- Gaussian blur: $I_{\sigma} = G_\sigma * I$ — suppress noise
- Gradient computation: $G_x, G_y$ via Sobel. Compute magnitude $M$ and direction $\theta$
- Non-maximum suppression (NMS): Thin edges. For each pixel, keep it only if it's a local maximum along the gradient direction. Suppresses thick "fat" edges.
- Double thresholding: Classify pixels as:
- Strong edge: $M > T_{high}$
- Weak edge: $T_{low} < M \leq T_{high}$
- Non-edge: $M \leq T_{low}$
- Hysteresis edge tracking: Keep a weak edge pixel only if it's connected to a strong edge pixel (8-connectivity). This retains real edges that have low gradient at some points while eliminating isolated noise pixels.
Aperture parameter: Canny uses a Sobel kernel of size apertureSize (1, 3, 5, or 7). Larger = detects smoother/larger-scale edges, filters out fine detail.
Contour Analysis
After binary segmentation or edge detection, contours are the boundaries of connected foreground regions.
contours, hierarchy = cv2.findContours(
binary_mask,
cv2.RETR_EXTERNAL, # only outer contours (vs RETR_TREE for full hierarchy)
cv2.CHAIN_APPROX_SIMPLE # compress horizontal/vertical runs (saves memory)
)
Contour features:
area = cv2.contourArea(cnt) # pixels
perimeter = cv2.arcLength(cnt, closed=True) # pixels
circularity = 4*np.pi*area / (perimeter**2 + 1e-8) # 1.0 = perfect circle
x,y,w,h = cv2.boundingRect(cnt) # bounding box
(cx,cy), radius = cv2.minEnclosingCircle(cnt) # min enclosing circle
hull = cv2.convexHull(cnt) # convex hull
Shape matching: cv2.matchShapes(cnt1, cnt2, cv2.CONTOURS_MATCH_I1, 0) — uses Hu moments (7 moment invariants that are invariant to translation, rotation, scale, and reflection for some).
Hough Transform
Detects lines and circles by voting in parameter space.
Line detection: Each edge pixel $(x, y)$ votes for all lines passing through it. A line $y = mx + b$ is parameterized as $\rho = x\cos\theta + y\sin\theta$ to avoid infinite slope. Peaks in the $(\rho, \theta)$ accumulator → lines.
lines = cv2.HoughLinesP(edges, rho=1, theta=np.pi/180, threshold=100,
minLineLength=50, maxLineGap=10)
Circle detection: cv2.HoughCircles() votes in $(x_c, y_c, r)$ space.
Interview Questions
Q: Walk me through Canny edge detection step by step.
A: (1) Gaussian blur to reduce noise. (2) Compute x and y gradients via Sobel, then magnitude and direction. (3) Non-maximum suppression: for each pixel, zero it out if it's not the local maximum along its gradient direction — this thins edges from fat blobs to single-pixel lines. (4) Double thresholding: strong edges above high threshold, weak edges between low/high, discard below low. (5) Hysteresis: walk connected components — a weak edge pixel is kept only if connected to a strong edge pixel. The two thresholds are typically set at ratio 1:2 or 1:3 (e.g., 50 and 150).
Q: What is the difference between Gaussian and bilateral filtering? When would you use each?
A: Gaussian blur is a linear filter that weights neighbors by spatial distance only — it always blurs edges. Bilateral filter additionally weights by intensity similarity, so pixels across a sharp edge contribute little (intensity very different), while pixels within a smooth region contribute a lot (intensity similar). Use Gaussian for simple noise removal where edge preservation doesn't matter. Use bilateral for portrait smoothing, medical image preprocessing, or any case where you need to denoise while keeping edges sharp. Bilateral is ~10–100× slower than Gaussian.
Q: What does NMS (non-maximum suppression) do in Canny? Why is it needed?
A: After computing gradient magnitude, edges appear as thick ridges (multiple pixels wide) rather than thin lines. NMS thins edges to single-pixel width by suppressing pixels that are not local maxima along the gradient direction. For each pixel, we look at the two neighbors in the gradient direction and only keep the pixel if it has the highest gradient magnitude. Without NMS, all subsequent thresholding would operate on thick noisy blobs, making precise edge localization impossible.
Lab 03 — Feature Detection: Harris, SIFT, ORB & Homography
Phase: 1 — Classical CV | Difficulty: ⭐⭐⭐⭐☆
What Are Keypoints & Descriptors?
A keypoint is a distinctive location in an image (corner, blob, etc.) characterized by:
- Position $(x, y)$
- Scale (size of the neighborhood used to describe it)
- Orientation (dominant gradient direction for rotation invariance)
- Response (detection strength)
A descriptor is a compact vector representation of the local appearance around a keypoint, designed to be distinctive and invariant to certain transformations.
Harris Corner Detector
Based on the structure tensor (second-moment matrix) of local image gradients:
$$M = \sum_{(x,y) \in W} \begin{bmatrix} I_x^2 & I_x I_y \ I_x I_y & I_y^2 \end{bmatrix}$$
The eigenvalues $\lambda_1, \lambda_2$ of $M$ reveal the local structure:
| $\lambda_1$ | $\lambda_2$ | Region |
|---|---|---|
| Both large | Both large | Corner — large variation in all directions |
| One large, one small | Edge — large variation in one direction only | |
| Both small | Both small | Flat — little variation |
Harris response function (avoids computing eigenvalues directly): $$R = \det(M) - k \cdot \text{trace}(M)^2 = \lambda_1 \lambda_2 - k(\lambda_1 + \lambda_2)^2$$
Typical $k = 0.04$–$0.06$.
Limitations: Not scale-invariant (a corner at one scale is an edge at another scale).
SIFT — Scale-Invariant Feature Transform (Lowe, 2004)
SIFT achieves scale, rotation, and partial illumination invariance. The algorithm:
1. Scale-Space Extrema Detection
Build a Gaussian pyramid: apply Gaussians with increasing $\sigma$ to the image. Then compute Difference of Gaussians (DoG) between consecutive levels: $$D(x, y, \sigma) = (G(x, y, k\sigma) - G(x, y, \sigma)) * I(x, y)$$
DoG approximates the Laplacian of Gaussian (blob detector). Find local extrema (maxima and minima) across scale and space.
2. Keypoint Localization
Refine extrema positions via Taylor expansion. Discard low-contrast candidates and keypoints on edges (using Harris-like eigenvalue ratio criterion: $r = \lambda_{max}/\lambda_{min}$, discard if $r > 10$).
3. Orientation Assignment
For each keypoint, compute gradient magnitude and direction in the local region (scaled by $\sigma$). Build a histogram of gradient orientations (36 bins). The dominant orientation becomes the keypoint's orientation — this enables rotation invariance.
4. Descriptor Computation
Take a $16 \times 16$ window around the keypoint, divide into $4 \times 4$ cells. In each cell, compute an 8-bin gradient orientation histogram. Concatenate: $4 \times 4 \times 8 = 128$-dimensional descriptor. Normalize for illumination invariance.
128-dim SIFT descriptor: distinctive and relatively robust. Matching uses L2 distance.
ORB — Oriented FAST and Rotated BRIEF
ORB was designed as a free, faster alternative to SIFT/SURF (SIFT has a patent, though it expired in 2020).
FAST (Features from Accelerated Segment Test)
A keypoint is a corner if a contiguous arc of $n \geq 9$ pixels (out of 16) on a circle of radius 3 are all brighter or all darker than the center by a threshold. Very fast (no gradients computed).
BRIEF (Binary Robust Independent Elementary Features)
Computes a binary string descriptor by comparing intensity pairs in the keypoint's neighborhood. Sampling locations are predetermined from a pattern. 256-bit descriptor — matched via Hamming distance (XOR + popcount), much faster than L2.
ORB's contribution: Makes FAST orientation-aware (using intensity centroid), and makes BRIEF rotation-invariant (rotate the sampling pattern by the keypoint's orientation, "rBRIEF").
Comparison:
| Feature | SIFT | ORB | AKAZE |
|---|---|---|---|
| Descriptor size | 128 float32 = 512 bytes | 256 bits = 32 bytes | 61 bytes |
| Scale invariant | ✅ | ✅ | ✅ |
| Rotation invariant | ✅ | ✅ | ✅ |
| Affine invariant | ❌ | ❌ | Partial |
| Speed | Slow | Fast | Medium |
| Patent (2024) | Free | Free | Free |
Feature Matching
Brute Force Matcher (BFMatcher)
Compares every descriptor in set A with every descriptor in set B: O(N·M) distance computations.
- For SIFT: use
cv2.NORM_L2 - For ORB/BRIEF: use
cv2.NORM_HAMMING
FLANN (Fast Library for Approximate Nearest Neighbors)
Uses tree-based structures (KD-trees for float descriptors, LSH for binary). Much faster than brute force for large descriptor sets, at the cost of occasional missed matches.
Lowe's Ratio Test
Keep a match only if the best match is significantly better than the second best: $$\frac{d_1}{d_2} < 0.75$$
Eliminates ambiguous matches where two features look similar.
Homography
A homography is a projective transformation mapping points between two planes:
$$\begin{bmatrix} x' \ y' \ 1 \end{bmatrix} \sim H \begin{bmatrix} x \ y \ 1 \end{bmatrix}, \quad H \in \mathbb{R}^{3 \times 3}$$
Has 8 degrees of freedom (9 elements, but scale is arbitrary so divide by H[2,2]).
Applications:
- Panorama stitching: align overlapping photos
- Document scanning: "deskew" a photographed page
- AR marker tracking: map screen coordinates onto a marker plane
- Visual localization: match current view to a reference map
RANSAC (Random Sample Consensus): Required because matched features include outliers. Algorithm:
- Randomly sample 4 point pairs (minimum to solve homography)
- Compute $H$ from these 4 pairs
- Count inliers: points where $|Hx - x'| < \epsilon$ ("reprojection error")
- Keep the $H$ with the most inliers
- Refit $H$ on all inliers
H, mask = cv2.findHomography(pts_src, pts_dst, cv2.RANSAC, ransacReprojThreshold=5.0)
Interview Questions
Q: Explain how SIFT achieves scale invariance.
A: SIFT detects keypoints in scale-space — an image pyramid where each level is blurred by a larger sigma. Keypoints are found at local extrema in this 3D (x, y, scale) space. Because the same corner detected at different scales corresponds to the same physical feature, you can match across scale changes. The keypoint's scale is the sigma at which it was detected, and the descriptor is computed using a window scaled proportionally — so the descriptor always captures the same physical region regardless of image scale.
Q: Why is the Lowe ratio test used in feature matching, and what does the 0.75 threshold mean?
A: The ratio test compares the distance to the best match vs the second-best match. If the ratio is < 0.75, the best match is "clearly better" than alternatives, so the match is considered reliable. If ratio ≥ 0.75, the feature looks similar to multiple candidates — it's ambiguous. The 0.75 threshold was empirically found by Lowe to give the best tradeoff between false positives (incorrect matches) and false negatives (missed correct matches). For stricter matching (less false positives), lower the threshold; for more matches, raise it.
Q: What is RANSAC and why is it needed for homography estimation?
A: Feature matching always produces some incorrect (outlier) matches even after the ratio test. Standard least-squares for homography fitting assumes all input correspondences are correct — one outlier can dominate the solution. RANSAC is a robust fitting algorithm: it randomly samples the minimum number of points needed (4 for homography), computes a candidate model, then counts how many other points fit that model (inliers). This is repeated many times; the model with the most inliers wins. Final homography is re-fit on all inliers. RANSAC is used everywhere: SLAM, structure-from-motion, PnP pose estimation.
Lab 04 — Optical Flow & Object Tracking
Phase 1: Classical Computer Vision | Week 3-4
Learn how images change over time — the foundation of video understanding, autonomous driving, and surveillance systems.
Learning Objectives
- Derive and implement Lucas-Kanade optical flow from the brightness constancy constraint
- Understand dense vs sparse optical flow and when to use each
- Implement a simple tracker using Kalman filtering concepts
- Know the math behind why optical flow fails at edges (aperture problem)
Theory
Optical Flow — Core Equation
Brightness constancy assumption: $$I(x, y, t) = I(x + dx, y + dy, t + dt)$$
Taylor expand the right side: $$I(x,y,t) + I_x u + I_y v + I_t = I(x,y,t)$$
$$\Rightarrow I_x u + I_y v + I_t = 0$$
where $u = dx/dt$, $v = dy/dt$ are the flow vectors. This is one equation, two unknowns — the aperture problem.
Lucas-Kanade (Sparse, Local)
Assume flow is constant within a window $W$ of pixels:
$$\begin{bmatrix} I_{x1} & I_{y1} \ \vdots & \vdots \ I_{xN} & I_{yN} \end{bmatrix} \begin{bmatrix} u \ v \end{bmatrix} = -\begin{bmatrix} I_{t1} \ \vdots \ I_{tN} \end{bmatrix}$$
Least-squares solution:
$$\mathbf{A}^T\mathbf{A} \begin{bmatrix} u \ v \end{bmatrix} = \mathbf{A}^T \mathbf{b}$$
Note: $\mathbf{A}^T\mathbf{A}$ is exactly the Harris matrix — LK fails on edges (one eigenvalue ≈ 0) and is well-defined only at corners.
Farneback Dense Optical Flow
Polynomial expansion of each neighborhood, then match polynomials. Produces a flow vector for every pixel. Slower but more complete than LK.
Gunnar-Farneback vs Lucas-Kanade Comparison
| Method | Type | Speed | Accuracy | Use Case |
|---|---|---|---|---|
| Lucas-Kanade | Sparse | Fast | High on corners | Track specific features |
| Farneback | Dense | Medium | Medium everywhere | Full motion analysis |
| DeepFlow/RAFT | Dense DL | Slow (GPU) | Best | Production video |
What the Lab Covers
| Function | Concept | Complexity |
|---|---|---|
create_synthetic_video() | Controlled ground-truth motion | - |
lucas_kanade_demo() | Sparse LK with goodFeaturesToTrack | Medium |
farneback_dense_demo() | Dense flow + HSV visualization | Medium |
optical_flow_magnitude_demo() | Motion heatmap, background subtraction | Easy |
multi_scale_pyramid_demo() | Pyramid LK for large motion | Hard |
Key OpenCV Functions
# Detect corners to track
pts = cv2.goodFeaturesToTrack(gray, maxCorners=100, qualityLevel=0.3, minDistance=7)
# Sparse LK optical flow
next_pts, status, err = cv2.calcOpticalFlowPyrLK(prev_gray, next_gray, pts, None,
winSize=(15,15), maxLevel=3)
# Dense Farneback flow
flow = cv2.calcOpticalFlowFarneback(prev, next, None,
pyr_scale=0.5, levels=3, winsize=15,
iterations=3, poly_n=5, poly_sigma=1.1, flags=0)
# flow.shape: (H, W, 2) — (dx, dy) per pixel
Interview Questions
Q: What is the aperture problem? A: At a single pixel, you can only measure the component of flow perpendicular to the local edge direction. Without a window constraint (LK) or regularization (Horn-Schunck), the problem is underdetermined.
Q: Why does optical flow break down at occlusion boundaries? A: The brightness constancy assumption fails when a pixel in frame $t$ corresponds to a different surface in frame $t+1$ (occlusion). The Taylor expansion is also invalid for large displacements — hence pyramid schemes.
Q: How does RAFT (2020) improve over classical methods? A: RAFT iteratively updates a dense flow field using a correlation volume (4D cost volume over all displacement combinations) and a recurrent GRU update operator. It handles large displacements without a fixed scale pyramid.
Q: How is optical flow used in action recognition? A: Two-stream networks: one stream on RGB frames, one stream on stacked optical flow fields. The flow stream provides motion cues that appearance alone can't capture.
Run
pip install -r requirements.txt
python solution.py
# Outputs saved to outputs/
Lab 05 — Camera Calibration & Pose Estimation
Phase 1: Classical Computer Vision | Week 4
Every computer vision system that interacts with the physical world — robots, AR, autonomous vehicles — needs to know its camera model. This lab teaches you to calibrate cameras and estimate 3D pose.
Learning Objectives
- Understand the pinhole camera model and projection equations
- Perform camera calibration using the Zhang method (chessboard)
- Estimate rotation/translation (PnP problem)
- Understand lens distortion and how to correct it
- Apply reprojection error to evaluate calibration quality
Theory
Pinhole Camera Model
A 3D point $\mathbf{P}_W = [X, Y, Z]^T$ in world coordinates projects to pixel $\mathbf{p} = [u, v]^T$:
$$\begin{bmatrix} u \ v \ 1 \end{bmatrix} = \frac{1}{Z} \underbrace{\begin{bmatrix} f_x & 0 & c_x \ 0 & f_y & c_y \ 0 & 0 & 1 \end{bmatrix}}{\mathbf{K}} \underbrace{\begin{bmatrix} R & \mathbf{t} \end{bmatrix}}{[\text{R}|\mathbf{t}]} \begin{bmatrix} X \ Y \ Z \ 1 \end{bmatrix}$$
- $f_x, f_y$: focal lengths in pixels
- $c_x, c_y$: principal point (usually near image center)
- $[R|\mathbf{t}]$: extrinsic matrix (camera pose)
- $\mathbf{K}$: camera intrinsic matrix
Lens Distortion Model
Real lenses introduce radial and tangential distortion. For a normalized point $(x_n, y_n)$:
$$r^2 = x_n^2 + y_n^2$$
$$x_d = x_n(1 + k_1 r^2 + k_2 r^4 + k_3 r^6) + 2p_1 x_n y_n + p_2(r^2 + 2x_n^2)$$
Coefficients $(k_1, k_2, p_1, p_2, k_3)$ are estimated during calibration.
Zhang's Calibration Method (1998)
- Observe a planar pattern (chessboard) from $\geq 3$ different orientations
- Compute homography $H_i$ between pattern plane and image for each view
- Each homography gives 2 constraints on $\mathbf{K}$
- With $\geq 3$ views: solve for $\mathbf{K}$, then refine all parameters via Levenberg-Marquardt
Reprojection error (lower = better, < 0.5px is excellent): $$\text{err} = \frac{1}{N}\sum_i |\mathbf{p}_i - \hat{\mathbf{p}}_i(\mathbf{K}, \mathbf{d}, R_i, \mathbf{t}_i)|_2$$
PnP Problem (Perspective-n-Point)
Given $n \geq 4$ 2D-3D correspondences, solve for camera pose $[R|\mathbf{t}]$:
- EPnP (default, O(N)): efficient closed-form via virtual control points
- RANSAC + PnP: handles outliers for robust pose estimation in the wild
What the Lab Covers
| Function | Concept |
|---|---|
synthesize_chessboard_views() | Generate calibration data with known ground truth |
calibrate_camera_opencv() | Zhang method via cv2.calibrateCamera() |
evaluate_reprojection_error() | Per-view error, residual histogram |
undistort_demo() | Apply distortion correction |
pose_estimation_pnp() | Estimate 6-DOF pose with RANSAC |
draw_axes_on_frame() | Visualize 3D coordinate frame projected onto image |
Key OpenCV Functions
# Find chessboard corners
ret, corners = cv2.findChessboardCorners(gray, board_size)
# Sub-pixel refinement
corners = cv2.cornerSubPix(gray, corners, (11,11), (-1,-1), criteria)
# Calibrate
rms, K, dist, rvecs, tvecs = cv2.calibrateCamera(
obj_points, img_points, img_size, None, None)
# Undistort
undistorted = cv2.undistort(frame, K, dist)
# Or compute map once (faster for video)
map1, map2 = cv2.initUndistortRectifyMap(K, dist, None, K, size, cv2.CV_32FC1)
dst = cv2.remap(frame, map1, map2, cv2.INTER_LINEAR)
# PnP pose estimation
success, rvec, tvec, inliers = cv2.solvePnPRansac(
obj_pts, img_pts, K, dist,
iterationsCount=100, reprojectionError=8.0)
Interview Questions
Q: How many chessboard views do you need for calibration? Why? A: Minimum 3 (each gives 2 constraints on K's 5 DOF), but 15-30 views are used in practice. More views give better statistical averaging and cover diverse angles needed to separate intrinsics from extrinsics.
Q: What's the difference between intrinsic and extrinsic parameters? A: Intrinsic ($\mathbf{K}$, $\mathbf{d}$) are properties of the camera itself — fixed for a given camera/lens. Extrinsic ($R$, $\mathbf{t}$) define the camera's pose in the world — changes each frame.
Q: What does reprojection error tell you? A: The RMS pixel distance between observed 2D points and the same 3D points projected through the estimated camera model. < 0.5px is excellent; > 2px suggests bad views or wrong corner detection.
Q: How would you calibrate a stereo camera system?
A: Calibrate each camera independently first, then use cv2.stereoCalibrate() to jointly optimize and find the relative pose $R, T$ between cameras. cv2.stereoRectify() then aligns epipolar lines to be horizontal for efficient matching.
Run
pip install -r requirements.txt
python solution.py
# Outputs saved to outputs/
Phase 2 — Machine Learning Fundamentals
Weeks: 5–6 | Goal: Scikit-learn pipelines, data preprocessing, model evaluation metrics for CV
Labs
| Lab | Topic | Key Skills |
|---|---|---|
| lab-01-sklearn-pipeline | SVM, Random Forest, cross-validation | ML pipeline, hyperparameter search |
| lab-02-data-preprocessing | Normalization, augmentation, class imbalance | Albumentations, SMOTE, focal loss |
| lab-03-model-evaluation | Confusion matrix, mAP, ROC-AUC | Evaluation metrics for object detection |
Why ML Fundamentals Matter for CV Engineers
Many production CV systems use classical ML on top of CNN features:
- SVM on CNN embeddings (classic fine-grained recognition approach)
- One-class SVM for novelty detection / OOD detection
- Decision trees for interpretable defect classification in manufacturing
- k-NN in embedding space for zero-shot recognition
More importantly: evaluation metrics for CV are notoriously tricky. Misunderstanding mAP@0.5 vs mAP@0.5:0.95 in a model comparison is a senior-level interview red flag.
Warmup Guide — ML Fundamentals
Zero-to-expert primer for Phase 02: the machine-learning method itself — generalization, the data discipline, evaluation that doesn't lie, and the sklearn machinery that encodes all of it — before any deep learning.
Table of Contents
- Chapter 1: What Learning Is — Generalization, Not Memorization
- Chapter 2: The Bias-Variance Lens
- Chapter 3: Data Splits and Leakage — The Cardinal Sins
- Chapter 4: Preprocessing as Part of the Model
- Chapter 5: The Classical Model Zoo and When Each Wins
- Chapter 6: Evaluation Metrics — Beyond Accuracy
- Chapter 7: Cross-Validation and Honest Model Selection
- Chapter 8: Pipelines — The Engineering Pattern
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: What Learning Is — Generalization, Not Memorization
Supervised learning: from examples $(x_i, y_i)$ drawn from some distribution, find $f$ minimizing loss on new draws from the same distribution. The italics carry the whole field: training loss is what you optimize, generalization is what you want, and the gap between them is the central object of study. A lookup table achieves zero training loss and learns nothing; the entire apparatus of this phase — splits, validation, regularization, cross-validation — exists to estimate and control that gap honestly.
Corollary that bites in practice: the i.i.d. assumption is a claim about deployment. A model is a contract with a distribution; when production drifts from training (new camera, new season, new user mix), the contract is void and metrics rot silently — which is why monitoring (Phase 07) is part of ML, not an afterthought.
Chapter 2: The Bias-Variance Lens
Expected error decomposes (conceptually always, exactly for squared loss) into:
- Bias: error from the model family being too simple for the truth — underfitting. Signature: train and validation error both high and close.
- Variance: error from sensitivity to the particular training sample — overfitting. Signature: train error low, validation error much higher.
- Irreducible noise: label noise and unmeasured factors — the floor.
The knobs and their direction: more model capacity (deeper trees, higher polynomial, more features) trades bias down for variance up; more data reduces variance at fixed capacity (why deep learning works at scale); regularization (L2 ridge, L1 lasso, tree depth limits, early stopping) buys variance reduction at a bias price. The diagnostic instrument is the learning curve (error vs training-set size): high bias = both curves plateau high together (more data won't help — change the model); high variance = persistent gap (more data or regularization will help). Drawing and reading these curves is a lab deliverable and a career-long reflex.
Chapter 3: Data Splits and Leakage — The Cardinal Sins
- Three-way split: train (fit parameters) / validation (choose hyperparameters, models) / test (touched once, at the end, as the unbiased estimate). The moment you iterate against the test set, it becomes a validation set and its number becomes optimistic fiction — the sin every leaderboard-chasing team commits.
- Leakage: information from outside the training fold influencing the model.
The canonical forms, all of which the lab makes you commit and then catch:
- Preprocessing leakage: fitting the scaler/imputer/feature-selector on all data before splitting (test statistics leaked into training). The fix is structural — Chapter 8's Pipeline.
- Group leakage: correlated samples straddling the split — multiple images of the
same patient/object/session; random splits inflate scores wildly (use
GroupKFold). - Temporal leakage: shuffling time series — training on the future (use time-based splits).
- Target leakage: features that encode the label by construction (the "filled by the outcome process" column).
- The professional reflex: a score that looks too good is a bug until proven otherwise — leakage hunting is debugging, with the split design as the prime suspect.
Chapter 4: Preprocessing as Part of the Model
Transformations are learned from training data and therefore part of the model:
- Scaling: standardization (z-score) for SVMs/linear models/k-NN (anything distance- or gradient-based — features on different scales otherwise dominate by unit choice); trees don't care (split points are scale-invariant) — knowing who needs what beats applying everything everywhere.
- Categorical encoding: one-hot for low cardinality; ordinal only when order is real; target encoding is powerful and a leakage minefield (must be fit within folds).
- Imputation: median/most-frequent as defaults; add missingness-indicator columns — missingness is often signal.
- Class imbalance:
class_weightfirst (cheap, principled), resampling (SMOTE et al.) with care — and never resample the validation/test folds (evaluate on the true distribution you'll face). - Every one of these has a fit/transform split — fit on train folds only — which is exactly the contract sklearn's API encodes (Ch. 8).
Chapter 5: The Classical Model Zoo and When Each Wins
The models worth genuinely knowing, each with its niche and its failure:
- Linear/logistic regression: the baseline that's embarrassingly hard to beat on wide, noisy data; coefficients are interpretable (with correlated-features caveats); regularized variants (ridge/lasso) are the defaults. Logistic outputs are probabilities by construction (calibration still worth checking).
- k-NN: no training, all inference; the curse of dimensionality is its biography — in high-D, everything is equidistant (Phase 00 Ch. 4's geometry). Great as a sanity baseline and for "similar items" features.
- SVM: maximum-margin linear classifier; the kernel trick buys nonlinearity without explicit features. Strong on small/medium clean data; scales poorly past ~10⁵ samples.
- Decision trees: axis-aligned recursive splits — interpretable, scale-free, high-variance alone.
- Random forests: bagging (bootstrap + feature subsampling) averages away tree variance — the robust default for tabular data; near-zero tuning, built-in feature importances (with bias toward high-cardinality features — caveat).
- Gradient boosting (XGBoost/LightGBM): trees fit sequentially to residuals — the tabular state of the art when tuned; the answer to "why not deep learning on tabular data" is usually "boosting wins."
- The meta-skill: always fit the dumb baseline first (majority class, linear model) — the delta over baseline is your actual contribution, and half of impressive numbers evaporate against it.
Chapter 6: Evaluation Metrics — Beyond Accuracy
Accuracy fails exactly when it matters (imbalance): 99% accuracy on 1% prevalence is the all-negative classifier. The toolkit:
- The confusion matrix is the ground truth; everything else is a summary of it.
- Precision (of predicted positives, how many real) vs recall (of real positives, how many found) — the trade is set by the decision threshold, and choosing the threshold is a business decision (cost of FP vs FN), not a default 0.5. F1 is their harmonic mean when you must have one number; F-β when costs are asymmetric.
- PR curves vs ROC curves: ROC-AUC is threshold-free but flatters under heavy imbalance (the FPR denominator is huge); PR-AUC is the honest summary for rare positives — the distinction is a standard interview probe and a real reporting decision.
- Calibration: does predicted 0.8 mean 80%? Reliability diagrams; Platt/isotonic recalibration — matters whenever scores feed downstream decisions (it almost always does).
- And — from the other tracks' shared discipline — error bars: scores on finite test sets carry sampling variance; cross-validation's per-fold spread (Ch. 7) is the cheapest honest uncertainty estimate.
Chapter 7: Cross-Validation and Honest Model Selection
- k-fold CV (k=5/10): every sample serves in validation once; report mean ± std — the spread is your uncertainty and your overfitting alarm. Stratify for classification (preserve class ratios per fold); group/time variants per Ch. 3's leakage modes.
- Hyperparameter search: grid (small spaces), random (better per budget in high-D — the counterintuitive classic result), then Bayesian/successive-halving when runs are expensive. All searched inside the CV loop on train data only.
- Nested CV for the strict question "what's the generalization of my whole selection procedure": outer folds estimate, inner folds select — expensive, and the honest answer when the dataset is small and the claim matters.
- The result of selection is a procedure; the final model refits the winning recipe on all training data, and the untouched test set speaks once.
Chapter 8: Pipelines — The Engineering Pattern
sklearn's deepest design contribution is the API contract: everything is an
estimator (fit), transformers add transform, predictors add predict — and
Pipeline chains them into one estimator:
pipe = Pipeline([("prep", ColumnTransformer([...])), ("clf", RandomForestClassifier())])
cross_val_score(pipe, X, y, cv=5) # preprocessing fit *inside* each fold
GridSearchCV(pipe, {"clf__max_depth": [3, 10, None]}, cv=5)
What the pattern buys, structurally: preprocessing leakage becomes impossible
(fit happens per-fold automatically — Ch. 3's worst sin engineered away),
hyperparameter search spans preprocessing and model in one space, and the deployed
artifact is a single serializable object whose train-time and serve-time transforms
cannot drift apart (the training-serving skew bug, pre-empted — Phase 07 returns to
this). ColumnTransformer routes numeric/categorical columns; custom transformers
implement fit/transform and slot in. This composition discipline is the phase's
lasting engineering lesson — the deep-learning equivalents (torchvision transforms,
tf.data) are the same idea with tensors.
Lab Walkthrough Guidance
Order: labs 01→04 as numbered.
- Lab 01 (sklearn pipeline): build the full Pipeline + ColumnTransformer + CV + GridSearch stack on a tabular problem; then deliberately commit preprocessing leakage (scaler fit on all data, manually) and measure the score inflation — the delta is the lesson.
- Lab 02 (preprocessing): per-model scaling experiments (SVM vs forest with/without standardization); target-encoding leakage demo; imbalance handling with the evaluate-on-true-distribution rule.
- Lab 03 (model evaluation): imbalanced dataset; produce confusion matrix, PR and ROC curves, threshold sweep with a cost function, calibration plot — and write the one-paragraph "which metric and threshold I'd ship and why."
- Lab 04 (pandas/sklearn deep dive): the EDA-to-model workflow — groupby aggregations as features, learning curves (Ch. 2's instrument), and the baseline- first discipline with a results table.
Success Criteria
You are ready for Phase 03 when you can, from memory:
- Define generalization gap and explain why the test set speaks once.
- Diagnose bias-vs-variance from learning curves and prescribe per diagnosis.
- Name four leakage modes with their split-design fixes.
- Say which models need scaling and why trees don't.
- Choose PR-AUC vs ROC-AUC under imbalance and defend a threshold from costs.
- Explain how Pipeline makes preprocessing leakage structurally impossible.
Interview Q&A
Q: Your colleague reports 99.2% accuracy on fraud detection. What do you ask? Base rate first (at 0.5% fraud prevalence, all-negative scores 99.5% — accuracy is the wrong metric; show me the confusion matrix and PR curve). Then split design (group leakage across the same card/customer? temporal leakage — trained on future?), then preprocessing leakage (encoders fit on full data), then target leakage (post-hoc features). The ordered skepticism — metric, split, leakage — is the answer; "great, ship it" is the failing one.
Q: Random forest vs gradient boosting — how do you choose? Forest: parallel bagging, variance reduction, nearly tuning-free, robust to noise — the fast strong baseline. Boosting: sequential bias reduction, higher ceiling when tuned (LR, depth, regularization), more overfit-prone on noisy/small data, and sensitive to label noise. Practice: forest first as the honest baseline; boosting when the delta matters and you'll pay the tuning cost; both lose to "fix the features" most of the time.
Q: Why does cross-validation give you uncertainty "for free," and what doesn't it cover? Per-fold scores are k estimates of generalization from k different train/val draws — their spread estimates sampling variance of the score. What it misses: folds share most training data (estimates correlated — the spread understates), it says nothing about distribution shift (i.i.d. within the dataset only), and selection bias if you report the best of many CV runs (multiple comparisons — the other tracks' Phase 08/09 statistics apply here verbatim).
References
- Hastie, Tibshirani, Friedman, The Elements of Statistical Learning (free PDF) — ch. 2, 7 for bias-variance and model assessment
- scikit-learn User Guide — especially "Common pitfalls and recommended practices" (the leakage doc)
- Kaufman et al., Leakage in Data Mining (2012) — the leakage taxonomy's source
- Bergstra & Bengio, Random Search for Hyper-Parameter Optimization (2012)
- Davis & Goadrich, The Relationship Between Precision-Recall and ROC Curves (2006)
- Niculescu-Mizil & Caruana, Predicting Good Probabilities with Supervised Learning (2005) — calibration
- Hands-On Machine Learning (Géron, 3rd ed.) — ch. 2–7 pair with the labs
Lab 01: Scikit-learn Pipelines & Classical ML
What You'll Learn
- Build production-quality ML pipelines with
sklearn.pipeline.Pipeline - Understand SVMs deeply (kernel trick, RBF kernel, support vectors)
- Random Forest: bagging, feature importance, out-of-bag error
- Hyperparameter tuning: GridSearchCV vs RandomizedSearchCV
- Proper cross-validation to avoid data leakage
SVM Theory
Linear SVM
Find the hyperplane $\mathbf{w}^T \mathbf{x} + b = 0$ that maximizes the margin:
$$\text{margin} = \frac{2}{|\mathbf{w}|}$$
Subject to: $y_i(\mathbf{w}^T \mathbf{x}_i + b) \geq 1 \quad \forall i$
This is a convex quadratic program — guaranteed global optimum. The dual form:
$$\max_\alpha \sum_i \alpha_i - \frac{1}{2}\sum_{i,j}\alpha_i \alpha_j y_i y_j \mathbf{x}_i^T \mathbf{x}_j$$
The prediction only depends on dot products $\mathbf{x}_i^T \mathbf{x}_j$ — this is the key insight for the kernel trick.
Kernel Trick
Replace the dot product with a kernel function $K(\mathbf{x}_i, \mathbf{x}_j)$ that implicitly computes a dot product in a high-dimensional feature space:
$$K_{\text{RBF}}(\mathbf{x}, \mathbf{z}) = \exp\left(-\frac{|\mathbf{x} - \mathbf{z}|^2}{2\sigma^2}\right)$$
The RBF kernel: $K(\mathbf{x}, \mathbf{z}) = e^{-\gamma |\mathbf{x}-\mathbf{z}|^2}$
- $\gamma$ large → narrow Gaussians → complex decision boundary (overfitting risk)
- $\gamma$ small → smooth decision boundary (underfitting risk)
- $C$ large → hard margin (penalize misclassification more)
- $C$ small → soft margin (allow more violations for better generalization)
Interview: Why is the kernel trick efficient?
The explicit feature map for RBF is infinite-dimensional — you can't compute it directly. The kernel function computes the dot product in that space in $O(d)$ time (where $d$ is input dimensionality), instead of computing the infinite vector and taking a dot product.
Random Forest Theory
Random Forests build $T$ decision trees, each trained on:
- A bootstrap sample (random sample with replacement) of the training set
- At each split: only $\sqrt{d}$ randomly selected features are considered
Out-of-bag (OOB) error: ~37% of samples are never selected in each bootstrap. These form a natural validation set per tree. Average OOB error across all trees is a free, unbiased estimate of test error.
Feature importance: For feature $j$, average the decrease in Gini impurity across all splits on $j$ across all trees. More reliable: permutation importance (shuffle feature $j$, measure performance drop).
Data Leakage (Critical Concept)
Wrong (leakage):
# Scaler sees test data — test statistics contaminate training
scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test) # Fine so far...
# But with cross-validation, this is WRONG:
scores = cross_val_score(svm, scaler.transform(X), y, cv=5)
# ^ scaler was fit on ALL of X including the held-out folds!
Correct (Pipeline prevents leakage):
from sklearn.pipeline import Pipeline
pipe = Pipeline([
('scaler', StandardScaler()), # fit on train fold only inside CV
('svm', SVC(kernel='rbf'))
])
scores = cross_val_score(pipe, X, y, cv=StratifiedKFold(5))
# Pipeline correctly fits scaler only on training fold each time
Interview Questions
Q: SVM vs Logistic Regression — when to use which?
A: SVMs are preferred when: (1) the dataset is small-medium with complex non-linear boundaries (use RBF kernel), (2) you need a maximum margin classifier, (3) high-dimensional sparse data (text, gene expression — linear SVM works well). Logistic Regression is preferred when: (1) you need calibrated probabilities, (2) very large datasets (SGD optimization scales better than SVM's quadratic), (3) interpretability matters (weights are directly interpretable), (4) online learning.
Q: How does cross-validation prevent overfitting compared to a single train/test split?
A: A single split has high variance — you might get "lucky" or "unlucky" with which samples end up in test. K-fold CV uses K different splits and averages the metric, reducing variance by ~1/K. Stratified K-fold ensures each fold has the same class proportions as the full dataset, which matters for imbalanced classes.
Q: Explain the bias-variance tradeoff in Random Forests.
A: Individual decision trees have high variance (they overfit to their training data) but low bias. Random Forest reduces variance through averaging (Var(mean of N) = Var(single)/N, assuming independence). The random feature selection at each split decorrelates the trees, making the independence assumption more valid. Bias stays roughly the same (averages of low-bias models are still low-bias). As T→∞, the generalization error converges to the expected error — you can't overfit by adding more trees.
Lab 02: Data Preprocessing & Augmentation for CV
What You'll Learn
- Albumentations for fast, GPU-ready image augmentation
- Handling class imbalance: oversampling, undersampling, class weights
- Focal Loss — designed specifically for class imbalance in detection
- Data pipeline best practices (avoid augmentation leakage)
Why Augmentation Matters
A model trained on 10,000 images will generalize far better if augmented to behave as if it saw 500,000 diverse examples. Augmentation is the single highest-ROI technique in CV.
Augmentation Categories
| Category | Operations | Preserves Label? |
|---|---|---|
| Geometric | flip, rotate, scale, crop, perspective | Usually yes |
| Photometric | brightness, contrast, hue, saturation | Always |
| Noise/Blur | Gaussian noise, motion blur, JPEG artifacts | Always |
| Regularization | Cutout, CutMix, MixUp | Requires label mixing |
| Domain-specific | Elastic deformation (medical), rain/fog (driving) | Yes |
Critical rule: Apply augmentation only to training data. Validation and test sets should use only normalization (and possibly center crop for classification).
Albumentations
Albumentations is the de facto standard for CV augmentation. It's 3-10× faster than torchvision transforms because it operates on NumPy arrays and is optimized with OpenCV + Cython.
import albumentations as A
from albumentations.pytorch import ToTensorV2
train_transform = A.Compose([
A.RandomResizedCrop(height=224, width=224, scale=(0.8, 1.0)),
A.HorizontalFlip(p=0.5),
A.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1, p=0.8),
A.GaussNoise(var_limit=(10, 50), p=0.3),
A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=15, p=0.5),
A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
ToTensorV2(),
])
For object detection, pass bounding boxes through the transform:
transform = A.Compose([
A.HorizontalFlip(p=0.5),
A.RandomBrightnessContrast(p=0.3),
], bbox_params=A.BboxParams(format='pascal_voc', label_fields=['class_labels']))
transformed = transform(image=image, bboxes=bboxes, class_labels=labels)
Class Imbalance
The most common problem in real-world CV datasets: 95% negative (background), 5% positive (defect/person).
Strategy Comparison
| Method | How | Pro | Con |
|---|---|---|---|
| Class weights | weight[c] = N / (K × N_c) | No data needed | Doesn't help with hard negatives |
| Oversampling (SMOTE) | Synthesize minority examples | Fixes marginal distribution | Doesn't apply to images |
| Undersampling | Randomly drop majority | Fast | Loses information |
| Focal Loss | Down-weight easy negatives | Best for detection | Needs tuning of γ |
| Data collection | More minority examples | Correct fix | Expensive |
Focal Loss Derivation
Standard cross-entropy loss: $CE(p_t) = -\log(p_t)$
For a well-classified example ($p_t = 0.9$): $CE = -\log(0.9) = 0.105$
In a dataset with 99% negatives and batch_size=256:
- ~253 easy negatives contribute loss ≈ 0.105 each
- ~3 positives contribute loss ≈ 2.3 each (hard case)
- Total loss dominated by easy negatives → gradients don't learn from hard cases
Focal Loss (Lin et al., 2017, RetinaNet paper):
$$FL(p_t) = -\alpha_t(1-p_t)^\gamma \log(p_t)$$
- $(1-p_t)^\gamma$: modulating factor — if $p_t=0.9$ (easy), $(1-0.9)^2 = 0.01$ → loss reduced 100×
- If $p_t=0.1$ (hard), $(1-0.1)^2 = 0.81$ → loss barely reduced
- $\gamma=2$ is the sweet spot (proven by RetinaNet paper)
- $\alpha_t$: class balancing weight (typically 0.25 for positives)
Interview Questions
Q: What's the difference between augmentation during training vs test-time augmentation (TTA)?
A: During training, augmentation artificially increases dataset diversity to reduce overfitting. TTA applies augmentation at inference: make N augmented versions of the test image, run inference on all N, and average/ensemble the predictions. TTA typically gives 1-3% accuracy improvement with no training cost. Common TTA: horizontal flip, 5-crop (4 corners + center). The tradeoff: N× inference cost.
Q: Your detection model has 98% background, 2% objects. Training loss is 0.05 after 1 epoch but recall is 0. Why?
A: The model learned to predict everything as background — this achieves 98% accuracy but 0% recall. The cross-entropy loss is dominated by easy negatives. Solutions: (1) Focal loss with γ=2, α=0.25, (2) Hard negative mining (only backprop the top-k hardest negative examples), (3) Class-balanced sampling (ensure each batch has 50% positive examples), (4) Use class-weighted loss. In practice, modern detectors (YOLO, RetinaNet) all use focal loss or anchor-based balancing for this reason.
Lab 03 — Model Evaluation Metrics
Phase 2: ML Fundamentals | Week 5-6
Building a model is easy. Knowing if it actually works — and where it fails — is the job. Master these metrics and you'll catch problems that loss curves will never show you.
Learning Objectives
- Implement confusion matrix, Precision, Recall, F1 from scratch
- Build ROC curves and understand AUC interpretation
- Compute IoU and mAP (COCO 101-point interpolation) from scratch
- Know when to use each metric and how to defend your choices in interviews
Theory
Classification Metrics
Given a confusion matrix for class $c$:
| Predicted Positive | Predicted Negative | |
|---|---|---|
| Actually Positive | TP | FN |
| Actually Negative | FP | TN |
$$\text{Precision} = \frac{TP}{TP + FP} \quad \text{Recall} = \frac{TP}{TP + FN}$$
$$F_1 = \frac{2 \cdot P \cdot R}{P + R} \quad F_\beta = \frac{(1+\beta^2) \cdot P \cdot R}{\beta^2 \cdot P + R}$$
- $\beta > 1$: weight recall more (e.g., cancer detection — missing a case is costly)
- $\beta < 1$: weight precision more (e.g., spam filter — false positives destroy trust)
ROC Curve & AUC
Sweep threshold $t$ from 1 → 0, compute TPR and FPR at each:
$$TPR = \frac{TP}{TP + FN} \quad FPR = \frac{FP}{FP + TN}$$
- AUC = 1.0: perfect separation
- AUC = 0.5: random classifier
- AUC < 0.5: worse than random (check label encoding!)
When to use ROC vs PR curve: Use PR curve when classes are heavily imbalanced. ROC can look optimistic on imbalanced data because TN is large, keeping FPR small.
IoU (Intersection over Union)
$$\text{IoU}(A, B) = \frac{|A \cap B|}{|A \cup B|} = \frac{|A \cap B|}{|A| + |B| - |A \cap B|}$$
For boxes $A = [x_1^A, y_1^A, x_2^A, y_2^A]$:
$$x_1^I = \max(x_1^A, x_1^B), \quad x_2^I = \min(x_2^A, x_2^B)$$ $$\text{inter} = \max(0, x_2^I - x_1^I) \cdot \max(0, y_2^I - y_1^I)$$
mAP — Mean Average Precision
For each class $c$:
- Sort all detections by confidence score (descending)
- For each detection: TP if IoU with a GT box ≥ threshold, else FP
- Compute precision/recall curve
- Compute AP using 101-point COCO interpolation:
$$AP = \frac{1}{101} \sum_{r \in {0, 0.01, ..., 1.0}} \max_{\tilde{r} \geq r} P(\tilde{r})$$
$$\text{mAP} = \frac{1}{C} \sum_{c=1}^C AP_c$$
mAP@0.5: threshold = 0.5. Classic VOC metric.
mAP@0.5:0.95: average over IoU thresholds [0.5, 0.55, ..., 0.95]. Stricter COCO metric.
What the Lab Covers
| Function | Concept | Interview Frequency |
|---|---|---|
confusion_matrix() | From-scratch implementation | ★★★★★ |
precision_recall_f1() | Macro/micro averaging | ★★★★★ |
roc_auc_from_scratch() | Threshold sweep | ★★★★ |
iou() | Vectorized box IoU | ★★★★★ |
compute_ap() | 101-point interpolation | ★★★★★ |
map_by_class() | Full mAP computation | ★★★★ |
calibration_curve() | Reliability diagram | ★★★ |
Pandas in Practice
import pandas as pd
# Typical evaluation workflow with pandas
results_df = pd.DataFrame({
'image_id': ids,
'class': class_names,
'confidence': scores,
'tp': tp_flags,
'fp': fp_flags,
})
# Per-class breakdown
per_class = results_df.groupby('class').agg(
precision=('tp', lambda x: x.sum() / len(x)),
recall=('tp', 'mean'),
n_detections=('tp', 'count'),
)
print(per_class)
Interview Questions
Q: Your model has 99% accuracy on a medical dataset. Is it good? A: Probably not. If 99% of samples are negative (healthy), a model that always predicts negative achieves 99% accuracy. Use recall (sensitivity) and precision, or AUC-PR.
Q: Explain the precision-recall tradeoff. A: Lowering the confidence threshold increases recall (fewer FN) but decreases precision (more FP). The tradeoff is governed by the score distribution overlap between positives and negatives.
Q: mAP@0.5:0.95 vs mAP@0.5 — which should you optimize? A: mAP@0.5:0.95 is the primary COCO metric and is harder — it requires tight localization. mAP@0.5 is the VOC metric. For production, mAP@0.5 is often more practically meaningful. Always report both.
Q: How do you handle class imbalance in multi-class classification? A: (1) Use macro-averaged F1 (treats all classes equally). (2) Use weighted loss (inverse frequency or focal loss). (3) Oversample rare classes (SMOTE for tabular, copy-paste augmentation for detection).
Run
pip install -r requirements.txt
python solution.py
# Outputs saved to outputs/
Lab 04 — Pandas & Scikit-Learn Deep Dive
Phase 2: ML Fundamentals | Week 6
Pandas and sklearn are the invisible backbone of every CV production system. Data cleaning, feature pipelines, hyperparameter search, and experiment tracking all run through these libraries. You will be tested on them in every ML interview.
Learning Objectives
- Master pandas for annotation management, EDA, and experiment tracking
- Build sklearn
Pipeline+ColumnTransformerfor reproducible feature engineering - Implement cross-validation strategies for imbalanced datasets
- Run
GridSearchCV/RandomizedSearchCVand analyze results - Build a full annotation analysis workflow from raw CSV to insights
Part 1: Pandas for CV Data Science
Reading & Exploring Annotation Files
import pandas as pd
df = pd.read_csv("annotations.csv")
# Shape, types, nulls
print(df.shape)
print(df.dtypes)
print(df.isnull().sum())
print(df.describe()) # stats for numeric columns
print(df["class"].value_counts())
# Column selection
bbox_cols = ["xmin", "ymin", "xmax", "ymax"]
boxes = df[bbox_cols] # DataFrame
classes = df["class"] # Series
Feature Engineering on Annotations
# Derived bbox features — all in one assign call (chainable)
df = df.assign(
width = df["xmax"] - df["xmin"],
height = df["ymax"] - df["ymin"],
area = lambda d: d["width"] * d["height"],
aspect_ratio = lambda d: d["width"] / d["height"].clip(lower=1e-6),
cx = lambda d: (d["xmin"] + d["xmax"]) / 2,
cy = lambda d: (d["ymin"] + d["ymax"]) / 2,
normalized_area = lambda d: d["area"] / (d["img_w"] * d["img_h"]),
)
GroupBy — The Workhorse Operation
# Per-image statistics
per_image = df.groupby("image_id").agg(
n_objects = ("class", "count"),
n_classes = ("class", "nunique"),
mean_area = ("area", "mean"),
classes_list = ("class", list),
).reset_index()
# Per-class statistics
per_class = df.groupby("class").agg(
count = ("image_id", "count"),
mean_area = ("area", "mean"),
median_conf = ("confidence", "median"),
images = ("image_id", "nunique"),
).sort_values("count", ascending=False)
# Pivot: class × image_id — useful for co-occurrence analysis
pivot = df.pivot_table(
index="image_id", columns="class",
values="confidence", aggfunc="max", fill_value=0
)
Joining Predictions with Ground Truth
preds = pd.read_csv("predictions.csv") # image_id, class, confidence, bbox...
gt = pd.read_csv("ground_truth.csv") # image_id, class, bbox...
# Merge on image_id to align per-image
merged = pd.merge(preds, gt, on="image_id", suffixes=("_pred", "_gt"))
# Find missed classes (FN at class level)
pred_classes = set(preds["class"].unique())
gt_classes = set(gt["class"].unique())
missed = gt_classes - pred_classes
print(f"Classes never predicted: {missed}")
# Error analysis: highest-area false positives
fp_df = preds[(preds["iou_with_gt"] < 0.5) & (preds["confidence"] > 0.7)]
fp_df.nlargest(20, "area")
Cleaning & Validation
# Remove out-of-bounds boxes
df = df[
(df["xmin"] >= 0) & (df["ymin"] >= 0) &
(df["xmax"] <= df["img_w"]) & (df["ymax"] <= df["img_h"]) &
(df["xmin"] < df["xmax"]) & (df["ymin"] < df["ymax"])
]
# Remove tiny boxes (likely annotation noise)
df = df[df["area"] > 100]
# Handle missing confidence scores
df["confidence"] = df["confidence"].fillna(1.0) # GT has no confidence → 1
# Deduplicate (exact duplicate rows)
df = df.drop_duplicates()
apply / transform for Custom Logic
# apply: returns one value per group
iou_stats = df.groupby("class")["iou"].apply(
lambda x: pd.Series({
"ap50": (x > 0.5).mean(),
"ap75": (x > 0.75).mean(),
})
)
# transform: returns same-length Series (useful for adding group stats to rows)
df["class_mean_area"] = df.groupby("class")["area"].transform("mean")
df["area_vs_class_mean"] = df["area"] / df["class_mean_area"]
Part 2: Scikit-Learn Pipelines
Why Pipelines?
A Pipeline chains preprocessing + model into one object. Benefits:
- Prevents data leakage (fit scaler ONLY on train, applies to test)
- One
fit()/predict()call - Fully compatible with
GridSearchCV
raw CSV
│
▼
ColumnTransformer
├── numeric: [impute → scale]
└── categorical: [impute → one-hot]
│
▼
Classifier / Regressor
Building a Pipeline
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
numeric_features = ["area", "aspect_ratio", "cx", "cy", "width", "height"]
categoric_features = ["dataset_split", "scene_type"]
numeric_transformer = Pipeline([
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
])
categorical_transformer = Pipeline([
("imputer", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
])
preprocessor = ColumnTransformer([
("num", numeric_transformer, numeric_features),
("cat", categorical_transformer, categoric_features),
])
pipeline = Pipeline([
("preprocessor", preprocessor),
("classifier", RandomForestClassifier(n_estimators=100, random_state=42)),
])
pipeline.fit(X_train, y_train)
preds = pipeline.predict(X_test)
Custom Sklearn Transformer
from sklearn.base import BaseEstimator, TransformerMixin
class BBoxFeatureExtractor(BaseEstimator, TransformerMixin):
"""Extracts geometric features from raw bounding box columns."""
def __init__(self, img_w=1920, img_h=1080):
self.img_w = img_w
self.img_h = img_h
def fit(self, X, y=None):
return self # stateless
def transform(self, X):
df = pd.DataFrame(X, columns=["xmin", "ymin", "xmax", "ymax"])
w = df["xmax"] - df["xmin"]
h = df["ymax"] - df["ymin"]
return pd.DataFrame({
"area": w * h,
"aspect_ratio": w / h.clip(lower=1e-6),
"cx": (df["xmin"] + df["xmax"]) / 2 / self.img_w,
"cy": (df["ymin"] + df["ymax"]) / 2 / self.img_h,
"normalized_area": (w * h) / (self.img_w * self.img_h),
}).to_numpy()
Cross-Validation Strategies
from sklearn.model_selection import (
StratifiedKFold, GroupKFold, StratifiedGroupKFold, cross_val_score
)
# Standard: stratified to preserve class proportions
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(pipeline, X, y, cv=skf, scoring="f1_macro")
# Group: no image appears in both train and val (critical for CV — prevents leakage!)
gkf = GroupKFold(n_splits=5)
groups = df["image_id"].to_numpy() # each bbox belongs to an image
scores = cross_val_score(pipeline, X, y, cv=gkf, groups=groups, scoring="f1_macro")
# StratifiedGroupKFold: both stratified + group-aware
sgkf = StratifiedGroupKFold(n_splits=5)
scores = cross_val_score(pipeline, X, y, cv=sgkf, groups=groups, scoring="f1_macro")
GridSearchCV / RandomizedSearchCV
from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import randint, uniform
param_dist = {
"classifier__n_estimators": randint(50, 300),
"classifier__max_depth": [None, 5, 10, 20],
"classifier__min_samples_leaf": randint(1, 20),
"preprocessor__num__imputer__strategy": ["mean", "median"],
}
search = RandomizedSearchCV(
pipeline, param_dist,
n_iter=30, cv=5, scoring="f1_macro",
n_jobs=-1, random_state=42, verbose=1,
)
search.fit(X_train, y_train)
# Results as DataFrame for analysis
results_df = pd.DataFrame(search.cv_results_)
results_df.sort_values("mean_test_score", ascending=False).head(10)
Feature Importance + SHAP
# Get feature names after pipeline transforms
feature_names = (
numeric_features
+ pipeline["preprocessor"].transformers_[1][1]["onehot"]
.get_feature_names_out(categoric_features).tolist()
)
importances = pipeline["classifier"].feature_importances_
feat_df = (
pd.DataFrame({"feature": feature_names, "importance": importances})
.sort_values("importance", ascending=False)
.head(15)
)
Interview Questions
Q: What is data leakage? Give a concrete example in a CV context.
A: Data leakage is when information from the test set influences training. Example: if you fit a StandardScaler on the full dataset and then split, the scaler's mean/std were computed with test data — test distribution influenced the preprocessing. Fix: always fit preprocessing ONLY on training data. sklearn Pipeline prevents this automatically.
Q: Why use GroupKFold instead of StratifiedKFold for object detection datasets?
A: Object detection datasets have multiple bounding boxes per image. If the same image appears in both train and val folds, the model has effectively "seen" those images during training (because features extracted from the same image are highly correlated). GroupKFold groups by image_id, ensuring all boxes from one image are in the same fold.
Q: Write a pandas operation to find the top-5 most confused class pairs from a prediction DataFrame.
confused = (
df[df["pred_class"] != df["true_class"]]
.groupby(["true_class", "pred_class"])
.size()
.sort_values(ascending=False)
.head(5)
.reset_index(name="count")
)
Q: A Pipeline has steps [('scaler', StandardScaler()), ('model', SVC())]. How do you access the scaler's mean_ after fitting?
pipeline.fit(X_train, y_train)
means = pipeline.named_steps["scaler"].mean_
# or
means = pipeline[0].mean_
Q: What is the difference between fit_transform() and transform() in sklearn?
A: fit_transform(X) is equivalent to fit(X).transform(X) — it learns parameters from X AND applies the transformation. transform(X) only applies previously learned parameters. Never call fit_transform on test data — always transform only.
Q: How does ColumnTransformer handle columns not listed in any transformer?
A: By default, unlisted columns are dropped (remainder='drop'). Set remainder='passthrough' to keep them as-is. You can also set remainder=SomeTransformer() to apply a specific transformation.
Run
pip install -r requirements.txt
python solution.py
# Outputs saved to outputs/
Phase 3 — PyTorch Deep Learning
Weeks: 7–9 | Goal: Master PyTorch from tensors to distributed training; GPU/CUDA proficiency
Labs
| Lab | Topic | Key Skills |
|---|---|---|
| lab-01-pytorch-tensors-autograd | Tensors, autograd, custom backward | CUDA, mixed precision |
| lab-02-training-loop | DataLoader, training loop, optimizers | AMP, gradient accumulation |
| lab-03-cnn-from-scratch | Build ResNet-like CNN | BatchNorm, skip connections |
| lab-04-transfer-learning | Fine-tune pretrained models | Feature extraction vs fine-tuning |
| lab-05-distributed-training | DDP, gradient accumulation | Multi-GPU scaling strategies |
GPU/CUDA Fundamentals
This phase covers:
- CUDA device management (
torch.device,.cuda(),.to(device)) - Mixed precision training (
torch.cuda.amp.autocast,GradScaler) - Memory management (
torch.cuda.empty_cache(),torch.no_grad()) - Profiling (
torch.profiler,nvidia-smi) - DataParallel vs DistributedDataParallel (DDP)
Why PyTorch for CV Engineers
"If you can't implement it in PyTorch, you don't understand it."
Every SOTA CV model (YOLO, SAM, CLIP, ViT) ships in PyTorch. Debugging gradient issues, optimizing training throughput, and serving with TorchScript requires deep PyTorch fluency — not just calling .fit().
Warmup Guide — PyTorch
Zero-to-expert primer for Phase 03: tensors and autograd, the training loop and its failure modes, CNNs through ResNet, transfer learning's regimes, and a working mental model of distributed training.
Table of Contents
- Chapter 1: Tensors — NumPy with Two Superpowers
- Chapter 2: Autograd — The Tape Behind the Curtain
- Chapter 3: Modules, Losses, Optimizers — The Object Model
- Chapter 4: The Training Loop and Its Bug Bestiary
- Chapter 5: Data Loading — The Underrated Bottleneck
- Chapter 6: Convolutions to ResNet
- Chapter 7: Transfer Learning — The Default Strategy
- Chapter 8: Distributed Training in One Chapter
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Tensors — NumPy with Two Superpowers
A torch.Tensor is Phase 00's ndarray (same buffer/shape/strides/dtype model, same
broadcasting rule, same views-vs-copies semantics) plus: device placement
(.to("cuda") — and the rule that all operands of an op must share a device; the
Expected all tensors to be on the same device error is your introduction) and
gradient tracking (Ch. 2).
The transfer costs that shape code structure: host↔device copies are expensive and
asynchronous-able (pin_memory + non_blocking=True in loaders); .item()/.cpu()
synchronize — calling them per step in the loop serializes the GPU (the bug that
the model-accuracy track's profiling phase hunts with traces; here, just don't write
it). Image layout is (N, C, H, W) in PyTorch — the transpose from OpenCV/NumPy's
HWC is a permanent border-crossing ritual (permute(2, 0, 1)), and contiguous()
after permute when an op demands it.
Chapter 2: Autograd — The Tape Behind the Curtain
Every operation on a requires_grad tensor records a node in a dynamic computation
graph; loss.backward() walks it in reverse applying the chain rule (Phase 00 Ch. 5,
automated). The working rules:
- Gradients accumulate into
.grad— henceoptimizer.zero_grad()every step (forgetting it = silently summed gradients = effective LR drift; bug bestiary #1). - The graph is rebuilt every forward (define-by-run) — Python control flow just
works; the price is per-op overhead (what
torch.compilelater removes — the model-accuracy track's Phase 04 is the deep dive; here, know it exists). with torch.no_grad():for inference/eval — skips graph construction (memory and speed);.detach()cuts a tensor out of the graph (the stop-gradient tool — you saw its load-bearing uses in DINO and DPO if you've toured other tracks).- Leaf vs non-leaf: only leaves (parameters, inputs you created) get
.gradby default; intermediate gradients needretain_grad()— relevant when debugging. - Verify understanding once: implement a custom
torch.autograd.Functionwith explicit forward/backward and gradcheck it (the lab does) — after that, autograd is bookkeeping you trust.
Chapter 3: Modules, Losses, Optimizers — The Object Model
nn.Module: parameters (registered automatically by attribute assignment) +forward(). Composition is nesting;model.parameters()walks the tree — understanding registration explains the classic silent bug (modules stored in a plain Python list don't register — usenn.ModuleList).train()vseval()mode: flips Dropout and BatchNorm behavior. Forgettingmodel.eval()at validation (ortrain()after) is bug bestiary #2 — symptoms: noisy val metrics or frozen-stats training.- Losses:
CrossEntropyLosstakes logits, not probabilities (it fuses log-softmax + NLL for numerical stability — bestiary #3 is applying softmax first); class weights live here for imbalance. - Optimizers: SGD+momentum (the CNN classic — pairs with stepped/cosine LR decay) and AdamW (the adaptive default; the LLM track's Phase 05 derives it fully). LR schedulers step per epoch or per iteration — know which yours expects. The LR is the single most important hyperparameter; everything else is second-order.
Chapter 4: The Training Loop and Its Bug Bestiary
The canonical loop you'll write hundreds of times — and its known failure modes, each of which the lab makes you commit deliberately:
for x, y in loader:
x, y = x.to(dev, non_blocking=True), y.to(dev, non_blocking=True)
optimizer.zero_grad()
loss = criterion(model(x), y)
loss.backward()
optimizer.step()
| Bug | Symptom |
|---|---|
missing zero_grad | loss decreases then oscillates; effective LR wrong |
missing model.eval() / no_grad at val | val noisy, OOM at eval, slow eval |
| softmax before CrossEntropyLoss | trains, but worse — doubly-squashed gradients |
accumulating loss (not loss.item()) into a list | memory leak — the whole graph retained per step |
| labels/logits shape or dtype mismatch | sometimes an error; sometimes silent broadcasting garbage (Phase 00 Ch. 3's warning, realized) |
| transforms with stale normalization stats | accuracy mysteriously capped |
And the two pre-flight rituals from the shared curriculum discipline: overfit one batch to ~zero loss before any long run (wiring test), and fixed-seed determinism so changes are attributable.
Chapter 5: Data Loading — The Underrated Bottleneck
Dataset (__getitem__/__len__ — Phase 00's dunder protocol, productized) +
DataLoader (batching, shuffling, parallel workers). The performance model:
workers decode/augment on CPU while the GPU trains — if the GPU waits (low
utilization, jagged step times), raise num_workers, use pin_memory=True, move
heavy augmentation to GPU (or pre-resize the dataset on disk — JPEG decode of
full-resolution photos is the classic hidden cost). Augmentation policy lives here
too: train-time randomness (flips, crops, color jitter) is regularization;
val/test transforms are deterministic (resize + center-crop + normalize) — train
transforms on val data is bestiary #7. Normalization uses the pretraining dataset's
stats when fine-tuning (ImageNet means/stds) — a contract with the checkpoint, same
species as the LLM track's chat-template contract.
Chapter 6: Convolutions to ResNet
- Convolution layers are Phase 01 Ch. 3 with learned kernels: local connectivity + weight sharing = translation equivariance and parameter economy (a 3×3×C_in×C_out kernel vs a dense layer over pixels). Output size arithmetic ($\lfloor (n + 2p - k)/s \rfloor + 1$) and receptive field growth with depth are the two calculations to be fluent in.
- BatchNorm: normalize each channel over the batch, then learnable scale/shift — stabilizes and accelerates CNN training; its batch-statistics dependence is also its curse (small batches → noisy stats; train/eval mode split; the running-stats machinery). Conv-BN-ReLU is the CNN unit (and BN folds into conv at inference — the model-accuracy track's FX lab does it mechanically).
- The ResNet insight: deeper plain nets trained worse (not just overfit — higher training error: a degradation problem). Residual blocks ($x + F(x)$) make identity the default and let layers learn corrections — gradient highways (the same idea you've now met as LSTM cell states and transformer residuals; deep learning has one good trick and it keeps wearing new clothes). Bottleneck blocks (1×1 reduce → 3×3 → 1×1 expand) buy depth cheaply; stride-2 stages halve resolution while doubling channels.
- Building ResNet-18 from scratch (the lab) and matching torchvision's parameter count exactly is the rite of passage — the count check catches every wiring mistake.
Chapter 7: Transfer Learning — The Default Strategy
Training from scratch is the exception; the default is starting from ImageNet-pretrained (or CLIP-class) weights:
- Why it works: early conv layers learn universal features (edges, textures — Gabor-like filters emerge regardless of task); only later layers are task-specific. Pretraining is, in effect, a better initialization plus a feature library.
- The regimes, by data size and domain gap:
- Linear probe (freeze all, train a new head): tiny data, similar domain.
- Partial fine-tune (unfreeze last block(s) + head): the workhorse middle.
- Full fine-tune at low LR (often with discriminative LRs — smaller for early layers): enough data, or large domain gap.
- Larger domain gap (medical, satellite, industrial) shifts you toward later regimes — early features transfer less.
- The mechanics that matter: replace the classifier head to your class count; match the pretraining normalization stats; consider BN-stats behavior when freezing (frozen-but-train-mode BN is a subtle gotcha); start with 10–100× lower LR than scratch training. The lab runs regimes 1–3 on the same dataset — the comparison table is the deliverable.
Chapter 8: Distributed Training in One Chapter
The working mental model (the LLM track's Phase 10 carries the full depth):
- DDP (the 90% case): N processes, each a GPU, each a model replica and a data
shard; gradients all-reduced (averaged) each step so replicas stay identical.
Launch via
torchrun; wrap withDistributedDataParallel; useDistributedSampler(andset_epochfor reshuffling — the classic omission). Effective batch = per-GPU batch × N: adjust LR accordingly (linear-scaling heuristic + warmup). - The practical rules: rank-0-only logging/checkpointing;
barrier()around shared filesystem moments; seeds offset per rank for augmentation diversity; and measurement before scaling — a dataloader-bound job scales GPUs into idleness (Ch. 5 first!). - Beyond DDP — when the model doesn't fit — lives sharding (FSDP/ZeRO) and tensor/pipeline parallelism: pointers in the LLM track's Phase 10 WARMUP; for CV-scale models, DDP is almost always the whole story.
Lab Walkthrough Guidance
Order: labs 01→05 as numbered.
- Lab 01 (tensors/autograd): port a Phase 00 NumPy exercise to tensors; build and
gradcheck a custom autograd Function; demonstrate the
.gradaccumulation behavior explicitly. - Lab 02 (training loop): write the loop bare (no Lightning-style wrappers); commit each bestiary bug deliberately and document its symptom; finish with the overfit-one-batch ritual and a clean CIFAR-10 run with curves.
- Lab 03 (ResNet from scratch): build ResNet-18; assert parameter-count parity with torchvision; train on CIFAR-10 (with the stem adaptation for 32×32) and compare against your plain-CNN baseline — the residual delta, personally measured.
- Lab 04 (transfer learning): the three regimes on a small fine-grained dataset; the comparison table + learning curves; note where each regime's curve says bias vs variance (Phase 02's lens, reused).
- Lab 05 (distributed): convert lab 02's script to DDP via torchrun on 2 processes (GPU or even CPU-Gloo to learn the mechanics); verify same-loss-as-single-process at matched effective batch; measure scaling efficiency and name what limits it.
Success Criteria
You are ready for Phase 04 when you can, from memory:
- Explain device semantics, the
.item()sync trap, and NCHW vs HWC. - Describe graph construction/backward, why
zero_grad, and whatdetachdoes. - Recite five bestiary bugs with symptoms.
- Do conv output-size and receptive-field arithmetic; explain BN's train/eval split.
- Tell the ResNet degradation story and connect residuals to the other gradient highways you know.
- Choose a transfer regime from data size × domain gap and list the three mechanical musts.
- Sketch DDP's replicate-shard-allreduce loop and the LR/batch adjustment.
Interview Q&A
Q: Training loss decreases but validation accuracy is stuck at chance. What's your
checklist?
Wiring first: labels shuffled relative to inputs in the val loader (or a transform
mismatch — train augmentations applied to val); model.eval() missing (BN/Dropout
chaos); metric computed on logits vs probabilities with a wrong argmax axis;
normalization stats mismatched between train and val. The tell is chance-level —
that's a broken pipe, not underfitting; bisect by evaluating on the training set
(if that's also chance under the val code path, the eval code is the bug).
Q: Why does CrossEntropyLoss take logits instead of probabilities?
Numerical stability: it computes log-softmax internally via the log-sum-exp trick;
feeding probabilities means an external softmax → log of small numbers → precision
loss, and gradients through two softmaxes are doubly squashed. It also fuses two
kernels. The deeper point: API contracts encode numerics — same reason
binary_cross_entropy_with_logits exists and is preferred.
Q: You move from 1 GPU to 4 with DDP and accuracy drops slightly. Why might that be
correct behavior?
Effective batch ×4 changes the optimization trajectory: fewer steps per epoch, less
gradient noise (which regularizes), and the LR heuristic (linear scaling) is an
approximation — some recipes need warmup retuning. Also check DistributedSampler
without set_epoch (same shard order every epoch) and per-rank seed handling. The
point being probed: DDP is mathematically not identical to the single-GPU run at
the same nominal hyperparameters — it's identical to a 4×-batch run.
References
- PyTorch tutorials: 60-minute blitz + autograd mechanics notes
- He et al., Deep Residual Learning for Image Recognition (2015) — arXiv:1512.03385
- Ioffe & Szegedy, Batch Normalization (2015) — arXiv:1502.03167
- Goyal et al., Accurate, Large Minibatch SGD (2017) — arXiv:1706.02677 — linear scaling + warmup
- Yosinski et al., How transferable are features in deep neural networks? (2014) — arXiv:1411.1792
- PyTorch DDP design notes
- Karpathy, A Recipe for Training Neural Networks — the bug-bestiary philosophy, canonically
- The model-accuracy track's Phase 01 WARMUP — autograd/dispatcher internals when you want the next level down
Lab 3-01: PyTorch Tensors & Autograd
Learning Goals
- Master tensor operations and their CUDA equivalents
- Understand PyTorch's dynamic computation graph
- Use
autogradto compute gradients manually - Avoid common pitfalls: in-place ops, detach, no_grad
- Profile GPU memory usage with
torch.cuda.memory_summary
Core Concepts
Tensors
import torch
# Creation
x = torch.tensor([1.0, 2.0, 3.0]) # from Python list
x = torch.zeros(3, 4) # zeros
x = torch.randn(2, 3, requires_grad=True) # Gaussian random, tracks gradients
x = torch.arange(12).reshape(3, 4).float()
# Device placement
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
x = x.to(device)
# or
x = x.cuda() # GPU
x = x.cpu() # back to CPU
Computation Graph
PyTorch builds a directed acyclic graph (DAG) dynamically as ops execute.
Each tensor with requires_grad=True records its creation operation.
x = torch.tensor(2.0, requires_grad=True)
y = x ** 2 + 3 * x + 1 # y = x²+ 3x + 1
y.backward() # dy/dx = 2x + 3
print(x.grad) # tensor(7.) = 2*2 + 3
Gradient Tape (manual backward)
x = torch.randn(3, requires_grad=True)
W = torch.randn(4, 3, requires_grad=True)
b = torch.zeros(4, requires_grad=True)
# Forward pass
z = W @ x + b
loss = z.pow(2).sum()
# Backward pass — PyTorch computes all gradients
loss.backward()
print(W.grad) # dL/dW, shape (4, 3)
print(x.grad) # dL/dx, shape (3,)
torch.no_grad() vs detach()
# no_grad: disable gradient tracking for inference (saves memory, faster)
with torch.no_grad():
pred = model(x) # no gradient computation
# detach: break the graph — use when you want the value without gradient
y = x.detach().numpy() # convert to numpy
# grad_fn shows you what op created the tensor
x = torch.randn(3, requires_grad=True)
y = x.sin()
print(y.grad_fn) # <SinBackward0 object>
In-Place Operations — Common Pitfall
x = torch.randn(3, requires_grad=True)
# BAD: in-place modifies the tensor autograd needs for backward
x += 1 # RuntimeError: a leaf Variable that requires grad has been used in an in-place operation
# GOOD: create a new tensor
x = x + 1
CUDA Memory Management
# Check memory
print(torch.cuda.memory_allocated() / 1e6, "MB allocated")
print(torch.cuda.max_memory_allocated() / 1e6, "MB peak")
# Free cache
torch.cuda.empty_cache()
# Memory profiling context
with torch.profiler.profile(
activities=[torch.profiler.ProfilerActivity.CUDA],
profile_memory=True,
) as prof:
y = model(x)
print(prof.key_averages().table(sort_by="cuda_memory_usage", row_limit=10))
Interview Questions
Q: What is the computation graph in PyTorch? How does it differ from TensorFlow 1.x?
A: PyTorch uses a dynamic (define-by-run) computation graph built at runtime during the forward pass. TF1 used a static graph defined before execution. Dynamic graphs enable Python control flow (if/else, loops) in model forward passes and easier debugging.
Q: What does .detach() do?
A: It returns a new tensor with the same data but without gradient tracking. Use it to: (1) convert to numpy; (2) prevent gradients flowing into part of the graph (e.g., frozen encoder); (3) implement stop-gradient operations.
Q: Why does zero_grad() need to be called before backward()?
A: PyTorch accumulates gradients by default. Without zero_grad(), each backward pass adds to existing gradients. This is useful for gradient accumulation (simulating larger batch sizes), but must be reset at the start of each update step.
Q: What's the difference between model.eval() and torch.no_grad()?
A: model.eval() changes the behavior of layers like BatchNorm (use running stats instead of batch stats) and Dropout (disable it). torch.no_grad() disables gradient computation to save memory and time. For inference, you typically want both.
Lab 02 — Training Loop Best Practices
Phase 3: PyTorch | Week 7-8
A good training loop is the difference between a model that diverges and one that trains reliably. These patterns appear in every production codebase.
Learning Objectives
- Build a production-grade training loop with validation
- Implement early stopping, gradient clipping, and checkpointing
- Compare LR schedulers: StepLR, CosineAnnealingLR, OneCycleLR
- Use Automatic Mixed Precision (AMP) correctly on GPU
- Debug training instability with gradient norm monitoring
Theory
The Complete Training Loop
for epoch in range(n_epochs):
model.train()
for batch in train_loader:
optimizer.zero_grad(set_to_none=True) # slightly faster than zero
with autocast(device_type='cuda'): # AMP
loss = criterion(model(x), y)
scaler.scale(loss).backward()
scaler.unscale_(optimizer)
clip_grad_norm_(model.parameters(), max_norm=1.0)
scaler.step(optimizer)
scaler.update()
scheduler.step()
model.eval()
with torch.no_grad():
val_loss = evaluate(model, val_loader)
Automatic Mixed Precision (AMP)
FP32: 32-bit floats — full precision, more memory.
FP16: 16-bit floats — 2× smaller, Tensor Core acceleration (16× faster on A100).
Loss scaling: FP16 has small dynamic range (~$10^{-4}$ to $10^4$). Gradients can underflow to 0. Scale loss by large factor $S$, then divide gradients by $S$ before update.
PyTorch GradScaler handles this automatically. Dynamic scaling: halves $S$ on overflow, doubles $S$ every 2000 steps.
BF16: Brain Float 16 — same exponent range as FP32 but fewer mantissa bits. No loss scaling needed. Preferred on A100/H100.
Gradient Clipping
Prevents exploding gradients (common in RNNs, deep networks):
$$\text{if} |\nabla| > \text{max_norm}: \quad \nabla \leftarrow \nabla \cdot \frac{\text{max_norm}}{|\nabla|}$$
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
Always clip after scaler.unscale_() and before scaler.step().
Learning Rate Schedulers
| Scheduler | Behavior | Best For |
|---|---|---|
| StepLR | Decay by $\gamma$ every $k$ epochs | Simple baselines |
| CosineAnnealingLR | Cosine decay to $\eta_{min}$ | ResNets, most CNNs |
| OneCycleLR | Warmup → peak → cosine decay (1 cycle) | Fast training (less epochs) |
| ReduceLROnPlateau | Reduce LR when metric plateaus | When you don't know n_epochs |
| WarmupCosine | Linear warmup + cosine | Transformers |
OneCycleLR formula: LR rises linearly from $\eta_{min}$ to $\eta_{max}$ for first 30% of training, then decays via cosine anneal.
Early Stopping
class EarlyStopping:
def __init__(self, patience=10, min_delta=1e-4):
self.patience = patience
self.counter = 0
self.best_loss = float('inf')
def __call__(self, val_loss) -> bool:
if val_loss < self.best_loss - self.min_delta:
self.best_loss = val_loss
self.counter = 0
return False # continue
self.counter += 1
return self.counter >= self.patience # stop
What the Lab Covers
| Section | Content |
|---|---|
SyntheticImageDataset | Custom Dataset + DataLoader + pin_memory |
SimpleCNN | 3-block CNN with BatchNorm |
EarlyStopping | Patience-based stopping |
train_one_epoch() | AMP + GradScaler + gradient clipping |
lr_scheduler_comparison() | Plot 4 schedulers side-by-side |
checkpoint_demo() | Save/load model + optimizer state |
Interview Questions
Q: Why zero_grad(set_to_none=True) instead of zero_grad()?
A: Setting to None avoids writing zeros to memory, which is slightly faster and saves memory when using optimizer state. Functionally identical for standard training.
Q: Why does gradient clipping go between unscale_ and step_?
A: GradScaler.unscale_() divides gradients by the scale factor, restoring their true magnitudes. You must clip the true gradients, not the scaled ones. Otherwise, your clip threshold is meaningless.
Q: When should you use OneCycleLR vs CosineAnnealingLR? A: OneCycleLR is best when you know the total number of steps and want fastest convergence (fewer epochs). CosineAnnealingLR is better when training is more exploratory or you want to restart training.
Q: What causes NaN loss during training?
A: (1) Learning rate too high. (2) Log of 0 or division by 0 in loss. (3) FP16 overflow without loss scaling. (4) Bad data (inf/nan in input). Always add assert not torch.isnan(loss) early in debugging.
Run
pip install -r requirements.txt
python solution.py
# Outputs saved to outputs/
Lab 03 — ResNet from Scratch
Phase 3: PyTorch | Week 8-9
ResNet solved the vanishing gradient problem that blocked deep networks for years. Understanding skip connections is non-negotiable for any CV engineer interview.
Learning Objectives
- Prove the vanishing gradient problem experimentally
- Implement BasicBlock and BottleneckBlock from the original paper
- Build ResNet-18 and ResNet-50 from scratch
- Understand BatchNorm's role in deep network training
- Compare training dynamics: plain network vs ResNet
Theory
Vanishing Gradient Problem
For a network with $L$ layers, the gradient of the loss w.r.t. weights at layer $k$:
$$\frac{\partial \mathcal{L}}{\partial W_k} = \frac{\partial \mathcal{L}}{\partial a_L} \cdot \prod_{i=k}^{L} \frac{\partial a_i}{\partial a_{i-1}}$$
If $\frac{\partial a_i}{\partial a_{i-1}} = \sigma'(z_i) W_i$ and $|\sigma'| < 1$ (sigmoid saturates), the product shrinks exponentially → gradients vanish.
ReLU helps ($\sigma'(z) = 1$ for $z > 0$), but multiplying many weight matrices still causes issues.
Residual Block — The Key Idea
Instead of learning $H(x)$ directly, learn the residual:
$$H(x) = \mathcal{F}(x) + x$$
$$\mathcal{F}(x) = H(x) - x$$
If the optimal solution is close to identity, $\mathcal{F}(x) \approx 0$ — much easier to learn than $H(x) \approx x$.
Gradient flow: $$\frac{\partial \mathcal{L}}{\partial x} = \frac{\partial \mathcal{L}}{\partial H} \cdot \left(\frac{\partial \mathcal{F}}{\partial x} + 1\right)$$
The $+1$ ensures gradient is at least 1 even if $\frac{\partial \mathcal{F}}{\partial x} \approx 0$ — gradient highway.
BasicBlock (ResNet-18/34)
x → Conv3×3 → BN → ReLU → Conv3×3 → BN → (+x) → ReLU
When channels change: use 1×1 conv projection to match dimensions.
class BasicBlock(nn.Module):
expansion = 1
def __init__(self, in_ch, out_ch, stride=1):
self.conv1 = nn.Conv2d(in_ch, out_ch, 3, stride, 1, bias=False)
self.bn1 = nn.BatchNorm2d(out_ch)
self.conv2 = nn.Conv2d(out_ch, out_ch, 3, 1, 1, bias=False)
self.bn2 = nn.BatchNorm2d(out_ch)
self.downsample = nn.Sequential(
nn.Conv2d(in_ch, out_ch, 1, stride, bias=False),
nn.BatchNorm2d(out_ch)
) if stride != 1 or in_ch != out_ch else nn.Identity()
BottleneckBlock (ResNet-50/101/152)
x → Conv1×1 (reduce) → BN → ReLU
→ Conv3×3 → BN → ReLU
→ Conv1×1 (expand) → BN → (+x) → ReLU
Why bottleneck? Reduces channels before the expensive 3×3 conv, then restores. For 256-channel input:
- BasicBlock: $256 \times 256 \times 3 \times 3 \times 2 \approx 1.2$M FLOPs
- Bottleneck: $256{\times}64{\times}1^2 + 64{\times}64{\times}3^2 + 64{\times}256{\times}1^2 \approx 70$K FLOPs
BatchNorm in Deep Networks
$$\hat{x} = \frac{x - \mu_B}{\sqrt{\sigma_B^2 + \epsilon}} \quad \rightarrow \quad y = \gamma \hat{x} + \beta$$
Benefits: (1) Reduces internal covariate shift. (2) Acts as regularizer. (3) Allows higher LR. (4) Makes optimization landscape smoother.
Placement: BN after Conv, before ReLU (He et al. original). Pre-activation BN (BN before Conv) is sometimes better for very deep networks.
ResNet Architecture Summary
| Model | Blocks | Params | Top-1 (ImageNet) |
|---|---|---|---|
| ResNet-18 | Basic | 11.7M | 69.8% |
| ResNet-34 | Basic | 21.8M | 73.3% |
| ResNet-50 | Bottleneck | 25.6M | 76.1% |
| ResNet-101 | Bottleneck | 44.5M | 77.4% |
| ResNet-152 | Bottleneck | 60.2M | 78.3% |
What the Lab Covers
| Function | Concept |
|---|---|
vanishing_gradient_demo() | Gradient norms per layer, plain vs ResNet |
BasicBlock | Exact paper implementation with downsample |
BottleneckBlock | Channel reduction pipeline |
ResNet18() / ResNet50() | Full architecture from scratch |
batchnorm_effect_demo() | Training stability with/without BN |
layer_activation_stats() | Mean/std of activations across depth |
Interview Questions
Q: Why doesn't a deeper plain network always perform better? A: Optimization difficulty, not expressiveness. A 56-layer plain net has higher training error than a 20-layer one (He et al. 2015). Skip connections provide direct gradient paths, enabling effective training.
Q: ResNet-50 has the same depth as VGG-16 but better accuracy. Why? A: Bottleneck blocks are computationally efficient — the 1×1 convolutions reduce/restore channels. This allows 50 layers with fewer FLOPs than VGG-16's 16 layers (3.8B vs 15.5B FLOPs).
Q: What is the role of bias=False when using BatchNorm?
A: BatchNorm has its own learned bias $\beta$. The conv bias is redundant and would be subtracted out by BN's mean normalization — so we omit it to save parameters.
Q: How does torch.compile() speed up ResNet training? A: It fuses operator kernels (e.g., Conv+BN+ReLU into one CUDA kernel), eliminating memory roundtrips between operations. Typically 10-30% speedup on A100.
Run
pip install -r requirements.txt
python solution.py
# Outputs saved to outputs/
Lab 04 — Transfer Learning & Fine-Tuning
Phase 3: PyTorch | Week 9
Transfer learning is the single most impactful technique in practical CV. Almost every production model starts from ImageNet pretrained weights. Know every variant and tradeoff cold.
Learning Objectives
- Understand feature extraction vs full fine-tuning vs discriminative learning rates
- Fine-tune a pretrained ResNet-50 on a new task
- Implement progressive unfreezing (ULMFiT-style for vision)
- Quantify how much data you need for each transfer strategy
- Handle domain gap between source and target datasets
Theory
Why Transfer Learning Works
ImageNet-pretrained networks learn a hierarchy of reusable features:
- Early layers (conv1-conv3): edges, colors, textures — universal
- Mid layers (conv4): parts, patterns — semi-universal
- Late layers (conv5, FC): high-level semantics — task-specific
Reusing early/mid layers provides a strong initialization, especially when target data is limited.
Three Transfer Strategies
| Strategy | When | Trainable Params | Data Needed |
|---|---|---|---|
| Feature extraction | < 1K images, similar domain | Only new head | Very little |
| Partial fine-tuning | 1K-10K images | Last N layers + head | Moderate |
| Full fine-tuning | > 10K images, different domain | All layers | More |
Discriminative Learning Rates
Different layers should have different learning rates — earlier layers need less updating:
$$\eta_k = \frac{\eta_{\text{base}}}{\text{decay}^{(L-k)}}$$
Typical decay = 3×. If base LR = 1e-3: head gets 1e-3, last block gets 3.3e-4, earlier blocks get 1.1e-4, etc.
param_groups = [
{'params': model.layer1.parameters(), 'lr': 1e-5},
{'params': model.layer2.parameters(), 'lr': 3e-5},
{'params': model.layer3.parameters(), 'lr': 1e-4},
{'params': model.layer4.parameters(), 'lr': 3e-4},
{'params': model.fc.parameters(), 'lr': 1e-3},
]
optimizer = torch.optim.AdamW(param_groups, weight_decay=1e-4)
Progressive Unfreezing
- Freeze all except head → train 1-2 epochs
- Unfreeze last block → train 1-2 epochs
- Unfreeze more blocks → train with lower LR
- Unfreeze all → fine-tune at very low LR
Prevents catastrophic forgetting of pretrained knowledge.
Domain Gap
Similar domain (ImageNet → other natural images): All strategies work.
Different domain (ImageNet → medical X-rays): Early layers still useful; fine-tune more layers.
Very different domain (ImageNet → satellite imagery): May need to fine-tune from layer1 with low LR.
Catastrophic Forgetting
When fine-tuning on a small target dataset, the model "forgets" its pretraining. Mitigations:
- Low LR for pretrained layers
- L2 regularization toward original weights (Elastic Weight Consolidation)
- Progressive unfreezing
- Mix in pretraining data during fine-tuning
What the Lab Covers
| Function | Concept |
|---|---|
build_feature_extractor() | Freeze backbone, replace head |
partial_finetune() | Unfreeze last N layers progressively |
discriminative_lr_optimizer() | Per-layer LRs |
compare_transfer_vs_scratch() | Convergence curves: pretrained vs random init |
lr_finder() | Find optimal LR range |
domain_gap_experiment() | Accuracy vs dataset size curves |
Interview Questions
Q: When should you NOT use transfer learning? A: When your domain is very different from pretraining data AND you have a lot of data. E.g., training a medical segmentation model from scratch with 100K annotated scans can outperform ImageNet transfer. Also, if input modality differs (e.g., depth maps, multi-spectral images).
Q: How do you fine-tune efficiently on a single GPU? A: (1) Freeze backbone, train head first. (2) Use discriminative LRs. (3) Mixed precision. (4) Gradient checkpointing for large backbones. (5) Accumulate gradients if batch size is too small.
Q: What is the difference between model.eval() and torch.no_grad()?
A: model.eval() changes behavior of BatchNorm (use running stats instead of batch stats) and Dropout (disable). torch.no_grad() prevents gradient computation to save memory and speed up inference. Both should be used during evaluation; only torch.no_grad() during inference-only code.
Q: You have 500 images for a new classification task. What's your approach? A: Start with ResNet-50 pretrained on ImageNet. Freeze all layers except the last FC. Train for 5-10 epochs at LR=1e-3. Then unfreeze layer4 and fine-tune at LR=1e-4 for 5 more epochs. Use aggressive augmentation (RandomHorizontalFlip, ColorJitter, RandomResizedCrop). Expected accuracy: 85-95% depending on similarity to ImageNet.
Run
pip install -r requirements.txt
python solution.py
# Outputs saved to outputs/
Lab 05 — Distributed Training
Phase 3: PyTorch | Week 10
When a model doesn't fit on one GPU, or training takes too long, you need distributed training. This is a required skill for any ML engineer working at scale.
Learning Objectives
- Understand DDP (DistributedDataParallel) vs DataParallel vs FSDP
- Implement gradient accumulation that provably matches large-batch training
- Quantify communication overhead: bandwidth, latency, model size tradeoffs
- Understand Amdahl's Law applied to distributed ML
- Write a production-ready DDP launch template
Theory
Data Parallelism — DDP
Each GPU holds a full model copy. Batch is split across GPUs. Gradients are synchronized after each backward pass via All-Reduce.
GPU0: batch_0 → forward → backward → grad_0 ─┐
GPU1: batch_1 → forward → backward → grad_1 ─┤─→ AllReduce → averaged grad → update
GPU2: batch_2 → forward → backward → grad_2 ─┘
Ring-AllReduce (NCCL): each GPU communicates with 2 neighbors in a ring. Total data transferred per GPU: $2 \cdot (N-1)/N \cdot \text{model_size}$. Bandwidth scales with $N$ GPUs.
Gradient Synchronization
DDP uses model.no_sync() context manager to suppress gradient sync for gradient accumulation:
for i, batch in enumerate(loader):
if i % accum_steps == 0:
optimizer.zero_grad()
context = model.no_sync() if (i+1) % accum_steps != 0 else contextlib.nullcontext()
with context:
loss = model(batch) / accum_steps
loss.backward()
if (i+1) % accum_steps == 0:
optimizer.step()
Gradient Accumulation (Single GPU)
Mathematically equivalent to using effective_batch_size = batch_size × accum_steps:
$$\frac{1}{N_{\text{eff}}} \sum_{i=1}^{N_{\text{eff}}} \nabla_\theta L_i = \frac{1}{S} \sum_{s=1}^{S} \left(\frac{1}{N} \sum_{i \in \text{mini-batch}s} \nabla\theta L_i\right)$$
Proof: sum of mini-batch gradients divided by total steps = gradient of the full batch. Divide each mini-batch loss by accum_steps before .backward().
Scaling Efficiency — Amdahl's Law
If fraction $p$ of work is parallelizable:
$$\text{Speedup}(N) = \frac{1}{(1-p) + p/N}$$
For DDP, $1-p$ is communication overhead. With fast interconnects (NVLink ~600 GB/s), $p \approx 0.99$. With slow (PCIe ~50 GB/s), $p \approx 0.9$.
Linear scaling rule (He et al.): when scaling from batch size $B$ to $kB$ with $k$ GPUs, multiply LR by $k$. Requires warmup (5 epochs) for large $k$.
FSDP — Fully Sharded Data Parallel
DDP keeps a full model copy on each GPU. FSDP shards model parameters, gradients, and optimizer state across GPUs:
- Memory per GPU: $\approx \text{model_memory} / N$
- Each GPU only holds $1/N$ of parameters
- Parameters are gathered (all-gather) when needed for forward/backward
When to use: model > 10B params, or when model + optimizer state doesn't fit on a single GPU.
3D Parallelism (Megatron-LM)
| Dimension | What's split | For |
|---|---|---|
| Data parallel | Mini-batch | Fast, always use |
| Tensor parallel | Individual weight matrices | Large FC/attention |
| Pipeline parallel | Model layers across GPUs | Huge models (GPT-3+) |
What the Lab Covers
| Function | Concept |
|---|---|
DDP_TEMPLATE | Production torchrun template |
gradient_accumulation_demo() | Proves equivalence to large batch |
allreduce_overhead_simulation() | Model size vs bandwidth chart |
scaling_efficiency_plot() | Amdahl's law: NVLink/PCIe/InfiniBand |
Interview Questions
Q: DDP vs DataParallel — why always use DDP?
A: DataParallel uses a single Python process with a parameter server on GPU0, creating a bottleneck. DDP uses one process per GPU with NCCL all-reduce — no bottleneck, near-linear scaling.
Q: What is the communication complexity of all-reduce for N GPUs with model size M? A: Ring-all-reduce: $2(N-1)/N \cdot M$ data transferred per GPU. For large $N$, approaches $2M$ per GPU regardless of $N$ — bandwidth efficient.
Q: Gradient accumulation vs larger batch — are they truly equivalent? A: Mathematically yes (if you scale LR accordingly). Practically, there are differences: (1) BatchNorm statistics use the mini-batch, not the effective batch. (2) Data order differs slightly. (3) It's slower per sample. Use it when memory limits batch size.
Q: What is find_unused_parameters=True in DDP and when do you need it?
A: When some model parameters don't receive gradients in every forward pass (e.g., conditional branches), DDP's gradient sync would hang waiting for them. This flag detects and skips unused parameters. It adds overhead — only use when needed.
Run
# Single GPU:
python solution.py
# Multi-GPU with torchrun:
torchrun --nproc_per_node=4 solution.py
# Outputs saved to outputs/
Phase 04: TensorFlow / Keras
Weeks 9-10 | 3 Labs
TensorFlow/Keras is the dominant production framework at scale — used by Google, Waymo, DeepMind, and most cloud ML services. Master the Keras Functional API,
tf.datapipelines, and TFLite deployment.
Why TensorFlow?
- TFLite / TF.js / Edge TPU: deployment to mobile and edge devices
- tf.data: high-performance input pipelines with prefetch/cache/map
- SavedModel format: the standard for serving with TF Serving
- Keras Functional API: build complex DAG models (multi-input, multi-output)
- TF Hub: pretrained models with fine-tuning in 10 lines of code
Lab Structure
| Lab | Topic | Key Concepts |
|---|---|---|
| lab-01-keras-functional-api | Keras Functional API | multi-input, shared layers, custom layers |
| lab-02-tf-data-pipeline | tf.data Input Pipelines | .map(), .batch(), .prefetch(), augmentation |
| lab-03-tflite-edge-deploy | TFLite Conversion & Quantization | INT8 post-training quantization, benchmarking |
TF vs PyTorch Cheatsheet
| Concept | PyTorch | TensorFlow/Keras |
|---|---|---|
| Model definition | nn.Module | tf.keras.Model or Functional API |
| Forward pass | model(x) | model(x) or model.predict(x) |
| Training loop | manual | model.fit() or manual |
| Loss | nn.CrossEntropyLoss | tf.keras.losses.SparseCategoricalCrossentropy |
| Optimizer | torch.optim.Adam | tf.keras.optimizers.Adam |
| Dataset | torch.utils.data.Dataset | tf.data.Dataset |
| Export | torch.onnx.export / TorchScript | model.save() (SavedModel) / TFLite |
| Gradient tape | loss.backward() | tf.GradientTape |
Warmup Guide — TensorFlow
Zero-to-expert primer for Phase 04: the TF/Keras ecosystem as a production CV engineer meets it — the Keras model-building spectrum, tf.data input pipelines, and the TFLite path to edge deployment.
Table of Contents
- Chapter 1: Why Learn Both Frameworks
- Chapter 2: The Keras API Spectrum
- Chapter 3: Eager, tf.function, and Tracing — the One Deep Difference
- Chapter 4: tf.data — Input Pipelines as Dataflow Graphs
- Chapter 5: Training with Keras — fit() and Custom Loops
- Chapter 6: SavedModel — the Deployment Contract
- Chapter 7: TFLite — the Edge Path
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Why Learn Both Frameworks
Pragmatics, not ideology: research and most new training code is PyTorch; a large
share of deployed production CV — especially mobile/edge/embedded — runs TF/TFLite,
and teams maintain TF estates that need extending. The job market rewards bilinguals,
and the translation exercise itself deepens both: every concept in this phase maps to
a Phase 03 concept (Keras Layer ↔ nn.Module, tf.data ↔ DataLoader,
SavedModel ↔ TorchScript/ONNX export), and the one genuinely different idea —
graph tracing (Ch. 3) — is a concept PyTorch later adopted in its own form
(torch.compile). Learn TF as "same physics, different conventions, one new idea."
Chapter 2: The Keras API Spectrum
Three ways to define a model, in increasing flexibility — choosing the lowest sufficient tier is the Keras idiom:
- Sequential: a linear stack. Fine for toy models; outgrown immediately by anything with skips or multiple inputs.
- Functional (the lab's focus, and the production default): build a DAG by calling
layers on symbolic tensors:
Because the graph is explicit data, Keras can validate shapes at construction time,inputs = keras.Input(shape=(224, 224, 3)) x = layers.Conv2D(64, 3, padding="same")(inputs) x = layers.add([x, shortcut]) # skips, multi-branch — all natural model = keras.Model(inputs, outputs)model.summary()andplot_modelrender it, and serialization is exact — the payoff of declaring structure rather than executing it. - Subclassing (
keras.Modelwithcall()): PyTorch-style imperative freedom; costs you construction-time shape checking and easy graph introspection. Use when control flow demands it. - Mental model for the spectrum: Functional = "the model is a data structure" (inspectable, exportable); Subclassing = "the model is code" (flexible, opaque). TF tooling (export, TFLite conversion, pruning) prefers data structures — another reason Functional is the deployment-minded default. Note TF is NHWC (channels-last) by default — the opposite of PyTorch; conversion bugs love this.
Chapter 3: Eager, tf.function, and Tracing — the One Deep Difference
TF2 executes eagerly by default (PyTorch-like). Performance comes from
@tf.function: the first call traces the Python function — executes it with
symbolic tensors, recording ops into a graph — and subsequent calls run the compiled
graph, skipping Python entirely.
The three working rules (each a classic bug when violated):
- Python side effects run at trace time only: a
print()fires once;tf.printfires every call. A Python counter increments once. If you need stateful behavior, usetf.Variable. - Retracing triggers: new input shapes or dtypes, and new Python argument
values each create a new trace — passing a Python int batch size retraces per
value (use tensors or
input_signaturewithNonedims). - Data-dependent Python control flow doesn't survive tracing:
if tensor > 0:fails or bakes one branch; AutoGraph converts simple cases totf.cond/tf.while_loop, but the honest fix is tensor ops (tf.where).
This is the same capture problem the model-accuracy track studies deeply
(its Phase 01 lab-03 builds a tracing inspector; its Phase 04 covers PyTorch's
answer) — TF made you confront it a decade early. model.fit wraps its train step in
tf.function automatically, which is why custom Keras code is fast by default and
why violating rule 1 inside a custom layer produces silent weirdness.
Chapter 4: tf.data — Input Pipelines as Dataflow Graphs
tf.data.Dataset is the DataLoader counterpart with a more explicit philosophy:
the pipeline is itself a graph of transformations, optimized and executed in C++:
ds = (tf.data.Dataset.from_tensor_slices(paths_labels)
.shuffle(10_000) # before batch; buffer size matters
.map(decode_and_augment, num_parallel_calls=tf.data.AUTOTUNE)
.batch(64)
.prefetch(tf.data.AUTOTUNE)) # overlap input with training — always last
The performance grammar: map parallelism for CPU-bound decode/augment;
prefetch to overlap producer and consumer (the single most important call —
omitting it serializes input and training); cache() after expensive deterministic
steps when the dataset fits memory/disk; order matters (shuffle before batch; augment
after cache). For real datasets, TFRecord files (protobuf records, sequential
reads) replace per-file opens — the same large-scale I/O lesson as every other track's
data phase: sequential, sharded, parallel. Diagnosis tooling: the TF Profiler's input-
pipeline analyzer tells you whether you're input-bound — check it before buying GPUs
(the universal lesson again).
Chapter 5: Training with Keras — fit() and Custom Loops
- The compiled path:
model.compile(optimizer, loss, metrics)+model.fit(ds, validation_data=...)— with callbacks as the extension points:ModelCheckpoint(save best),EarlyStopping(patience on val metric),ReduceLROnPlateau/LearningRateScheduler,TensorBoard. Ninety percent of training needs are a callbacks list away — resisting the urge to hand-roll what a callback does is the Keras discipline. - Custom training: override
train_step()on the model (keepsfit's machinery — callbacks, distribution — while customizing the step), or go fully manual withtf.GradientTape:
The tape is autograd made explicit and scoped — record only inside thewith tf.GradientTape() as tape: loss = loss_fn(y, model(x, training=True)) grads = tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables))with. Note thetraining=True/Falseargument (TF's explicit version ofmodel.train()/.eval()— BatchNorm/Dropout switch per call, and forgetting it at inference is the same bestiary bug as Phase 03's). - Transfer learning mechanics mirror Phase 03 Ch. 7:
base.trainable = Falsefor the probe stage — with the documented BN subtlety (frozen BN should run in inference mode; Keras handles it whentrainable=False, if you passtraining=Falsecorrectly — read the official transfer-learning guide's BN section once).
Chapter 6: SavedModel — the Deployment Contract
model.export(path) (or tf.saved_model.save) writes the SavedModel: the traced
computation graph(s) + weights + signatures (named typed entry points) —
self-contained, Python-free, loadable by TF Serving, TFLite converter, TF.js, and
other languages. Signatures are the API contract: input names, shapes (including
which dims are dynamic), dtypes — inspect with saved_model_cli show --all.
The discipline (identical in spirit to every export path in this curriculum —
ONNX in Phase 07, the model-accuracy track's Phase 05): export, reload, and verify
outputs match the in-memory model numerically before calling it done; bake
preprocessing into the exported graph where feasible (a Normalization/resize layer
in the model) so the train-time and serve-time transforms cannot drift —
training-serving skew, pre-empted at the artifact level (Phase 02 Ch. 8's pipeline
lesson, now for tensors).
Chapter 7: TFLite — the Edge Path
TFLite is the mobile/embedded runtime: a compact flatbuffer format + interpreter with CPU/GPU/NNAPI/EdgeTPU delegates. The conversion ladder (each rung trades accuracy risk for size/speed):
- Plain conversion (FP32): graph ops → TFLite kernels; size from graph trimming.
- Dynamic-range quantization: weights INT8, activations FP32 — ~4× smaller, modest speedup, near-zero accuracy risk; the default first rung.
- Full integer quantization: weights and activations INT8 — needs a representative dataset (a few hundred real inputs) to calibrate activation ranges; required for integer-only accelerators (EdgeTPU, many MCUs/NPUs).
- Quantization-aware training when rung 3's accuracy drop is unacceptable.
This ladder is precisely the quantization story of the model-accuracy track (its Phase 03 derives the math; its Phase 06 covers NPU deployment at depth) — at this phase's level, the required competencies are: run the conversion, measure the accuracy delta on your eval set per rung (never ship an unmeasured conversion), and benchmark on-device (or with the TFLite benchmark tool) rather than trusting desktop numbers. The lab's deliverable is exactly that table: size / latency / accuracy per rung.
Lab Walkthrough Guidance
Order: labs 01→03 as numbered.
- Lab 01 (Keras Functional): rebuild your Phase 03 ResNet-ish CNN functionally —
skips force the Functional idiom; use
model.summary()to verify against your PyTorch parameter count (a bilingual sanity check); train with the callbacks stack (checkpoint + early stop + TensorBoard). - Lab 02 (tf.data): build the decode→augment→batch→prefetch pipeline from image
files; benchmark with/without
prefetch,num_parallel_calls, andcache— produce the throughput table; convert to TFRecords and measure again. Demonstrate one tf.function retracing trap and its fix. - Lab 03 (TFLite edge deploy): take lab 01's model through conversion rungs 1–3 (with a representative dataset for rung 3); produce the size/latency/accuracy table; verify SavedModel reload-equivalence first (Ch. 6's ritual).
Success Criteria
You are ready for Phase 05 when you can, from memory:
- Place a modeling task on the Sequential/Functional/Subclassing spectrum with the data-structure-vs-code rationale.
- State the three tf.function rules and the bug each prevents; explain trace-time vs run-time.
- Write the canonical tf.data pipeline and justify each stage's order; name the input-bound diagnosis tool.
- Use
train_step/GradientTapeand thetraining=flag correctly. - Describe SavedModel signatures and the export-verify ritual.
- Recite the TFLite quantization ladder with each rung's requirement and risk.
Interview Q&A
Q: Your tf.data pipeline feeds a GPU at 40% utilization. Walk through the fix.
Confirm input-bound with the Profiler's input analyzer (don't guess). Then in order:
prefetch(AUTOTUNE) present and last? map parallelized (AUTOTUNE) and is decode
the hot spot (pre-resize on disk / TFRecords to kill per-file opens)? cache() after
deterministic expensive work if it fits? shuffle buffer too large (startup stall) or
batch too small? Same playbook as PyTorch DataLoader tuning — the framework changes,
the producer-consumer physics doesn't.
Q: A model behaves differently in fit() than when you call it in a loop. Name the
suspects.
training= flag (BN/Dropout modes — fit sets it true, your loop may not),
tf.function trace staleness (your loop mutated Python state that was baked at trace
time — rule 1), different input dtypes/shapes triggering retraces or silent casts,
and regularization losses (model.losses) that fit adds but a naive custom loop
forgets. The grader is listening for trace-time-vs-run-time reasoning — it's the
chapter's one deep idea.
Q: Why does full-integer TFLite need a representative dataset when dynamic-range doesn't? Weights are static — their ranges are known exactly at conversion. Activations are input-dependent — INT8 activation quantization needs calibrated ranges (scale/zero-point per tensor), which only real sample inputs can provide. Dynamic-range sidesteps it by keeping activations float at runtime. Skimping on representativeness (wrong domain, too few samples) shows up as clipped activations and accuracy loss — measure per rung, always.
References
- Keras Functional API guide and Transfer learning guide (the BN section!)
- Better performance with tf.function — the tracing rules, authoritative
- tf.data performance guide and the TF Profiler input-pipeline analyzer
- SavedModel guide and
saved_model_cli - TFLite post-training quantization
- The model-accuracy track's Phase 03 WARMUP — the quantization math under the TFLite ladder
Lab 4-01: Keras Functional API
Learning Objectives
- Build models with the Keras Functional API (vs Sequential)
- Create multi-input, multi-output models
- Implement shared layers and branching architectures
- Write custom
tf.keras.layers.Layersubclasses - Use callbacks: EarlyStopping, ModelCheckpoint, TensorBoard
Keras Functional API vs Sequential
# Sequential: only linear stacks
model = tf.keras.Sequential([
tf.keras.layers.Conv2D(32, 3, activation='relu'),
tf.keras.layers.GlobalAveragePooling2D(),
tf.keras.layers.Dense(10, activation='softmax'),
])
# Functional API: full DAG support
inputs = tf.keras.Input(shape=(224, 224, 3))
x = tf.keras.layers.Conv2D(32, 3, activation='relu')(inputs)
x = tf.keras.layers.GlobalAveragePooling2D()(x)
outputs = tf.keras.layers.Dense(10, activation='softmax')(x)
model = tf.keras.Model(inputs=inputs, outputs=outputs)
Multi-Input Model
# Multi-modal: image + metadata
img_input = tf.keras.Input(shape=(128, 128, 1), name="image")
meta_input = tf.keras.Input(shape=(5,), name="metadata")
# Image branch
x = tf.keras.layers.Conv2D(32, 3, activation='relu', padding='same')(img_input)
x = tf.keras.layers.GlobalAveragePooling2D()(x)
# Fuse
combined = tf.keras.layers.Concatenate()([x, meta_input])
out = tf.keras.layers.Dense(1, activation='sigmoid', name="output")(combined)
model = tf.keras.Model(inputs=[img_input, meta_input], outputs=out)
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
Custom Layer
class ChannelAttention(tf.keras.layers.Layer):
"""Squeeze-and-Excite channel attention."""
def __init__(self, reduction_ratio=4, **kwargs):
super().__init__(**kwargs)
self.reduction_ratio = reduction_ratio
def build(self, input_shape):
C = input_shape[-1]
self.fc1 = tf.keras.layers.Dense(C // self.reduction_ratio, activation='relu')
self.fc2 = tf.keras.layers.Dense(C, activation='sigmoid')
def call(self, x):
# Global average pool → FC → FC → rescale
gap = tf.reduce_mean(x, axis=[1, 2]) # (B, C)
attn = self.fc2(self.fc1(gap)) # (B, C)
return x * attn[:, tf.newaxis, tf.newaxis, :] # broadcast
Interview Questions
Q: When should you use the Functional API instead of Sequential?
A: Whenever you need: (1) multiple inputs/outputs, (2) shared layers, (3) skip connections (ResNet-style), (4) branching (Inception). Sequential only supports linear chains.
Q: What is tf.GradientTape and when do you use it instead of model.fit()?
A: GradientTape records operations for automatic differentiation, enabling a custom training loop with full control. Use it when: custom loss terms, gradient clipping, multiple optimizers (GANs), or logging gradients per-step.
Q: How does model.compile() relate to model.fit()?
A: compile() configures the model: sets optimizer, loss, and metrics. fit() runs the training loop. You must call compile() before fit(). In custom training loops with GradientTape, you bypass both.
Lab 4-02: tf.data Input Pipelines
Learning Objectives
- Build high-performance
tf.data.Datasetpipelines - Use
.map(),.cache(),.shuffle(),.batch(),.prefetch() - Apply image augmentation within
tf.data(TensorFlow native ops) - Profile pipeline bottlenecks with
tf.data.experimental.AUTOTUNE - Understand why
tf.datapipelines are faster than Python DataLoaders for TPU
Pipeline Building Blocks
Raw files / numpy arrays
│
▼ tf.data.Dataset.from_tensor_slices() / .list_files()
│
▼ .map(parse_fn, num_parallel_calls=AUTOTUNE) ← decode, resize, normalize
│
▼ .cache() ← cache after expensive decode (if fits in RAM)
│
▼ .shuffle(buffer_size) ← randomize order
│
▼ .batch(batch_size, drop_remainder=True)
│
▼ .map(augment_fn) ← augmentation AFTER batch for efficiency
│
▼ .prefetch(AUTOTUNE) ← overlap CPU preprocessing with GPU training
Key Rules
| Rule | Why |
|---|---|
.cache() before .shuffle() | Shuffle runs on already-decoded data |
.prefetch(AUTOTUNE) last | Always — overlaps CPU/GPU work |
num_parallel_calls=AUTOTUNE in .map() | Parallelizes decoding automatically |
Augmentation after .batch() | GPU can vectorize batched operations |
drop_remainder=True | Fixed batch sizes needed for TPU XLA compilation |
AUTOTUNE
AUTOTUNE = tf.data.AUTOTUNE # let TF choose parallelism based on hardware
dataset = (
tf.data.Dataset.from_tensor_slices((images, labels))
.map(preprocess, num_parallel_calls=AUTOTUNE)
.cache()
.shuffle(1000)
.batch(32)
.map(augment, num_parallel_calls=AUTOTUNE)
.prefetch(AUTOTUNE)
)
Interview Questions
Q: What is the difference between .cache() and .prefetch()?
A: .cache() stores dataset elements in memory (or disk) after the first epoch — eliminates re-decoding/re-preprocessing in subsequent epochs. .prefetch() runs the data pipeline in the background while training — eliminates pipeline stalls between batches. Use both: cache first, prefetch last.
Q: Why must .shuffle() come after .cache() but before .batch()?
A: After .cache(): shuffle operates on already-decoded examples (fast). Before .batch(): ensures batches contain mixed examples. If you shuffle after batch, you shuffle batches not individual examples (much weaker randomization).
Q: How large should the shuffle buffer be?
A: Buffer size controls randomness quality: buffer_size=N maintains a pool of N examples and samples uniformly from it. For perfect shuffle, buffer_size = dataset_size. In practice, 1000-10000 is a good tradeoff. Too small → correlated batches. Too large → high memory usage, slow first epoch.
Lab 4-03: TFLite Conversion & Edge Deployment
Learning Objectives
- Convert a Keras model to TFLite FlatBuffer format
- Apply INT8 post-training quantization with a representative dataset
- Benchmark FP32 vs FP16 vs INT8 TFLite latency
- Understand what happens during quantization (weight + activation quantization)
- Compare TFLite vs ONNX Runtime for mobile deployment
- Save benchmark results as pandas CSV
TFLite Conversion Pipeline
Keras Model (.keras)
│
▼ tf.lite.TFLiteConverter.from_keras_model()
TFLiteConverter
│
├── FP32 (no quantization) → baseline accuracy, full size
├── FP16 (float16) → ~2x smaller, ~5% faster on GPU/DSP
└── INT8 (with representative → ~4x smaller, ~2-4x faster, minimal
dataset calibration) accuracy drop if calibrated well
│
▼ converter.convert()
.tflite FlatBuffer
│
▼ tf.lite.Interpreter
On-Device Inference
Quantization Types
| Type | Weights | Activations | Calibration Needed | Size Reduction |
|---|---|---|---|---|
| FP32 | float32 | float32 | No | 1x (baseline) |
| FP16 | float16 | float32 | No | ~2x |
| Dynamic INT8 | int8 | float32 | No | ~4x |
| Full INT8 | int8 | int8 | YES (rep. dataset) | ~4x + faster |
Representative Dataset (required for full INT8)
def representative_dataset():
for batch in val_dataset.take(100):
imgs = batch[0]
yield [imgs.numpy().astype(np.float32)]
converter.representative_dataset = representative_dataset
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter.inference_input_type = tf.uint8
converter.inference_output_type = tf.uint8
Interview Questions
Q: What is the difference between dynamic range quantization and full INT8 quantization?
A: Dynamic range: only weights are quantized to INT8 at conversion time; activations are dynamically quantized at runtime (still FP32 math). Full INT8: both weights AND activations are quantized to INT8, requiring a calibration (representative) dataset to compute activation ranges. Full INT8 is faster on hardware INT8 accelerators (Edge TPU, DSP) but requires calibration.
Q: What is the FlatBuffer format and why does TFLite use it?
A: FlatBuffers is a zero-copy serialization format (no deserialization needed). TFLite uses it because on edge devices, you can memory-map the model file directly and start inference without loading it into RAM — critical for low-memory devices.
Q: When would you choose TFLite over ONNX Runtime for deployment?
A: TFLite: Android/iOS apps, Coral Edge TPU, Raspberry Pi. Tighter TF ecosystem integration. ONNX Runtime: Windows/Linux servers, diverse model sources (PyTorch, sklearn), more execution providers (CUDA, TensorRT, DirectML). For mobile = TFLite. For server = ORT.
Phase 05: Computer Vision Deep Learning
Object detection, segmentation, and modern architectures — the heart of the CV engineer role.
Labs
| Lab | Topic | Key Papers |
|---|---|---|
| lab-01 | YOLOv8 — training, evaluation, TensorRT export | Ultralytics YOLOv8 (2023) |
| lab-02 | Faster R-CNN — two-stage detection from scratch | Ren et al., 2015 |
| lab-03 | U-Net — semantic segmentation | Ronneberger et al., 2015 |
| lab-04 | Mask R-CNN — instance segmentation | He et al., 2017 |
Prerequisites
- Phase 3 complete (PyTorch training loops, ResNet)
- Phase 4 recommended (TensorFlow/Keras) but not required
Learning Path
- Start with YOLOv8 (lab-01) — get end-to-end detection working fast
- Study Faster R-CNN theory (lab-02) — understand two-stage detectors deeply
- U-Net (lab-03) — most important for medical/industrial CV
- Mask R-CNN (lab-04) — combines detection + segmentation
Hardware Requirements
- GPU strongly recommended (8GB+ VRAM)
- CPU fallback works but will be 20-50× slower for labs 02-04
Warmup Guide — CV Deep Learning: Detection & Segmentation
Zero-to-expert primer for Phase 05: the dense-prediction tasks — object detection (two-stage and one-stage), semantic and instance segmentation — and the shared machinery (anchors, IoU, NMS, mAP) that every system in this phase runs on.
Table of Contents
- Chapter 1: The Task Taxonomy
- Chapter 2: The Shared Machinery — IoU, Anchors, NMS
- Chapter 3: Evaluating Detectors — mAP, Honestly
- Chapter 4: Two-Stage Detection — the R-CNN Line
- Chapter 5: One-Stage Detection — YOLO and the Speed Frontier
- Chapter 6: Semantic Segmentation — U-Net
- Chapter 7: Instance Segmentation — Mask R-CNN
- Chapter 8: Training Dense Predictors — Losses and Imbalance
- Chapter 9: Detection as Set Prediction — DETR to RT-DETR
- Chapter 10: The YOLO Evolution, v8 → v10/v11 — and What Actually Changed
- Chapter 11: Multi-Object Tracking — Detection Over Time
- Chapter 12: Modern Training Recipes — What the YOLO Configs Actually Do
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: The Task Taxonomy
Classification answers what; this phase's tasks answer what and where, at increasing granularity:
| Task | Output | Unit of prediction |
|---|---|---|
| Classification | one label | image |
| Object detection | boxes + labels + scores | object |
| Semantic segmentation | a label per pixel | pixel (no instances: all "car" pixels are one class) |
| Instance segmentation | a mask per object | object × pixel |
| (Panoptic) | both: stuff + things | everything |
The structural shift from classification: outputs are variable-length and spatial, which forces the machinery of Chapter 2 (how do you compare a predicted box set to a ground-truth box set?) and reshapes evaluation entirely (Chapter 3). Choosing the minimal sufficient task is a product decision you'll own: counting cars needs detection, not instance masks; measuring crack area needs semantic masks, not boxes — each step up the table costs labeling effort, compute, and annotation-quality sensitivity.
Chapter 2: The Shared Machinery — IoU, Anchors, NMS
Three concepts power every detector:
- IoU (Intersection over Union): overlap area / union area of two boxes ∈ [0, 1] — the similarity metric of the field. Used to match predictions to ground truth (training assignment and evaluation), with thresholds as the vocabulary (IoU ≥ 0.5 = "correct" classically; 0.5:0.95 averaged = COCO's stricter modern standard).
- Anchors / priors: predefined reference boxes tiled across feature-map positions at multiple scales/aspect ratios; the network predicts offsets from anchors rather than raw coordinates — turning detection into dense classification + regression on a grid. Anchor design (scales, ratios) encodes dataset priors; anchor-free detectors (FCOS, modern YOLO) replace them with per-location center/distance predictions — simpler, now standard, but the anchor mental model remains the key to reading the literature.
- NMS (Non-Maximum Suppression): dense predictors fire many overlapping boxes per object; NMS keeps the highest-scoring box and removes neighbors above an IoU threshold, per class. Knobs that matter in production: the IoU threshold (crowded scenes need higher / Soft-NMS), the score threshold (the precision-recall dial — Ch. 3), and the fact that NMS is a post-process outside the network — exported models may or may not include it (a classic deployment surprise in Phase 07).
Chapter 3: Evaluating Detectors — mAP, Honestly
The metric pipeline, worth knowing mechanically because every published number hides choices inside it:
- Match predictions to ground truth greedily by score within a class: a prediction is TP if IoU ≥ threshold with an unmatched GT box, else FP; unmatched GT are FN.
- Sweep the score threshold → precision-recall curve per class.
- AP = area under that PR curve (interpolated); mAP = mean over classes; COCO's headline number additionally averages over IoU thresholds 0.5:0.05:0.95 (so "mAP 50.2" and "mAP@0.5 = 68" describe the same model — always check which).
- COCO also reports AP_small/medium/large — the size breakdown is where real systems are diagnosed (small-object AP is usually the disaster zone, and the reason FPNs exist).
The Phase 02 evaluation ethics apply unchanged: fixed eval sets, error bars across seeds where claims are close, and look at the errors — a TIDE-style breakdown (localization vs classification vs duplicate vs missed) tells you what to fix; a single mAP number doesn't.
Chapter 4: Two-Stage Detection — the R-CNN Line
The lineage that defined the field, compressed to its load-bearing ideas:
- R-CNN (2014): external region proposals (selective search) + CNN per crop — accurate, absurdly slow (2K forward passes/image).
- Fast R-CNN: run the backbone once; pool per-proposal features from the shared feature map (RoI Pooling) — proposals become cheap heads, not full passes. The shared-computation insight is the one that generalizes everywhere.
- Faster R-CNN (the lab's subject): replace external proposals with a learned Region Proposal Network — a tiny conv head on the backbone predicting objectness + box offsets per anchor; top proposals feed the RoI head (classify + refine). End-to-end, ~real-time-ish, and still the accuracy-first template.
- FPN (Feature Pyramid Network): lateral top-down connections build a multi-scale feature pyramid so small objects are detected on high-resolution levels and large on coarse — the single biggest small-object fix; standard in every modern detector.
- RoIAlign (from Mask R-CNN, but used everywhere now): RoI Pooling's integer quantization misaligns features by pixels — fatal for masks, harmful for boxes; RoIAlign uses bilinear sampling at exact fractional coordinates. The lesson in miniature: dense prediction punishes coordinate sloppiness that classification never noticed.
Chapter 5: One-Stage Detection — YOLO and the Speed Frontier
One-stage detectors skip proposals: predict boxes + classes densely over the feature grid in a single pass.
- The YOLO idea: divide the image into a grid; each cell (× anchor/scale) predicts objectness, class, and box — detection as one big regression. v1's coarse grid missed small/crowded objects; the series' evolution (v2–v8+) is a masterclass in iterative engineering: anchors then anchor-free, multi-scale heads (FPN-style necks — PANet), better label assignment (the quiet revolution: which predictions get assigned to which GT — SimOTA/TAL-class dynamic assignment), decoupled heads, and augmentation recipes (mosaic, mixup).
- The historical accuracy gap vs two-stage was largely class imbalance: a dense grid is ~10⁴–10⁵ background predictions per handful of objects; easy negatives swamp the loss. Focal loss (RetinaNet) fixed it by down-weighting easy examples ($(1-p_t)^\gamma$ scaling) — after which one-stage matched two-stage and the speed/simplicity argument won the deployment world.
- DETR-class (transformer set prediction, bipartite matching, no NMS) is the post-2020 branch — elegant, increasingly practical, worth knowing as the third paradigm even though this phase's labs are CNN-line.
- Production framing for the YOLOv8 lab: you'll mostly fine-tune and deploy YOLOs, not invent them — the competencies are dataset preparation (label format, class balance), augmentation/hyperparameter sanity, reading the per-class/per-size AP breakdown, and the latency/accuracy operating-point choice (model size × input resolution).
Chapter 6: Semantic Segmentation — U-Net
Per-pixel classification needs per-pixel resolution — but backbones downsample 32×. The resolutions must come back:
- Encoder-decoder: downsample for context (large receptive field, semantics), upsample (transposed conv or interpolation+conv) for resolution.
- U-Net's contribution — skip connections from each encoder stage to the matching decoder stage: the decoder gets both deep semantics (what) and early-layer detail (precisely where). Without skips, boundaries blur into blobs; with them, U-Net segments crisply from tiny datasets — which is why it owns medical imaging (the capstone's domain) and why "U-Net-shaped" is now a generic architecture adjective (diffusion models included).
- The practical loss story (Ch. 8 expands): per-pixel cross-entropy + Dice loss (overlap-based, robust to foreground/background imbalance — a 1% foreground organ drowns plain CE).
- Evaluation: mIoU (per-class IoU averaged — pixel accuracy lies under imbalance, same disease as Phase 02 Ch. 6), boundary metrics where edges matter.
Chapter 7: Instance Segmentation — Mask R-CNN
The synthesis: Faster R-CNN + a third head. For each detected RoI, a small FCN predicts a binary mask within the box (28×28, class-specific). The design decisions that carry the lessons:
- Detect-then-segment decomposition: instances come from the detector (boxes separate the objects); the mask head only answers "which pixels inside this box" — sidestepping the hard problem of separating touching same-class pixels that pure semantic segmentation can't express.
- RoIAlign is mandatory (Ch. 4): mask quality is precisely where pooling misalignment became visible — the feature that motivated the fix.
- Decoupled mask/class prediction: per-class binary masks with sigmoid (not a softmax competition across classes) — cleaner gradients, the paper's quiet ablation win.
- Cost profile: heavier than detection (per-RoI mask head), annotation cost is the real bottleneck (polygon masks are ~10× box-labeling effort) — which loops back to Chapter 1's "minimal sufficient task" judgment.
Chapter 8: Training Dense Predictors — Losses and Imbalance
The cross-cutting training realities (all four labs hit them):
- Multi-task losses: detection = classification loss + box-regression loss (smooth L1 / IoU-family losses — GIoU/DIoU/CIoU fix plain-IoU's zero-gradient-when- disjoint problem) (+ mask loss, + objectness), with weighting coefficients that are real hyperparameters.
- Label assignment decides what each prediction learns: anchor-IoU rules classically; dynamic/learned assignment in modern YOLOs — when training is unstable or duplicates persist, suspect assignment before architecture.
- Imbalance is the default condition: background≫foreground (focal loss, sampling ratios like RPN's 1:1 batch), class imbalance across categories (per-class AP exposes it), size imbalance (FPN + size-aware augmentation).
- Augmentation is task-aware: geometric transforms must transform boxes/masks identically (the library does it — but verify visually; silent box-augmentation mismatch is a classic ruin), mosaic/scale jitter for small-object recall.
- The debugging instrument: visualize predictions on training images early and often — anchor coverage, assignment, NMS behavior are all visible problems (Phase 00 Ch. 7's plot-everything ethic, at its highest value).
Chapter 9: Detection as Set Prediction — DETR to RT-DETR
Chapter 5 named DETR as the third paradigm; here is its machinery — because by 2026 the DETR line is a production option, not a curiosity.
The reframe. Every detector so far predicts densely — thousands of candidate boxes — and deduplicates afterward (NMS, Chapter 2). DETR (2020) instead treats detection as direct set prediction: output a fixed-size set of N predictions (N ≈ 100–300), trained so that each real object is owned by exactly one prediction slot.
- Architecture: a CNN (or transformer) backbone produces a feature map, flattened into a token sequence (+ positional encodings, since attention is order-blind); a transformer encoder refines it; the decoder runs N learned object queries — trainable embedding vectors, one per prediction slot — which cross-attend into the image features ("where should I look?") and self-attend to each other ("what are the other slots already claiming?"). Each output embedding passes through small heads to a class distribution (including a "no object" ∅ class) and a box.
- One-to-one bipartite matching: at training time the Hungarian algorithm computes the minimum-cost one-to-one matching between the N predictions and the ground-truth objects (leftover slots match ∅). The matching cost per candidate pair combines a class-probability term (how strongly this slot already predicts this GT's class), an L1 box distance, and a GIoU term — the same quantities the loss uses, so the assignment optimizes what the training optimizes. The loss is then computed only through that matching.
- Why this deletes NMS: with exactly one slot assigned per object, any duplicate box is by definition matched to ∅ and trained to shut up; the self-attention among queries is the mechanism that lets slot \( j \) see that slot \( i \) already owns the object. Duplicate suppression becomes a learned, in-graph behavior instead of a hand-tuned IoU post-process — every NMS production knob from Chapter 2 (threshold tuning, crowded-scene suppression of real neighbors, the in-graph-vs-app-side export ambiguity) simply ceases to exist.
Why original DETR converged brutally slowly (500 COCO epochs vs the usual 12–36): first, each query's cross-attention starts near-uniform over every pixel and must learn to localize from scratch — dense detectors get localization for free from the grid structure; second, early in training the Hungarian matching is unstable — the slot assigned to a given object flips between iterations, so queries receive contradictory regression targets. Two fixes define the modern line:
- Deformable DETR: replace global cross-attention with deformable attention — each query carries a reference point and predicts a small number of sampling offsets (e.g., 4 points per feature level) plus their attention weights, then attends only to those K bilinearly-sampled locations rather than the whole map. Cost drops from quadratic in pixels to linear in K, multi-scale feature maps become affordable (the FPN benefit, inside a transformer), and convergence speeds up ~10× because attention is born sparse and local instead of having to learn sparsity.
- Denoising training (DN-DETR), in one paragraph: alongside the learned queries, feed noised copies of the ground-truth boxes as extra decoder queries whose assignment is known by construction — each must reconstruct its own GT. These bypass Hungarian matching entirely, so they deliver a stable "refine a rough box into this exact box" gradient from step one and stabilize what the matched queries learn. The DINO-DETR recipe (contrastive denoising, better query initialization) consolidated this into the standard training package.
RT-DETR (2023) is the "and now it's real-time" packaging, built from three decisions: run self-attention only on the top pyramid level (the smallest map, where quadratic attention is affordable and the semantics live); fuse the higher-resolution levels back in with a CNN-based cross-scale path instead of more attention; and initialize decoder queries by IoU-aware selection — pick top-K encoder features scored so that classification confidence reflects expected localization quality, handing the decoder good starting boxes instead of blank slates.
Its position vs YOLO in 2026, honestly: RT-DETR-class models sit on the modern YOLO accuracy/latency curve on server GPUs, with two structural advantages — no NMS-tuning surface (and deterministic latency: NMS cost varies with detection count, set prediction doesn't) and a clean single-graph TensorRT export — while YOLOs still win at the smallest edge/CPU budgets and in ecosystem depth (tooling, formats, community recipes). The open-vocabulary detection story and what foundation-model representations change for this line belong to Phase 06's warmup; this chapter stays at the detection-mechanics level.
Chapter 10: The YOLO Evolution, v8 → v10/v11 — and What Actually Changed
Honesty first: post-v8 YOLO releases are engineering increments, not revolutions — version numbers are partly release cadence, and reading the actual diffs beats believing launch blogs. What matters more than any single version is the shared modern settlement that v8-era detectors converged on:
- Anchor-free, decoupled heads. Separate convolutional branches predict classification and box regression. Why coupling hurt: classification wants translation-invariant semantics ("what is it, wherever it sits"), regression wants translation-sensitive geometry ("where exactly is the edge") — one shared branch compromises both, and worse, it misaligns confidence with box quality, which directly degrades NMS (NMS ranks by score; a high score on a sloppy box beats a lower score on a tight one).
- Task-aligned assignment (TAL): choose which predictions are positives by a combined score \( t = s^{\alpha},\mathrm{IoU}^{\beta} \) — classification score × localization quality. Why "aligned" beats center-based heuristics: geometric rules ("locations near the GT center are positives") encode a guess about which predictions should be good; TAL assigns based on which predictions are good at both subtasks, so the network is trained where it can win — and the classification score becomes an honest proxy for box quality, which is exactly what score-ranked deduplication needs.
- Distribution Focal Loss (DFL) for box regression: instead of regressing each box side as a point value, predict a distribution over discretized offsets (n bins per side; the final coordinate is the expectation of the softmax). A point regression forces the network to commit when the true boundary is ambiguous — occlusion, motion blur, annotation noise; a distribution can be sharp where the edge is crisp and spread where it isn't. That gives cleaner gradients near the target (the loss focuses mass on the two bins bracketing it) and a usable localization-confidence signal for free.
The versions, at fair size: YOLOv9 is a training-dynamics contribution wearing a version number — GELAN (a re-engineered efficient-aggregation backbone) plus PGI, an auxiliary reversible branch that preserves information for gradient computation and is discarded at inference. YOLOv10's real contribution is NMS-free inference via consistent dual assignments — and the trick deserves a proper explanation. Training purely one-to-one (DETR-style, one positive per object) starves a dense head: most of the grid gets no positive gradient, and convergence suffers. v10 therefore trains two heads on the same backbone and neck: a one-to-many head with classic YOLO assignment (many positives per GT — rich, dense gradients) and a one-to-one head (single positive per GT). A consistent matching metric makes the one-to-one head's chosen positive coincide with the one-to-many head's top-ranked sample, so the two heads pull the shared features in the same direction instead of fighting. At deployment the one-to-many head is deleted: the one-to-one head emits non-duplicated boxes directly — DETR's no-NMS deployment story at YOLO cost. YOLO11 (2024) is the Ultralytics continuation — C3k2 blocks, partial attention modules, better parameters-per-mAP — same paradigm, same API; treat it as v8 with three more years of tuning.
The production reality most tutorials omit: licensing. Ultralytics code (v5, v8, v11) is AGPL-3.0. AGPL extends GPL's copyleft trigger to network use: users interacting with your service over a network counts as conveying the software, which obligates you to offer the corresponding source of your modified version — and depending on how your product embeds the library, plausibly the surrounding service code. Corporate legal teams treat that exposure as radioactive for closed-source products. The real options on the table: (1) buy the Ultralytics commercial license — that's their business model, and it's a legitimate answer; (2) use permissively-licensed alternatives — RT-DETR (Apache-2.0 in its original implementations), D-FINE, RF-DETR-class detectors and other Apache/MIT reimplementations; (3) train your own from the papers on a permissive codebase. Note the subtlety that weights trained with AGPL tooling inherit the question — assume they do until counsel says otherwise, and note that consuming RT-DETR through the ultralytics package is still AGPL-bound library use. Detector selection in 2026 is an accuracy × latency × license decision, and raising the third axis before legal does is a senior-engineer move.
Chapter 11: Multi-Object Tracking — Detection Over Time
Video turns detection into tracking: assign each object a persistent identity across frames. The dominant paradigm, tracking-by-detection, decomposes it cleanly — run a detector on every frame, then solve data association: which of this frame's boxes continues which existing track? Three components recur in every system: per-frame detection quality, a motion model predicting where each track should be now, and an assignment step matching predictions to detections.
- SORT (2016) is the minimal honest implementation. Each track owns a Kalman filter whose state is the box's position and scale plus their velocities. Every frame it runs predict (advance the state under a constant-velocity model — uncertainty grows) then update (correct with the matched detection — uncertainty shrinks). The intuition: a principled weighted average between "where physics says it went" and "where the detector says it is", with weights set by each side's current uncertainty. Association is Hungarian assignment (the same algorithm as DETR's matching, Chapter 9) on the IoU between predicted track boxes and the new detections.
- DeepSORT adds appearance: a ReID embedding network — trained on re-identification data so the same identity maps to nearby feature vectors — computes an embedding per detection, and the association cost mixes motion distance with appearance (cosine) distance. The point is occlusion robustness: motion predictions drift when a track goes unobserved, but appearance persists, so the pedestrian re-attaches to their old track when they emerge from behind the truck.
- ByteTrack's key insight, explained properly: everyone had been discarding low-confidence detections before association — but an occluded true object usually still produces a box, just a low-score one, because the detector sees part of it. ByteTrack associates in two stages: high-confidence detections to tracks first; then the still-unmatched tracks against the low-confidence boxes. Occluded objects therefore keep their tracks alive with real observations instead of blind Kalman extrapolation. This single data-flow change outperformed systems with elaborate ReID machinery on the MOT benchmarks — the recurring engineering lesson: audit what your pipeline throws away before adding model capacity. (Its limit is full occlusion — no box at any score — which is where appearance and long-gap re-entry logic still earn their keep.)
- One-liners for the current top of the leaderboards: OC-SORT re-anchors the Kalman state on observations to undo the drift accumulated while a track was occluded; BoT-SORT is ByteTrack plus camera-motion compensation and a ReID term.
- MOT metrics, one honest sentence each: MOTA is detection-weighted — it is dominated by FP/FN counts, so a tracker can scramble identities constantly and barely lose MOTA; IDF1 is identity-weighted — it rewards keeping the right ID for a long time and forgives missed boxes more than MOTA does; HOTA decomposes performance into detection accuracy × association accuracy and takes their geometric mean — the balanced compromise, and the modern headline metric.
- Production notes: the user-visible failure is the ID switch — analytics double-count a person, the alert fires on the wrong track — so watch IDF1/IDsw even when MOTA looks fine. And track lifecycle is a real tuning surface: birth (require N consecutive matches before confirming a track, or every detector false positive becomes a phantom) and death (max frames unmatched before deletion — too short kills tracks at every occlusion, too long glues a ghost track onto the next passerby). Both interact with frame rate and your scene's occlusion statistics; there are no universal defaults.
Chapter 12: Modern Training Recipes — What the YOLO Configs Actually Do
The config file is not boilerplate — it encodes a decade of empirical findings, and in practice the recipe is half the mAP. What each line buys:
- Mosaic augmentation: four training images, randomly scaled and cropped, collaged
into one. Three things it buys: every object appears at reduced scale (small-object
exposure without collecting small-object data — recall Chapter 3's AP_small disaster
zone); each sample carries four scenes' worth of variety (effective batch diversity
at constant memory); and objects land in unnatural contexts, breaking
background-co-occurrence shortcuts. Why
close_mosaicdisables it for the last N epochs: mosaic samples are distributionally wrong — collage seams, truncated boxes, impossible layouts — useful as mid-training regularization, harmful at the end; the final clean epochs let the box regressor and normalization statistics settle on test-like images, closing the train/test distribution mismatch. - MixUp (blend two images and their labels with a sampled weight) adds decision- boundary smoothness; copy-paste pastes segmented instances onto other images — it needs masks, which is why it's the signature augmentation of instance segmentation recipes, where it's reliably worth multiple mask-AP points.
- EMA weights: maintain a shadow copy \( \theta_{\text{EMA}} \leftarrow d,\theta_{\text{EMA}} + (1-d),\theta \) (decay \( d \approx 0.999 \)) updated every step — and evaluate and deploy the shadow, not the raw weights. SGD's endpoint is one noisy sample from a trajectory bouncing around the optimum; the running average approximates ensembling along that trajectory for free. Every serious recipe has this on silently — if a released model beats your rerun and your curves jitter, check whether you evaluated raw weights while they shipped EMA.
- Warmup + cosine LR: the first steps after initialization are the most dangerous — gradients are large and misdirected, the optimizer's second-moment estimates are unformed, and with a pretrained backbone under a random head, the head's garbage gradients flow straight into good backbone weights. Warmup ramps the LR from near-zero over the first epochs so nothing violent happens until statistics stabilize; cosine decay then anneals smoothly toward zero so late training refines instead of thrashing.
- Multi-scale training: randomly resize inputs (±50%) across batches — resolution robustness, and the reason one checkpoint stays usable across the resolution operating points of Chapter 5.
- Where albumentations fits: in custom (non-Ultralytics) pipelines it's the
standard choice because its transforms are label-aware — boxes and masks move with
the pixels, with clipping and minimum-visibility policies handled. Chapter 8's
warning stands regardless: verify visually; a wrong
bbox_paramsformat silently corrupts every label while everything appears to train.
The closing honesty point: reproduction gaps between a paper's number and your run are more often recipe — augmentation schedule, EMA, assignment settings, training length — than architecture; and comparing detector A vs B under unequal recipes is meaningless. Diff the configs before crediting the architecture (Phase 02's controlled-comparison ethic, wearing a YOLO config).
Lab Walkthrough Guidance
Order: Lab 01 (YOLOv8) → Lab 02 (Faster R-CNN) → Lab 03 (U-Net) → Lab 04 (Mask R-CNN) — deploy-first, then anatomy, then pixels, then synthesis.
- Lab 01: fine-tune on a custom small dataset end to end; deliverables are the per-class/per-size AP table, a TIDE-style error breakdown, and a latency/accuracy operating-point comparison (n/s/m model × 2 resolutions).
- Lab 02: walk torchvision's Faster R-CNN internals — log and visualize RPN proposals, RoI assignments, and NMS inputs/outputs on a handful of images; swap the backbone and measure. The goal is anatomy fluency, not SOTA.
- Lab 03: implement U-Net from scratch; train with CE vs CE+Dice on an imbalanced-foreground dataset and compare boundary quality; ablate the skip connections (the blurred-blob failure, personally witnessed).
- Lab 04: fine-tune Mask R-CNN; inspect RoIAlign vs RoIPool on mask quality if feasible; report box AP vs mask AP and explain the gap on your data.
Success Criteria
You are ready for Phase 06 when you can, from memory:
- Choose the minimal sufficient task for three product scenarios and price the annotation delta.
- Define IoU, the anchor→offset scheme, and NMS with its production knobs.
- Walk the mAP pipeline including COCO's 0.5:0.95 convention and the size breakdown.
- Tell the R-CNN→Faster R-CNN story via the shared-computation and learned-proposal insights; explain FPN and RoIAlign.
- Explain why one-stage detectors needed focal loss, and what label assignment is.
- Draw U-Net and defend the skip connections; state Dice's role under imbalance.
- Describe Mask R-CNN's decomposition and its two key design choices.
- Explain DETR's query + Hungarian-matching mechanism, why one-to-one assignment deletes NMS, and the two fixes (deformable attention, denoising queries) for its slow convergence.
- State what actually changed post-v8 (TAL, DFL, v10's consistent dual assignment) and walk the AGPL decision as a license-vs-switch-vs-build call.
- Trace SORT → DeepSORT → ByteTrack by what information each adds, and say what MOTA, IDF1, and HOTA each reward.
Interview Q&A
Q: Your detector's mAP is 45 but customers complain it misses small objects. Diagnose and fix. The aggregate hides it: check AP_small (likely <15 while AP_large is 60+). Fixes in order of cost: raise input resolution (linear cost, immediate small-object gain); confirm FPN levels actually cover the size distribution (anchor/stride audit vs a histogram of GT sizes); augment with scale jitter/mosaic; tile-based inference for extreme cases (aerial imagery). And fix the metric: report size-stratified AP so the dashboard sees what customers see — the metric-design lesson is half the answer.
Q: Why does NMS exist, and when does it become the problem? Dense predictors are trained with many positives per object (assignment), so they fire duplicates by design; NMS deduplicates by score+IoU. It breaks: crowded same-class scenes (pedestrians — high IoU between real neighbors gets suppressed; Soft-NMS or higher thresholds), boxes of odd aspect ratios where IoU is a poor proximity measure, and deployment (is NMS inside the exported graph or app-side? mismatch = duplicated or missing boxes in prod). DETR's set-prediction-with-matching is the architectural answer to "what if no NMS" — name it as the trade: elegance vs the mature CNN ecosystem.
Q: Semantic vs instance segmentation — your client says "we need to know how much area is damaged." Which, and why? Semantic (U-Net-class): "how much area" is a per-pixel question with no instance identity needed — cheaper labels (paint the damage), cheaper model, simpler metric (IoU/area error). Instance segmentation earns its cost only when you must count or track discrete objects. Asking "is the unit of decision the pixel-region or the object?" is the consultative move the question is fishing for.
Q: Why does ByteTrack outperform trackers with fancy ReID? Because it fixed the information pipeline, not the model. Pre-ByteTrack systems discarded detections below a confidence threshold before association — and occlusion, the exact case ReID exists to handle, is precisely when a true object's score drops: the detector sees half a pedestrian and emits a low-score box. So the expensive appearance machinery was being asked to re-glue tracks that an upstream filter had broken. ByteTrack's two-stage association (high-confidence boxes to tracks first, then unmatched tracks against the low-confidence boxes) keeps occluded tracks fed with real observations, so identities don't break in the first place — prevention beat repair. The honest caveats: it requires the detector to produce some box under partial occlusion (full occlusion still needs appearance/long-gap logic — hence BoT-SORT), and low-confidence association leans on the motion gate to avoid latching onto background false positives — which conveniently rarely overlap a predicted track box. The meta-answer interviewers want: audit what your pipeline throws away before adding model capacity.
Q: Your YOLO's mAP is fine but users report duplicate boxes in production — walk the diagnosis, and how would a set-prediction detector change this? Fine offline mAP + duplicates in prod means the dedup machinery differs between eval and deployment. Diagnose in order: (1) Is NMS running at all in the deployed path? The exported ONNX/TensorRT graph may not contain NMS while offline eval used the framework's post-processing (Chapter 2's deployment surprise) — check the raw output count. (2) Parameter parity: deployed IoU threshold, score threshold, max-detections, and per-class vs class-agnostic NMS versus what eval used — per-class NMS lets near-duplicate class pairs both survive. (3) If NMS is genuinely identical, the model is emitting well-separated duplicates below the IoU threshold — that's an assignment/calibration symptom (Chapter 8): one-to-many training creates several positives per object, and if score doesn't track box quality (weak task alignment), two mediocre boxes both survive ranking. Note why the metric missed it: mAP barely punishes a duplicate (one extra FP among thousands), so add a TIDE-style duplicate-error count to the dashboard — metric design is half the fix. A set-prediction detector (RT-DETR, or YOLOv10's one-to-one deploy head) makes deduplication a learned in-graph behavior: no NMS config to drift between eval and prod, so this incident class disappears — replaced by its own trade: duplicates become a training problem you can't patch with a threshold at 2 a.m.
References
- Girshick et al., R-CNN (2014) / Girshick, Fast R-CNN (2015) / Ren et al., Faster R-CNN (2015) — arXiv:1506.01497
- Lin et al., Feature Pyramid Networks (2016) — arXiv:1612.03144 and Focal Loss (RetinaNet) (2017) — arXiv:1708.02002
- Redmon et al., YOLO (2015) — arXiv:1506.02640; Ultralytics YOLOv8 docs
- Ronneberger et al., U-Net (2015) — arXiv:1505.04597
- He et al., Mask R-CNN (2017) — arXiv:1703.06870
- Carion et al., DETR (2020) — arXiv:2005.12872
- Zhu et al., Deformable DETR (2020) — arXiv:2010.04159; Li et al., DN-DETR (2022) — arXiv:2203.01305
- Zhao et al., RT-DETR: DETRs Beat YOLOs on Real-time Object Detection (2023) — arXiv:2304.08069
- Wang et al., YOLOv9 (2024) — arXiv:2402.13616; Wang et al., YOLOv10: Real-Time End-to-End Object Detection (2024) — arXiv:2405.14458
- Bochkovskiy et al., YOLOv4 (2020) — arXiv:2004.10934 — the mosaic/recipe paper
- Bewley et al., SORT (2016) — arXiv:1602.00763; Wojke et al., DeepSORT (2017) — arXiv:1703.07402
- Zhang et al., ByteTrack (2021) — arXiv:2110.06864
- Luiten et al., HOTA: A Higher Order Metric for Evaluating Multi-Object Tracking (2020) — arXiv:2009.07736
- Bolya et al., TIDE: Identifying Detection Errors (2020) — arXiv:2008.08115 — the error-breakdown tool
- COCO evaluation docs — the mAP fine print
Lab 01: YOLOv8 — Real-Time Object Detection
Architecture Overview
YOLOv8 follows the single-stage detection paradigm: one forward pass produces all detections.
Input (640×640×3)
│
Backbone (CSPDarknet + C2f blocks)
• Extracts multi-scale features: P3 (80×80), P4 (40×40), P5 (20×20)
│
Neck (PAN-FPN)
• Path Aggregation Network: fuses features top-down and bottom-up
• Enables detection at 3 scales simultaneously
│
Head (Decoupled head — separate branches for cls and reg)
• Each scale: 3 anchors (actually anchor-free in YOLOv8!)
• Predicts: [x, y, w, h, cls_scores × 80]
│
Post-processing
• Sigmoid activation on class scores
• DFL (Distribution Focal Loss) for box regression
• NMS per class
Key YOLOv8 Improvements over YOLOv5
| Feature | YOLOv5 | YOLOv8 |
|---|---|---|
| Detection paradigm | Anchor-based | Anchor-free |
| Head | Coupled | Decoupled |
| Box loss | CIoU | DFL + CIoU |
| Backbone | CSPDarknet | C2f (CSP with 2 bottlenecks) |
| Augmentation | Mosaic | Close mosaic at epoch 10 |
Loss Functions
Box Regression: CIoU + DFL
IoU: $IoU = \frac{|B_1 \cap B_2|}{|B_1 \cup B_2|}$
CIoU (Complete IoU): adds aspect ratio and center distance terms:
$$\mathcal{L}_{CIoU} = 1 - IoU + \frac{\rho^2(\mathbf{b}, \mathbf{b}^{gt})}{c^2} + \alpha v$$
where $\rho^2$ = squared center distance, $c^2$ = diagonal of enclosing box, $v$ = aspect ratio consistency term.
DFL (Distribution Focal Loss): instead of predicting a single coordinate value, predict a distribution over discrete values. Allows the model to express uncertainty:
$$\mathcal{L}{DFL} = -\sum{i=y_l}^{y_r} \text{softmax}(s_i) \log(s_i)$$
Classification: Binary Cross-Entropy (not softmax!)
YOLOv8 uses BCE on each class independently — allows multi-label detection (one object can be "cat" and "animal" simultaneously). This is different from a softmax classifier.
Training Best Practices
Custom Dataset Preparation (YOLO format)
dataset/
├── images/
│ ├── train/ [*.jpg]
│ └── val/ [*.jpg]
└── labels/
├── train/ [*.txt] ← one file per image
└── val/ [*.txt]
Each .txt file: one line per object:
<class_id> <x_center> <y_center> <width> <height>
All values normalized to [0, 1] relative to image size.
Training Script
from ultralytics import YOLO
# Fine-tune YOLOv8m on custom data
model = YOLO('yolov8m.pt') # pre-trained on COCO
results = model.train(
data='dataset.yaml',
epochs=100,
imgsz=640,
batch=16,
device=0,
optimizer='AdamW',
lr0=1e-3,
lrf=0.01, # final LR = lr0 × lrf
warmup_epochs=3,
cos_lr=True,
augment=True,
close_mosaic=10, # disable mosaic last 10 epochs (stabilizes training)
patience=50, # early stopping
val=True,
save=True,
)
Transfer Learning Tips
- Don't freeze backbone for small datasets (< 1000 images) — YOLOv8 handles this automatically
- Close mosaic augmentation last 10 epochs — mosaic creates unrealistic objects at boundaries, hurts final mAP
- Use rect=True for variable aspect ratio datasets — reduces padding waste
- Multi-scale training — automatically enabled, trains on ±50% of target size
Evaluation Metrics
mAP@0.5 and mAP@0.5:0.95
mAP@0.5: Mean Average Precision at IoU threshold 0.5
mAP@0.5:0.95: COCO metric — average of mAP at IoU 0.5, 0.55, 0.6, ..., 0.95
Interpretation:
mAP@0.5:0.95 > 0.6 → Excellent (publishable)
mAP@0.5:0.95 > 0.4 → Good (production-ready for many applications)
mAP@0.5:0.95 < 0.2 → Needs more data or different architecture
TensorRT Export for Deployment
from ultralytics import YOLO
model = YOLO('runs/detect/train/weights/best.pt')
# Export to TensorRT with FP16 precision
model.export(
format='engine', # TensorRT .engine file
device=0,
half=True, # FP16 — 2× faster, same accuracy
dynamic=False, # static batch for max performance
imgsz=640,
batch=1, # optimize for real-time (batch=1)
)
# Benchmark
import time
model_rt = YOLO('best.engine')
img = torch.randn(1, 3, 640, 640).cuda()
# Warmup
for _ in range(10): model_rt(img)
# Benchmark
times = []
for _ in range(100):
t = time.perf_counter()
model_rt(img)
times.append(time.perf_counter() - t)
print(f"Latency: {np.mean(times)*1000:.1f}ms ± {np.std(times)*1000:.1f}ms")
Interview Questions
Q: How does YOLOv8 anchor-free detection work? What's the advantage?
A: Instead of predicting offsets relative to predefined anchor boxes, YOLOv8 predicts the distance from each grid cell center to the 4 sides of the bounding box (LTRB format). This eliminates the need to manually design anchor sizes, which is fragile — wrong anchor scales lead to poor detection of unusual aspect ratios. Anchor-free is also simpler to implement and generalize to new datasets.
Q: Why does YOLOv8 use a decoupled head?
A: YOLOv3-v5 used a coupled head: the same feature representation predicted both class scores and box coordinates. Classification requires high semantic information (what is it?) while box regression requires precise spatial information (where exactly?). Decoupling allows each branch to specialize, which improves both tasks. The trade-off is slightly higher parameter count and compute, but the accuracy improvement more than justifies it.
Q: How would you handle detection of very small objects (< 5% of image area)?
A: Several strategies: (1) Train at higher resolution (1280×1280 instead of 640×640) — small objects get more pixels, but compute quadruples; (2) Use SAHI (Slicing Aided Hyper Inference): slice the image into overlapping tiles, run detection on each tile, merge detections with NMS; (3) Use P2 feature map (160×160) in addition to P3/P4/P5 — adds a higher-resolution detection head; (4) Data augmentation: copy-paste small objects into training images, random zoom-in on small object regions.
The 2026 Landscape — Beyond v8
Everything above still works — but v8 is no longer the frontier, and swapping successors in is nearly free. Do it as a lab extension.
Drop-In Successors: YOLO11 and YOLOv10
Same ultralytics API, one-string change (note the naming: yolo11n.pt, not yolov11n.pt):
from ultralytics import YOLO
results = {}
for weights in ['yolov8n.pt', 'yolov10n.pt', 'yolo11n.pt']:
model = YOLO(weights)
model.train(data='dataset.yaml', epochs=100, imgsz=640, batch=16, device=0)
m = model.val()
results[weights] = (m.box.map, m.box.map50) # mAP@0.5:0.95, mAP@0.5
How to read the comparison on your dataset:
- YOLO11n typically edges out v8n on mAP at similar-or-lower latency (better parameter efficiency, same training recipe). Gains of 1–2 mAP are dataset-dependent and sometimes vanish on small custom datasets — measure, don't trust release blogs.
- YOLOv10n trains a one-to-many head for gradient richness but deploys a one-to-one head — so it needs no NMS at inference. Two consequences you can verify with the TensorRT benchmark above: its latency is stable regardless of scene density (NMS cost grows with box count), and the exported graph has no post-processing to misconfigure.
- Re-run the benchmark at both
nandssizes and both 640/960 resolutions — the operating-point table is the deliverable, not a single number.
RT-DETR — the NMS-Free Alternative
from ultralytics import RTDETR
model = RTDETR('rtdetr-l.pt') # same train/val/export API
RT-DETR replaces dense-predict-then-suppress with transformer set prediction: learned object queries, one-to-one matching, duplicates trained away instead of filtered away. Prefer it when: you deploy on server GPUs (its smallest variants are heavier than YOLO-n class), crowded scenes make NMS suppress real neighbors, or the NMS export/threshold-drift surface has bitten you in production. Prefer YOLO when: tight edge/CPU budgets, very small datasets (DETR-class heads want more data and epochs), or you depend on ecosystem depth.
The License Axis
Ultralytics code and its pretrained weights are AGPL-3.0 — network use counts as distribution, so a closed product built on it needs either the Ultralytics commercial license or a move to permissively-licensed detectors (RT-DETR's original Apache-2.0 implementations, D-FINE, RF-DETR-class models — note that using RT-DETR through the ultralytics package is still AGPL-bound). Treat license as a model-selection axis alongside mAP and latency; the full decision breakdown is in the warmup: The YOLO Evolution, v8 → v10/v11 — and What Actually Changed.
Further Reading
- Wang et al., YOLOv10: Real-Time End-to-End Object Detection (2024) — arXiv:2405.14458
- Zhao et al., RT-DETR: DETRs Beat YOLOs on Real-time Object Detection (2023) — arXiv:2304.08069
- Ultralytics model docs — YOLO11/RT-DETR usage and export matrix
Lab 02: Faster R-CNN — Two-Stage Object Detection
Motivation: Why Two-Stage?
Single-stage detectors (YOLO) are fast but sacrifice accuracy on small/dense objects. Two-stage detectors decouple:
- Stage 1 — Region Proposal Network (RPN): "Where could objects be?"
- Stage 2 — RoI Head: "What exactly is this object and where precisely?"
This separation allows specialized optimization of localization vs classification.
Architecture Deep Dive
Input Image (H × W × 3)
│
Backbone (e.g., ResNet-50-FPN)
├─ C1: /2 (stride 2)
├─ C2: /4 (stride 4)
├─ C3: /8 (stride 8)
├─ C4: /16 (stride 16)
└─ C5: /32 (stride 32)
│
FPN (Feature Pyramid Network)
• Top-down pathway + lateral connections
• Produces: P2, P3, P4, P5, P6
• Each Pi resolves objects at a different scale
│
RPN (Region Proposal Network)
• Slides 3×3 conv over each feature map level
• At each location, k=3 aspect ratios × 3 sizes = 9 anchors
• Outputs per anchor: objectness score (fg/bg) + bbox delta
│
RoI Align
• Project each proposal back to feature map coordinates
• Sample 2×2 bilinear interpolation grid in each RoI
• Output: fixed 7×7 feature map per proposal
│
Box Head (FC layers)
├─ Classifier: Softmax over (C+1) classes (background = class 0)
└─ Regressor: 4×C box deltas (class-specific regression)
Region Proposal Network (RPN)
Anchor Generation
For each spatial location $(i, j)$ on a feature map of stride $s$:
- Center: $(i \cdot s + s/2, ; j \cdot s + s/2)$
- Scales: ${32^2, 64^2, 128^2, 256^2, 512^2}$ pixels²
- Aspect ratios: ${1:2, ; 1:1, ; 2:1}$
Total anchors: $H/s \times W/s \times 9$ per feature map level.
RPN Loss
$$\mathcal{L}{RPN} = \frac{1}{N{cls}} \sum_i \mathcal{L}{cls}(p_i, p_i^*) + \lambda \frac{1}{N{reg}} \sum_i p_i^* \mathcal{L}_{reg}(t_i, t_i^*)$$
- $p_i$: predicted objectness probability for anchor $i$
- $p_i^*$: 1 if anchor overlaps GT with IoU > 0.7, 0 if IoU < 0.3
- $t_i$: predicted box parameterization
- $\mathcal{L}_{reg}$: Smooth L1 loss (robust to outliers)
Box Parameterization
$$t_x = (x - x_a) / w_a, \quad t_y = (y - y_a) / h_a$$ $$t_w = \log(w / w_a), \quad t_h = \log(h / h_a)$$
Log for width/height: prevents negative predictions and ensures scale-invariant regression.
Smooth L1 Loss
$$\text{SmoothL1}(x) = \begin{cases} 0.5 x^2 & |x| < 1 \ |x| - 0.5 & \text{otherwise} \end{cases}$$
Advantage over L2: linear for large errors (not dominated by outliers), quadratic for small errors (smooth gradient near 0).
RoI Align vs RoI Pooling
RoI Pooling (Faster R-CNN original):
- Quantizes proposal coordinates to feature map grid
- Causes misalignment: a pixel shift in proposal → different feature
- Hurts small-object detection and instance segmentation
RoI Align (Mask R-CNN):
- No quantization — uses bilinear interpolation
- Divides RoI into fixed-size grid (e.g., 7×7)
- For each cell, samples 4 points with bilinear interpolation
- Eliminates misalignment → crucial for segmentation
$$\text{RoIAlign}(x, y) = \sum_{ij} w_{ij} \cdot \text{feature}(x_i, y_j)$$
where $w_{ij}$ are bilinear interpolation weights.
FPN (Feature Pyramid Network)
Solves scale variation: small objects need high-resolution features, large objects need semantic features.
# Top-down pathway
P5 = conv(C5)
P4 = conv(C4) + upsample(P5) # lateral connection
P3 = conv(C3) + upsample(P4)
P2 = conv(C2) + upsample(P3)
Assignment rule: proposal of area $A$ goes to level $k$: $$k = k_0 + \lfloor \log_2(\sqrt{A} / 224) \rfloor$$
Interview Questions
Q: What's the role of anchor boxes in Faster R-CNN? Are they still needed?
A: Anchors define a prior distribution over object shapes. The RPN predicts offsets from anchors, not absolute coordinates — this makes training easier since the network only needs to learn small corrections. Modern detectors like FCOS and YOLOv8 are anchor-free: they directly predict coordinates from each grid cell. The trade-off: anchor-based requires careful anchor design but is more stable; anchor-free is simpler and generalizes better to unusual aspect ratios.
Q: Why does Faster R-CNN use separate losses for RPN and RoI head?
A: Each stage has different optimization targets. The RPN must learn to identify foreground vs background and roughly localize objects — it needs many examples and a high recall. The RoI head must distinguish 80+ classes precisely. Training them separately with different learning rates allows each to converge optimally. If trained jointly with naive averaging, the RPN loss often dominates.
Q: How does Non-Maximum Suppression reduce redundant proposals in the RPN?
A: After computing ~100K anchor scores, NMS keeps at most 2000 proposals for training (300 at test time). Process: (1) filter anchors with score < threshold (0.7), (2) clip to image boundary, (3) remove very small anchors (< 16px), (4) sort remaining by score, (5) greedily keep anchors with IoU < 0.7 with all previously kept anchors. This reduces 100K → 2000 proposals while maintaining diversity.
Lab 03: U-Net — Semantic Segmentation
What is Semantic Segmentation?
Assign a class label to every pixel in an image (vs detection which predicts bounding boxes).
| Task | Output | Example |
|---|---|---|
| Classification | 1 label per image | "This is a cat" |
| Detection | Bounding boxes | "Cat at [x1,y1,x2,y2]" |
| Semantic segmentation | Label per pixel | Each pixel = car/road/sky |
| Instance segmentation | Label+ID per pixel | Car #1, Car #2, background |
U-Net Architecture
Originally designed for biomedical image segmentation (2015). Now used universally.
Input (572×572×1) — or any (H×W×C)
│
Encoder (Contracting Path)
┌─────────────────────────────────────────┐
│ Block 1: 3×3 conv → 3×3 conv → MaxPool │ 64 channels → skip₁
│ Block 2: 3×3 conv → 3×3 conv → MaxPool │ 128 channels → skip₂
│ Block 3: 3×3 conv → 3×3 conv → MaxPool │ 256 channels → skip₃
│ Block 4: 3×3 conv → 3×3 conv → MaxPool │ 512 channels → skip₄
└─────────────────────────────────────────┘
│
Bottleneck: 3×3 conv → 3×3 conv │ 1024 channels
│
Decoder (Expanding Path)
┌───────────────────────────────────────────────────────────┐
│ Upsample 2× → concat(skip₄) → 3×3 conv → 3×3 conv │ 512 ch
│ Upsample 2× → concat(skip₃) → 3×3 conv → 3×3 conv │ 256 ch
│ Upsample 2× → concat(skip₂) → 3×3 conv → 3×3 conv │ 128 ch
│ Upsample 2× → concat(skip₁) → 3×3 conv → 3×3 conv │ 64 ch
└───────────────────────────────────────────────────────────┘
│
1×1 conv → N_classes channels → Softmax per pixel
Why Skip Connections?
Downsampling loses spatial information. Upsampling alone produces blurry boundaries. Skip connections bring back fine-grained details from the encoder.
- Encoder features: semantic information ("this region is a tumor")
- Skip connection: spatial details ("exact boundary of the tumor")
- Combined: precise, semantically-aware segmentation
Loss Functions
Binary Cross-Entropy (BCE) for binary segmentation
$$\mathcal{L}{BCE} = -\frac{1}{N} \sum{i} [y_i \log \hat{y}_i + (1-y_i) \log(1-\hat{y}_i)]$$
Problem: Massive class imbalance. In medical imaging, foreground may be 5% of pixels. BCE optimizes pixel accuracy → model learns to predict "all background" and achieves 95% accuracy.
Dice Loss
Based on the Dice coefficient / F1 score:
$$\text{Dice} = \frac{2 |A \cap B|}{|A| + |B|} = \frac{2 \sum_{i} p_i g_i}{\sum_i p_i + \sum_i g_i}$$
$$\mathcal{L}_{Dice} = 1 - \text{Dice}$$
Why it handles imbalance: Dice loss is normalized by both prediction size and GT size. Even if the foreground is 5% of pixels, a correct prediction is fully rewarded.
Combined Loss (standard practice)
$$\mathcal{L} = \mathcal{L}{Dice} + \mathcal{L}{BCE}$$
This combines Dice (handles imbalance) with BCE (provides pointwise gradients).
Focal Loss variant for segmentation
Focal Dice: downweight easy pixels (confident background) to focus on hard positives.
Evaluation Metrics
Pixel Accuracy
$$\text{Acc} = \frac{\text{Correct pixels}}{\text{Total pixels}}$$
Misleading for imbalanced classes (95% background → 95% acc trivially).
Mean IoU (mIoU)
$$\text{mIoU} = \frac{1}{C} \sum_{c=0}^{C-1} \frac{TP_c}{TP_c + FP_c + FN_c}$$
Gold standard for segmentation. Computes IoU per class, then averages. Penalizes both over- and under-segmentation equally.
Dice Score
$$\text{Dice} = \frac{2 TP}{2 TP + FP + FN}$$
Identical to F1-score. Popular in medical imaging (equivalent to mIoU for binary case via mathematical relationship).
Interview Questions
Q: When would you use Dice loss vs BCE for segmentation?
A: For imbalanced datasets (medical imaging, defect detection where lesion < 5% of pixels), always use Dice or Dice+BCE. Dice normalizes by prediction size, so even rare classes get proper gradients. For balanced segmentation (outdoor scenes like Cityscapes where all classes have similar frequencies), BCE or cross-entropy works fine. In practice, Dice+BCE combined consistently outperforms either alone — BCE provides dense gradients, Dice corrects for imbalance.
Q: What's the difference between transposed convolution and bilinear upsampling + conv?
A: Transposed convolution learns upsampling weights (8× parameters for upsampling), which can produce "checkerboard artifacts" from uneven gradient overlap. Bilinear upsampling is parameter-free and smooth, followed by a regular conv for learned feature processing. The bilinear+conv approach is now preferred in most architectures (including U-Net++ and modern variants) because it avoids artifacts and is more stable to train. Memory footprint is also lower.
Q: How would you adapt U-Net for 3D medical images (CT/MRI volumes)?
A: Replace all 2D operations with 3D equivalents: nn.Conv2d→nn.Conv3d, nn.MaxPool2d→nn.MaxPool3d, nn.BatchNorm2d→nn.BatchNorm3d. The challenge is memory: a 512³ volume with 64 channels at float32 = 8GB. Solutions: (1) patch-based training (crop 128³ overlapping patches, stitch at test time); (2) mixed 2D+3D (2D encoder, 3D decoder for memory efficiency); (3) anisotropic convolutions for data with non-cubic voxels (CT often 0.5mm in-plane, 2mm slice thickness).
Lab 04: Mask R-CNN — Instance Segmentation
Overview
Mask R-CNN extends Faster R-CNN by adding a mask branch: a small FCN (Fully Convolutional Network) that predicts a binary segmentation mask for each detected object independently.
Faster R-CNN Head
├── Box classifier (C+1 classes)
├── Box regressor (C×4 deltas)
└── [NEW] Mask head: FCN → 28×28 binary mask per class
The key insight: decouple mask prediction from class prediction. The mask head predicts K masks (one per class) for each proposal, but only the mask corresponding to the predicted class is used at inference.
Architecture
Input Image
│
FPN Backbone (ResNet-50-FPN or ResNet-101-FPN)
│
RPN → Region Proposals
│
RoI Align (7×7 for box head, 14×14 for mask head)
│
┌──────────────────────────────┐
│ Box Head (FC layers) │ → class scores + box deltas
└──────────────────────────────┘
┌──────────────────────────────┐
│ Mask Head (FCN) │ → K × 28×28 masks
│ 4× (256-ch conv3×3 → ReLU) │
│ Transposed conv 2× upsample │
│ 1×1 conv → K binary masks │
└──────────────────────────────┘
Why RoI Align (not RoI Pooling) is critical for masks
For bounding boxes, a 1-2 pixel misalignment is tolerable. For segmentation masks at 28×28 resolution, even half-pixel misalignment causes boundary artifacts. RoI Align's exact bilinear interpolation is non-negotiable here.
Mask Prediction Details
The mask head predicts logits for K classes, size 28×28 per RoI.
Training: For each proposal:
- Only train the mask branch for GT-matched positive proposals (IoU > 0.5)
- Use the GT class to select which mask channel to compute loss on
- Loss: sigmoid BCE on 28×28 binary mask
Inference:
- Select mask channel corresponding to predicted class
- Apply sigmoid → binary threshold at 0.5
- Resize from 28×28 back to proposal bounding box size
- Paste into full image canvas
Semantic vs Instance Segmentation
| Semantic | Instance | |
|---|---|---|
| Distinguishes instances? | No | Yes |
| Same-class objects | Same label | Different IDs |
| Handles overlap? | No | Yes |
| Output | H×W label map | N masks per image |
| Typical architecture | FCN, U-Net, DeepLab | Mask R-CNN, SOLO |
Panoptic segmentation = semantic + instance combined (every pixel labeled + every instance identified).
Loss Function
$$\mathcal{L}{total} = \mathcal{L}{rpn_cls} + \mathcal{L}{rpn_reg} + \mathcal{L}{cls} + \mathcal{L}{reg} + \mathcal{L}{mask}$$
The mask loss $\mathcal{L}_{mask}$ is sigmoid BCE (not softmax CE):
- Each of the K masks is predicted independently
- No competition between classes forces the network to learn class-specific masks
- During training: only use mask for GT class → no noise from other classes
Modern Variants
| Model | Improvement | Speed |
|---|---|---|
| Mask R-CNN | Baseline | ~5 FPS (ResNet-50) |
| SOLO | No RoIs, direct per-position masks | ~10 FPS |
| SOLOv2 | Dynamic convolutions | ~15 FPS |
| PointRend | Render masks at uncertain boundary points | +1-2 mAP |
| Mask2Former | Transformer-based, universal segmentation | SOTA |
Interview Questions
Q: Why does Mask R-CNN predict K masks (one per class) instead of 1 mask with K classes?
A: Using K independent binary masks decouples mask prediction from classification. Each binary sigmoid mask doesn't need to "compete" with other classes — it only asks "is this pixel part of class k?". Using softmax would force the model to classify every pixel even within the mask head, introducing entanglement. The selected mask (at inference: the predicted class's mask) will be cleaner and more accurate. This design choice yielded a 3+ point mAP improvement over single-channel mask prediction.
Q: How does Mask R-CNN handle overlapping instances?
A: Each proposal generates an independent mask crop. The model processes them separately, so masks can overlap in image coordinates. At output, overlapping masks are handled by confidence — typically the highest-confidence instance "wins" each pixel, or both masks are kept and the caller resolves the overlap (e.g., rendering order by confidence). Occlusion handling for heavily overlapping objects (like stacked items) remains a weakness; SOLO-based methods handle it better via position-based instance separation.
Q: What is the typical training procedure for Mask R-CNN on a custom dataset?
A: (1) Start from COCO-pretrained weights (all backbone + FPN + RPN + heads pretrained); (2) Fine-tune all components with discriminative LRs (backbone 0.1× LR, heads 1× LR); (3) Use 1× or 3× training schedule (12 or 36 epochs on COCO); (4) Data augmentation: horizontal flip, multi-scale train (480-800px shorter edge), optional mosaic. For small datasets (< 1000 images), freeze BatchNorm layers (model.backbone.body.freeze_bn()) and use batch size ≥ 2 (BN stat accuracy degrades with batch=1).
Phase 06: State-of-the-Art Vision Models
Weeks 13-14 | 4 Labs
The modern CV engineer must understand the architectural innovations that power GPT-4V, SAM, CLIP, and DINO. This phase builds ViT, CLIP, DINO, and a mini-SAM from scratch — then maps the 2024–2026 layer on top: promptable segmentation (SAM/SAM2), open-vocabulary detection (OWL-ViT, Grounding DINO), NMS-free set-prediction detectors (DETR/RT-DETR), frozen DINOv2/v3 backbones, and vision-language models.
Why SOTA Models?
- Vision Transformers (ViT): replaced CNNs as backbone in most SOTA systems
- CLIP: foundation for zero-shot recognition, image search, VLMs
- Self-supervised learning (DINO/DINOv2/v3): reduce label dependency by 10-100x; frozen features + light heads is the 2026 default
- SAM/SAM2 + open-vocabulary detection: promptable masks and text-defined classes — the standard auto-labeling stack (Grounded-SAM) and the data-engine mindset
- DETR/RT-DETR: detection as set prediction — no NMS, deterministic latency, real-time DETRs competing with YOLO
- VLMs (LLaVA-class, Qwen2.5-VL-class): when perception needs reasoning — plus the cost/verifiability framework for choosing them over pipelines
- Every top-tier role (Google DeepMind, Meta AI, OpenAI) expects fluency here
Lab Structure
| Lab | Topic | Key Concepts |
|---|---|---|
| lab-01-vision-transformer | ViT from scratch | Patch embedding, positional encoding, TransformerEncoder, attention maps |
| lab-02-clip-contrastive | CLIP-style contrastive learning | InfoNCE loss, image-text alignment, zero-shot classification |
| lab-03-dino-self-supervised | DINO-style self-supervised | Student-teacher with EMA, multi-crop, centering + sharpening |
| lab-04-promptable-segmentation | Mini-SAM from scratch | Point-prompt encoder, two-way-attention mask decoder, multi-mask output + IoU-prediction head |
Key Equations
Scaled Dot-Product Attention
$$\text{Attention}(Q,K,V) = \text{softmax}!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V$$
InfoNCE Loss (CLIP)
$$\mathcal{L} = -\frac{1}{N}\sum_{i=1}^{N} \log \frac{\exp(\text{sim}(z_i^I, z_i^T)/\tau)}{\sum_{j=1}^{N}\exp(\text{sim}(z_i^I, z_j^T)/\tau)}$$
DINO EMA Update (teacher)
$$\theta_t \leftarrow \lambda,\theta_t + (1-\lambda),\theta_s$$
DETR Bipartite Matching Cost (per prediction–GT pair)
$$\mathcal{C}{ij} = -\hat{p}i(c_j) + \lambda{L1},\lVert \hat{b}i - b_j \rVert_1 + \lambda{giou},\mathcal{L}{giou}(\hat{b}_i, b_j)$$
Architectural Comparison
| Model | Backbone | Pre-training | Zero-shot? | Key Innovation |
|---|---|---|---|---|
| ResNet | CNN | Supervised | No | Residual connections |
| ViT-B/16 | Transformer | Supervised (JFT-300M) | No | Patches as tokens |
| CLIP | ViT + Text Enc. | Contrastive (400M pairs) | Yes | Image-text alignment |
| DINO | ViT | Self-supervised | No (but great features) | Student-teacher + EMA |
| DINOv2/v3 | ViT (+ registers) | Self-supervised (curated LVD) | No (frozen-feature default) | DINO + iBOT objectives, register tokens, Gram anchoring |
| SAM / SAM2 | ViT-H (+ memory) | SA-1B / SA-V data engine | Yes | Promptable segmentation; streaming memory for video |
| OWL-ViT / Grounding DINO | ViT / DETR-family + text | Image-text + detection | Yes (open-vocab) | Text as the detection label space |
| DETR / RT-DETR | CNN + Transformer | Supervised | No | Set prediction + Hungarian matching — no NMS; real-time hybrid encoder |
| LLaVA / Qwen2.5-VL-class | CLIP tower + LLM | Alignment + instruction tuning | Yes (open-ended) | Visual tokens in an LLM — perception with reasoning |
Warmup Guide — SOTA Models: From ViT, CLIP, DINO to the 2026 Stack
Zero-to-expert primer for Phase 06: the transformer era of vision — ViT's patches-as-tokens move, CLIP's language-supervised embeddings, and DINO's self-supervised features — then the 2024–2026 layer built on top of them: promptable segmentation (SAM/SAM2), open-vocabulary detection, NMS-free set-prediction detectors (DETR/RT-DETR), frozen DINOv2/v3 backbones, and vision-language models — and what each changes about how you build CV systems.
Table of Contents
- Chapter 1: Why Vision Went Transformer
- Chapter 2: ViT — The Architecture, Honestly Assessed
- Chapter 3: Attention for CV Engineers — the Minimum Core
- Chapter 4: CLIP — Supervision from Language
- Chapter 5: What CLIP Changes in Practice
- Chapter 6: DINO — Supervision from Agreement
- Chapter 7: SAM and SAM2 — Promptable Segmentation
- Chapter 8: Open-Vocabulary Detection — Text as the Label Space
- Chapter 9: DETR and RT-DETR — Detection as Set Prediction
- Chapter 10: DINOv2, DINOv3 and the Frozen-Backbone Default
- Chapter 11: Vision-Language Models — When Perception Needs Reasoning
- Chapter 12: Generative Models in a Perception Stack
- Chapter 13: Choosing a Backbone in the Foundation-Model Era
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Why Vision Went Transformer
CNNs encode inductive biases — locality (3×3 kernels) and translation equivariance (weight sharing) — that act as built-in assumptions about images. Assumptions are training wheels: they help enormously when data is scarce and constrain when data is abundant. The transformer (Phase 04 of the LLM track derives it fully) makes almost no spatial assumptions: every patch can attend to every patch from layer 1.
The empirical law that decided the era (the ViT paper's core finding): below ~ImageNet-1k-scale data, CNNs win; with large-scale pretraining (JFT/LAION-class), ViTs match then exceed them — the bias was worth less than the flexibility once data paid for the difference. Combined with the transformer's scaling behavior and its unification bonus (one architecture for text, vision, audio → multimodal models become plumbing rather than research — Ch. 4), the field moved. Your CNN knowledge (Phase 03/05) is not obsolete: it remains the right answer at small data and tight latency — Chapter 13 is the decision framework.
Chapter 2: ViT — The Architecture, Honestly Assessed
The whole trick is the input adapter: an image becomes a token sequence.
- Split the image into non-overlapping P×P patches (16×16 standard → 224² image = 196 tokens); linearly project each flattened patch to d dimensions — implemented as a Conv2d with kernel = stride = P (one fused op, mathematically identical).
- Prepend a learnable [CLS] token (the sequence-level summary slot); add learned position embeddings (without them, attention is permutation-invariant and the image is a bag of patches).
- Run a standard pre-norm transformer encoder (bidirectional attention — no causal mask: vision has no autoregressive order); classify from the CLS token (or mean of patch tokens — both work).
What to know beyond the recipe:
- Resolution changes break position embeddings (more patches than trained positions) — handled by 2-D interpolation of the embedding grid; fine-tuning at higher resolution than pretraining is standard and this is the mechanism.
- Cost scales quadratically in patch count: halving P quadruples tokens and ~16×'s attention cost — patch size is the resolution/compute dial. Hierarchical variants (Swin: local windowed attention with shifts, producing CNN-like multi-scale maps) exist precisely to feed dense-prediction heads (Phase 05's FPN world) efficiently.
- DeiT's contribution: with strong augmentation + distillation, ViTs train respectably on ImageNet-1k alone — the data-hunger story softened by recipe.
Chapter 3: Attention for CV Engineers — the Minimum Core
The self-attention mechanics in one paragraph (full derivation in the LLM track's Phase 04 WARMUP — read it if any step feels unfamiliar): each token emits query/key/value projections; attention weights = softmax(QKᵀ/√d); output = weights · V; multiple heads run in parallel subspaces and concatenate. For vision specifically: attention maps are interpretable spatial objects — the CLS token's attention over patch tokens is a saliency map you will actually plot (DINO's famous result, Ch. 6), and "which heads look where" (local vs global specialization across depth) is a real diagnostic. The engineering note: 196–1024 tokens is small by LLM standards — vision attention is rarely the memory crisis it is in language; resolution (token count) is your knob when it becomes one.
Chapter 4: CLIP — Supervision from Language
The supervision bottleneck: ImageNet's 1.28M images took years to label over 1000 fixed classes. The internet has billions of (image, caption) pairs — noisy, free, and unboundedly descriptive. CLIP's move: train image and text encoders to agree.
- Architecture: an image encoder (ViT) and a text encoder (transformer) each map to a shared embedding space; normalize; similarity = cosine.
- The contrastive objective (InfoNCE): in a batch of N pairs, the N×N similarity matrix's diagonal entries (true pairs) should beat their row and column competitors — symmetric cross-entropy over the matrix, with a learned temperature scaling the logits. Large batches matter (more negatives = harder discrimination = better geometry) — this is word2vec's negative-sampling idea (LLM track Phase 02) industrialized.
- Zero-shot classification, the headline capability: embed prompts ("a photo of a {class}") for your label set; classify an image by nearest text embedding. No training on your classes at all — competitive with supervised baselines on many distributions, and prompt wording measurably matters (prompt ensembling is the standard boost).
Chapter 5: What CLIP Changes in Practice
The capability list a working CV engineer actually uses:
- Open-vocabulary everything: classes defined at inference time by text — classification without retraining (Ch. 4), and open-vocabulary detection/ segmentation when grafted onto Phase 05 architectures (OWL-ViT, Grounding-DINO-class systems — Chapter 8 opens up their mechanisms).
- Image-text retrieval: both directions, one index — the engine of semantic photo search and dataset curation (find label errors by searching "mislabeled-looking X").
- The vision tower of multimodal LLMs: LLaVA-class assistants are CLIP's image encoder + a projector + an LLM (built hands-on in the model-accuracy track's Phase 02 lab-05) — your CLIP understanding is the entry ticket to VLM work (Chapter 11 dissects the anatomy).
- Honest limitations: CLIP is weak at counting, spatial relations ("left of"), fine-grained text rendering, and inherits web-data biases (evaluate before deploying in anything consequential — the Phase 02/08 evaluation ethics apply); zero-shot underperforms a fine-tuned model on specialized domains (medical, industrial) — it's a starting point and a fallback, not a universal answer.
Chapter 6: DINO — Supervision from Agreement
The third supervision source: none at all. Self-supervised learning makes the data its own teacher; DINO is the vision instance whose features became the field's workhorse:
- Mechanism in brief (the full anatomy — centering vs sharpening collapse analysis, EMA teacher, multi-crop — is implemented and tested in the model-accuracy track's lab-04; this track's lab focuses on using and probing it): a student ViT sees local+global crops, a momentum-EMA teacher sees global crops, and the student learns to predict the teacher's output distribution — agreement across views forces representations that capture what the object is rather than where the crop was.
- Why CV engineers care — the emergent properties: (1) CLS attention maps segment objects with zero segmentation labels (the famous visualization, and a real weak-segmentation tool); (2) frozen DINO/DINOv2 features are superb for k-NN and linear-probe classification (label-efficient transfer: tiny labeled sets go far); (3) dense features match across images (correspondence, retrieval-by-part) — DINOv2 features are the current default "just give me good visual features" answer (Chapter 10: what v2 changed, and why v3 doubles down).
- The practical pattern this enables: pretrain-free pipelines — frozen foundation features + a small head trained on dozens-to-hundreds of labels, evaluated honestly (Phase 02 discipline) — often beating full fine-tunes of smaller supervised backbones at a fraction of the cost. This is the lab's experiment.
Chapter 7: SAM and SAM2 — Promptable Segmentation
Phase 05's segmentation models answer one fixed question — "which pixels belong to class k?" — and only for classes they were trained on. The Segment Anything Model (SAM, 2023) reframes segmentation as a promptable task: given an image and a prompt — a click, a box, a rough mask — return the mask of whatever the prompt indicates. Recognize the move: it is CLIP's move applied to segmentation. CLIP freed classification from a fixed label set by putting classes into text; SAM frees segmentation from a fixed label set by putting what to segment into an interaction.
The architecture is an asymmetric split, and the split IS the engineering:
- Image encoder (heavy): a ViT-H (~0.6B params) run once per image, producing a dense 64×64 embedding grid from a 1024² input. 95%+ of total compute lives here — and it amortizes over every prompt on that image.
- Prompt encoder (trivial): points and boxes become positional encodings plus learned type embeddings (foreground point / background point / box corner); rough masks pass through a small conv stack. Microseconds of work.
- Mask decoder (light): two transformer layers of two-way attention over a handful of tokens — the prompt tokens plus a few learned output-mask tokens (CLS-style summary slots, one per candidate mask). Each layer runs three moves: (1) self-attention among the tokens; (2) tokens→image cross-attention — the tokens read the image ("what is under and around my click?"); (3) image→tokens cross-attention — the image embedding is updated with prompt information ("pixels, here is what is being asked of you"). The reverse direction is the "two-way": image features become prompt-conditioned instead of static, which is why a two-layer decoder suffices where a one-way design would need depth. Mask tokens are then dotted against upscaled image features to produce masks — fast enough (~ms-scale) for interactive dragging.
Ambiguity is handled structurally, not ignored. A click on a shirt could mean shirt, person, or person-plus-backpack. SAM emits three masks per prompt (roughly whole/part/subpart granularities) plus an IoU-prediction head — a small MLP regressing each candidate's expected IoU with the (unknown) intended mask — whose scores rank the candidates. During training, only the lowest-loss candidate receives gradients, so the three heads specialize instead of averaging into mush. Downstream code keeps the top-ranked mask, or all three when a human is choosing.
The data engine is the deep lesson. SA-1B (11M images, 1.1B masks) was not labeled and then a model trained — model and dataset grew in a flywheel: (1) assisted-manual: annotators correct an early SAM's proposals, ~6× faster than freehand polygons; (2) semi-automatic: SAM pre-fills confident masks, humans add what it missed (buys diversity); (3) fully automatic: prompt a grid of points, keep masks that are confident and stable under threshold perturbation — the stage that produced 99%+ of the final masks. Retrain between stages. The architecture is a good paper; model-assisted annotation loops are the transferable asset — you will rebuild this flywheel at small scale on your own data (Ch. 8 shows the standard stack for it).
SAM2 (2024) extends the same interface to video: prompt on one frame, get the object segmented and tracked through the clip. The new mechanism is streaming memory: each frame is encoded once; before decoding, a memory-attention module cross-attends the current frame's features to a memory bank holding compact encodings of recent frames' predictions plus every prompted frame. Object identity is carried by attention over stored (feature, mask) memories rather than a bolted-on tracker — and re-prompting any frame corrects drift and refreshes the bank. Images are treated as one-frame videos (one model for both), ~6× faster than SAM per frame.
Production reality:
- Where it earns its keep: interactive annotation tooling (one click replaces a 20-vertex polygon — labeling cost drops an order of magnitude, which compounds with the data-engine lesson above); auto-labeling with box prompts fed from a detector (Ch. 8); zero-shot mask generation feeding classical pipelines — mask → measure/crop/remove-background, Phase 01 geometry meeting 2026 features.
- Honest limits: SAM has no semantics — it produces masks without labels; pairing it with something that names things (Ch. 8, Ch. 11) is your job. The encoder is heavy: interactivity only works because of the run-once split, and dense grid-prompt mask generation over large datasets is real GPU money. Edge deployment goes through distilled variants: MobileSAM (distill the ViT-H encoder into a tiny ViT by regressing SAM's image embeddings; decoder reused unchanged), EfficientSAM (pretrain a light encoder to reconstruct SAM features via masked-image modeling, then fine-tune on SA-1B).
Chapter 8: Open-Vocabulary Detection — Text as the Label Space
A Phase-05 detector's classification head is a fixed matrix: N classes chosen at training time, scores over exactly those N forever after. A new class means relabel, retrain, redeploy. Plenty of 2026 products cannot live with that: retail catalogs with weekly SKUs, content moderation chasing novel objects, robotics following "pick up the charger cable". Open-vocabulary detection makes text the label space — classes are strings supplied at inference.
OWL-ViT is the minimal graft: take a CLIP-style dual encoder, remove the image encoder's pooling so every output patch token survives, and put two small heads on each token — one predicting a box, one an embedding for whatever object that token represents. Classification is cosine similarity between each object embedding and the text embeddings of your runtime queries — the zero-shot trick of Ch. 4 pushed down from image level to per-object level. Detection-data fine-tuning teaches localization while CLIP's pretraining supplies the open vocabulary. Clean and simple; its localization is the weaker half versus DETR-family designs.
Grounding DINO is the heavyweight: a DETR-family detector (Ch. 9 derives object queries from first principles; short version — a fixed set of learned query vectors, each responsible for finding one object) with text wired into every stage. What talks to what: (1) a feature enhancer cross-attends image features and text features in both directions early — the picture learns what is being asked, the words learn what is visible; (2) language-guided query selection initializes decoder queries from the image locations most similar to the text, so queries start on plausible objects; (3) a cross-modality decoder has every query attend to both image features and text features in each layer, and final classification is query–text similarity. The payoff over OWL-ViT is phrase grounding: "the leftmost red mug", not just "mug".
The composition pattern — Grounded-SAM: text → Grounding DINO boxes → boxes as SAM prompts → labeled instance masks, with zero task-specific training. This is the de-facto 2024–2026 auto-labeling stack: run it offline over your unlabeled pool with your ontology as prompts, QA-sample the output (foundation models make confident mistakes — Phase 02 evaluation discipline is non-negotiable here), then distill: train a YOLO/RT-DETR-class small model on the generated labels. The deployed artifact stays small, fast, and measurable; the foundation models were labeling leverage, not the product. The cost framing that sells it: pay the big models once per dataset offline, instead of forever per frame online.
Chapter 9: DETR and RT-DETR — Detection as Set Prediction
Phase-05 detectors treat detection as dense classification: score thousands of anchors/grid cells, then deduplicate with NMS. DETR (2020) restates the problem as set prediction — the model outputs a fixed-size set of detections directly:
- Object queries: N learned vectors (N=100 in the paper), each a slot whose job is "find one object or declare nothing". A CNN backbone + transformer encoder produce image features; in the decoder, queries self-attend (coordinating — "you take that one, I'll take this one") and cross-attend the image features; small MLPs map each query to a class and a box. No anchors, no grid — boxes are direct regressions.
- Hungarian (bipartite) matching: predictions form a set, ground truth forms a set. Compute a matching cost for every (prediction, GT) pair — class probability plus L1 and generalized-IoU box terms — and solve for the one-to-one assignment minimizing total cost (the Hungarian algorithm: optimal assignment in polynomial time). The loss is applied only through that matching; unmatched predictions are pushed to a "no object" class.
- Why NMS disappears: one-to-one matching means two queries claiming the same object guarantees one of them eats loss — duplicates are penalized during training, so the model learns not to produce them. NMS existed because dense detectors are trained many-to-one and need a heuristic to dedup afterwards. The operational wins are concrete: no confidence-threshold/IoU-threshold pair to tune per dataset (a quiet production failure source — the thresholds that win on one distribution lose on another, and crowded scenes force painful trade-offs); a fully end-to-end differentiable graph with no non-differentiable post-op (clean ONNX/TensorRT export, no NMS plugin); and deterministic latency — NMS runtime varies with box count, a set head is fixed-shape every frame.
The catch was convergence: original DETR needed ~500 epochs against a Faster R-CNN's ~12–36. Two root causes: every query cross-attends all pixels uniformly at init and takes ages to learn to focus; and bipartite matching is unstable early — assignments flip between epochs, so gradients thrash. The fixes that made DETR practical:
- Deformable attention: each query attends to only K (e.g., 4) sample points whose offsets and weights are predicted from the query itself, around a learned reference point — attention becomes sparse and spatially localized. ~10× fewer epochs, and multi-scale feature maps become affordable (dense global attention over high-res maps was prohibitive).
- Denoising training (DN-DETR-style, one line): feed noised copies of ground-truth boxes as extra decoder queries whose task is reconstructing the originals — a matching-free auxiliary task that stabilizes and accelerates training.
RT-DETR (2023) is the first DETR to beat YOLO-class models on the real-time accuracy/latency curve. Two moves: a hybrid encoder — full self-attention only on the highest-level (smallest, most semantic) feature map where token count is tiny, with cheap CNN-style top-down/bottom-up fusion handling the other scales — removing the encoder bottleneck that made previous DETRs slow; and IoU-aware query selection — decoder queries are initialized from encoder tokens scored for both classification and localization quality, so the decoder starts near real objects instead of from blank slates. Result: ~53 AP at 100+ FPS (T4, TensorRT) at R50 scale, ahead of the contemporaneous YOLOs.
Positioned honestly against YOLO: RT-DETR wins mostly at S/M/L scales on GPU with TensorRT and wherever NMS-free matters (crowded scenes, export simplicity, latency determinism). YOLO keeps the deeper ecosystem (edge/tiny variants, export tooling, community recipes), and convolutions still quantize to INT8 more gracefully than attention. And note the convergent evolution: YOLOv10 dropped NMS too, via a one-to-one matching head trained alongside the classic one-to-many head — the set-prediction insight arriving in CNN land. The deep YOLO lineage belongs to phase-05's WARMUP; the short decision rule: edge/CPU/tiny → YOLO-class; GPU serving where NMS is a liability → RT-DETR; then measure both on your data, not the COCO table.
Chapter 10: DINOv2, DINOv3 and the Frozen-Backbone Default
DINO proved agreement-across-views yields good features (Ch. 6). DINOv2 (2023) is what turned that result into infrastructure — three changes:
- Curated data (LVD-142M): not "scrape more" but curate — start from ~1.2B web images, deduplicate, then build the corpus by retrieval: embed everything and pull nearest neighbors of curated seed datasets. Self-supervised learning on data selected to resemble the evaluation domains. Same lesson as SA-1B from the other direction: the data pipeline, not the loss function, is where the win lives.
- Two objectives combined: the image-level DINO objective (student matches an EMA teacher's output distribution across global/local crops — Ch. 6's mechanism) plus a patch-level iBOT-style masked-token objective: mask random patches in the student's input and train it to predict the teacher's representation of those patches — BERT's masked-LM move with teacher features as targets instead of a word vocabulary. Image-level buys global semantics; patch-level buys dense features that are locally correct — what segmentation, depth, and correspondence heads actually consume.
- KoLeo regularizer (one line): penalizes tiny nearest-neighbor distances within a batch, spreading embeddings uniformly over the sphere — retrieval quality's quiet friend.
The register-tokens story — a genuinely instructive internals find. Large ViTs trained long enough develop attention artifacts: a few background patch tokens with outlier norms that soak up attention, polluting the pretty maps of Ch. 3 and degrading dense features. The 2023 investigation ("Vision Transformers Need Registers") found the cause: the model needs somewhere to perform and store global computation, and with every token tied to a patch, it hijacks low-information patches as scratch space — overwriting their local content (hence broken dense features there) and marking them with huge norms. The fix is almost comic: append a few extra learned tokens — registers — that participate in attention but are discarded at output. The freeloading computation moves into them; artifacts vanish; dense predictions and attention maps clean up. The lesson underneath: architectures silently repurpose capacity, and the attention-map-plotting habit is how you catch them doing it.
DINOv3 (2025) pushes scale (ViT-7B, ~1.7B curated images) and fixes the failure mode scale exposed: over long training, global features keep improving while dense features quietly degrade. Its answer is Gram anchoring: constrain the Gram matrix of the student's patch features — the patch-to-patch similarity structure — to stay close to that of an earlier checkpoint whose dense features were still clean. Global semantics keep improving; local geometry stops eroding. Result: one frozen backbone competitive with specialized models across dense tasks.
The practical consequence — frozen is the default starting point. For classification, segmentation, and depth: frozen DINOv2/v3 features + a linear head or light decoder, before anything else. It is the cheapest experiment you can run — embeddings cached once, heads train in minutes on a laptop — and one backbone serves many heads, amortizing the encoder across products. Fine-tuning the backbone is the escalation you take when the frozen baseline measurably falls short (extreme domain gap: medical, thermal, microscopy), not the reflex you start with.
Chapter 11: Vision-Language Models — When Perception Needs Reasoning
Everything above emits fixed types: labels, boxes, masks, embeddings. Some products need answers: "read this invoice and return the line items", "why does this listing photo violate policy?". A vision-language model (VLM) is a vision encoder married to an LLM so that outputs are text conditioned on pixels.
LLaVA-class anatomy from first principles: (1) a vision tower — a CLIP-class ViT producing patch embeddings; (2) a projector — an MLP mapping each patch embedding into the LLM's token-embedding space, so visual patches become pseudo-words the LLM ingests alongside real ones; (3) the LLM decodes the answer autoregressively, attending to visual tokens and prompt tokens alike. Training is two cheap-ish stages: projector alignment on image–caption pairs (tower and LLM frozen; only the projector learns to speak the LLM's embedding dialect), then instruction tuning on (image, instruction, response) data — projector plus LLM — teaching it to follow tasks rather than merely caption. The 2023 surprise was that this blunt gluing works at all; everything since is refinement.
What changed 2024–2026:
- Native/any-resolution tiling: a fixed 336² input turns a document into a postage stamp — OCR and small objects are unreadable because the information is gone before the model starts. Fix: split the image into tiles at (near-)native resolution, encode each tile, concatenate the visual tokens, plus one downscaled global thumbnail for overall layout. Token count now scales with resolution — you pay for legibility in context length — and this, more than anything, is why modern VLMs actually read documents.
- Grounding: VLMs trained to emit boxes/points as text tokens (coordinates in the output vocabulary), so "where is the leak?" returns a localized answer — a bridge between chat and detection; verify against GT before trusting it.
- Video: sampled frames become visual tokens (with compression tricks); long video is fundamentally a token-budget problem.
- Open-weight families at production quality — one line each: Qwen2.5-VL-class shows open weights matching closed models on OCR, document parsing, and grounding, with native dynamic resolution; PaliGemma-class shows the transfer-first recipe — a small (~3B) base built to be fine-tuned, beating bigger generalists on your specific task; Llama-vision-class shows vision grafted onto a flagship LLM via cross-attention adapter layers, leaving the text weights untouched.
The decision framework — VLM vs classical pipeline (the actual CV-engineer skill):
- Per-image cost/latency: a detector runs in milliseconds at fractions of a cent per thousand images; a VLM pushes hundreds-to-thousands of tokens through a multi-billion-parameter decoder — orders of magnitude more per image, seconds not milliseconds.
- Verifiability: boxes and confidences are measurable with mAP against ground truth (Phase 02 machinery); free text is far harder to evaluate — and hallucination is worst precisely at counting and precise localization: a VLM will fluently report "4 bolts" over an image containing 5.
- Structured output: production VLM usage means forcing JSON (schema-constrained decoding) so downstream code can parse — and still validating fields against reality, because well-formed is not the same as true.
- Where the VLM wins: open-ended reasoning, reading, long-tail queries no label set anticipated, low-volume workloads, and prototyping speed — a prompt this afternoon versus a labeling project this quarter.
- The hybrid pattern, often the right answer: a cheap detector/classifier filters the stream; the VLM runs only on the hits. Detector flags 0.1% of frames, VLM explains those — the system costs like a detector and reasons like a VLM.
Chapter 12: Generative Models in a Perception Stack
Diffusion in one honest paragraph: train a network to denoise — corrupt training images with Gaussian noise at randomly sampled severity levels and learn to predict the noise (equivalently, the clean image) at each level; generation runs the process backwards, from pure noise to an image over many iterative denoising steps (hence the cost: one image = many forward passes). Latent diffusion (Stable-Diffusion-class) runs the whole loop inside a VAE's compressed latent space instead of pixel space — the same mechanism at a fraction of the spatial cost, which is what made it run on consumer GPUs; text conditioning enters through cross-attention to text-encoder embeddings.
What a perception engineer actually uses it for:
- Synthetic training data for rare classes — the defect that appears twice a year, the near-collision you cannot stage. Useful when the gap is appearance-level; the standing caveat is distribution shift: synthetic ≠ real, and a model can learn to detect the generator's artifacts instead of the object. Treat synthetic as augmentation, never as the eval set — validation stays on real held-out data.
- Restoration/super-resolution pre-processing — diffusion-based SR/deblur ahead of a recognition stage; caveat: generative SR invents plausible detail, which is disqualifying in evidence-grade pipelines (forensics, medical, metrology).
This track stays discriminative. What you need is the judgment call: when diffusion is a cheap data tool, and when its hallucinated pixels are a liability.
Chapter 13: Choosing a Backbone in the Foundation-Model Era
The decision framework that replaces "use ResNet":
| Situation | Default answer |
|---|---|
| Tight edge latency, fixed classes, enough labels | CNN (or small hierarchical ViT) fine-tune — Phase 03/05 playbook |
| Few labels, common visual domain | frozen DINOv2 features + linear/k-NN head |
| Classes change at runtime / long-tail vocabulary | CLIP zero-shot or open-vocab detector (Ch. 8) |
| Masks needed, no segmentation labels | SAM/SAM2 prompted (distilled variant at the edge) — Ch. 7 |
| Dense prediction at scale | hierarchical ViT (Swin-class) or ConvNeXt + FPN — or frozen DINOv2/v3 + light decoder (Ch. 10) |
| Multimodal (image+text reasoning) | CLIP tower + LLM (VLM stack) |
| Specialized domain (medical/industrial), enough data | fine-tune the strongest available backbone — domain gap beats zero-shot |
Plus the operational lens (every prior phase's ethics): measure on your distribution, account deployment cost (ViT-L is not an edge model — distill or choose smaller), and prefer the boring sufficient option. The era's real change is that features are now infrastructure — the engineering moves to heads, data, and evaluation.
Lab Walkthrough Guidance
Order: Lab 01 (ViT) → Lab 02 (CLIP) → Lab 03 (DINO) → Lab 04 (mini-SAM).
- Lab 01: implement ViT's patch embedding + encoder from your Phase 03 toolkit (or port the model-accuracy lab's ViT); verify the conv-as-patchify equivalence numerically; fine-tune pretrained ViT-B on a small dataset vs your ResNet — the comparison at this data scale should favor… find out, and explain via Ch. 1. Plot attention maps across depth.
- Lab 02: build zero-shot classification with open CLIP weights on your Phase 05 dataset: prompt engineering ablation (3 templates + ensemble), then linear probe on CLIP features vs zero-shot vs your fine-tuned CNN — the three-way table is the deliverable. Build a tiny text→image search demo over your own photo folder.
- Lab 03: load DINOv2; k-NN and linear-probe classification at 10/50/250 labels per class (the label-efficiency curve is the money plot); visualize CLS attention segmentation on non-iconic images; try dense-feature correspondence between two views of an object.
- Lab 04 (
lab-04-promptable-segmentation/): a mini-SAM built from scratch in pure PyTorch — synthetic shapes dataset, point-prompt encoder, two-way-attention mask decoder, multi-mask output with an IoU-prediction head. Do it after Lab 03: it assumes the ViT/attention fluency of Labs 01–03 and turns Ch. 7 from prose into tensors. What to look for: click a point where shapes overlap and watch the three candidate masks diverge (whole vs part — ambiguity resolved by architecture, not averaging); confirm the min-loss-only gradient routing is what makes them specialize; and check that the IoU head's ranking actually correlates with each mask's true IoU — that ranking is what makes multi-mask output usable downstream.
Success Criteria
You are ready for Phase 07 when you can, from memory:
- State the inductive-bias-vs-data law and what DeiT changed about it.
- Walk ViT's patchify → CLS → position-embedding pipeline and the resolution- interpolation mechanism; explain the patch-size dial.
- Write CLIP's symmetric InfoNCE idea and why batch size and temperature matter.
- Demonstrate (on paper) zero-shot classification and name CLIP's four weak spots.
- Explain DINO's agreement-across-views intuition and its three emergent properties.
- Apply the backbone-choice table to three scenarios with cost reasoning.
- Draw SAM's three-part split (heavy run-once encoder / trivial prompt encoder / light two-way-attention decoder), explain both directions of two-way attention, and retell the SA-1B data-engine flywheel — and why the engine, not the architecture, is the transferable lesson.
- Derive detection-as-set-prediction: what the Hungarian matching cost contains, why one-to-one training makes duplicates unprofitable, and the three operational consequences of deleting NMS.
- Tell the register-tokens story end to end: the artifact, the hijacked-patches cause, the fix, and what it teaches about diagnosing ViTs.
- Sketch VLM anatomy (tower → projector → LLM; alignment then instruction tuning) and argue VLM-vs-classical-pipeline for a given workload using cost, verifiability, and hallucination-risk reasoning.
Interview Q&A
Q: You have 200 labeled images per class for a niche industrial defect task. ViT, CNN, CLIP, or DINO? 200/class is small-data regime with a domain gap. First bet: frozen DINOv2 + linear probe (label-efficient, no overfitting risk, hours of work) with k-NN as the sanity baseline; second: fine-tune a small CNN with heavy augmentation (the Phase 03 playbook) — compare honestly. CLIP zero-shot likely fails (industrial defects aren't web vocabulary) but costs nothing to measure. Training ViT from scratch is the one clearly wrong answer. The graded skill: matching supervision regime to data reality, with measurement over allegiance.
Q: Why does contrastive learning need large batches, and what breaks with small ones? The loss discriminates the true pair against in-batch negatives: batch size is the number of negatives, and gradient quality scales with how hard the discrimination is. Small batches → few, mostly-easy negatives → weak geometry (embeddings collapse toward coarse clusters; retrieval precision dies in the tail). Mitigations: memory banks/queues (MoCo-style) to decouple negatives from batch, or non-contrastive objectives (DINO/BYOL) that avoid explicit negatives entirely — connecting the two families is the senior answer.
Q: A PM asks: "Can we just use CLIP zero-shot instead of labeling data for our detector?" Decompose: zero-shot classification of crops — plausible; detection needs localization, so open-vocab detectors (OWL-ViT/Grounding-DINO-class) are the relevant tools — try them on a measured pilot. Expect: good on common categories, weak on domain-specific ones; latency far above a YOLO; and prompt sensitivity becomes the new tuning surface. Likely production answer: use open-vocab models to bootstrap labels (pre-annotation, mined hard examples), then train the small fast supervised detector — foundation models as labeling leverage, not always as the deployed artifact.
Q: You have 50k unlabeled images and budget to label 2k — design the labeling strategy. Don't spend the human budget first. (1) Auto-label all 50k with Grounded-SAM (Ch. 8): your ontology as text prompts → Grounding DINO boxes → SAM masks — offline compute, zero human hours. (2) Spend humans where they compound: QA-sample the auto-labels to measure per-class quality (Phase 02: unmeasured labels are rumors), then route the budget by active learning — images where the auto-labeler is least trustworthy (low confidence, disagreement between Grounding DINO and a first distilled model) and rare-class candidates mined by CLIP/DINOv2 retrieval over the pool. (3) Reserve a slice of gold labels (say 500) as a sacred eval set never trained on. (4) Distill: train a compact detector on cleaned auto-labels + corrected hard cases; iterate the flywheel once (retrain → re-flag → re-correct — SA-1B's loop at hobby scale). Result: 50k-image supervision at a 2k-label price; the graded skill is spending humans on measurement and hard cases, not uniform annotation.
Q: When would you deploy RT-DETR over YOLO, and what breaks? Deploy RT-DETR when: GPU serving with TensorRT at S/M/L model scale (where its accuracy/latency curve wins); crowded scenes where NMS's IoU threshold eats true positives; or when NMS-free is operationally valuable — single clean export graph (no NMS plugin), no per-dataset threshold tuning, deterministic latency for tight SLAs. What breaks or needs checking: edge/CPU targets (tiny YOLO variants dominate, and attention survives INT8 quantization worse than convolutions — measure the post-quantization accuracy drop, the decoder is the risk); the ecosystem gap (Ultralytics-class tooling depth: exports, trackers, recipes); small-object performance on your data rather than the COCO table; and training friction if the team retrains often — DETR-family training is fiddlier despite denoising-era fixes. Also concede: YOLOv10-class NMS-free heads shrink the differentiator. The senior close: it's a benchmark on your distribution and your hardware, not a brand choice.
Q: A PM wants to replace your detection+OCR pipeline with a VLM — argue both sides with numbers-shaped reasoning. For the VLM: the pipeline is N models with N failure modes plus glue code; a VLM collapses it to one prompt, absorbs layout variation the template logic can't, and ships in days, not quarters. Against, do the arithmetic aloud: at 1M docs/day the pipeline costs milliseconds and fractions of a cent per doc on amortized GPU; a VLM pushes 1–3k tokens per doc through a multi-billion-parameter decoder — orders of magnitude more compute and seconds of latency; and hallucination concentrates exactly where this product bleeds (digits, totals, counts) delivered with fluent confidence, while free-text output is harder to evaluate than field-level accuracy against GT. The senior answer decomposes instead of picking a side: benchmark VLM field-level accuracy on a few hundred gold documents against the pipeline; propose the hybrid — pipeline for the ~95% standard layouts, VLM only for rejects and weird layouts (cost stays pipeline-shaped, robustness improves); force JSON schema output and validate fields either way. Hand the PM per-1k-doc cost, p95 latency, and field-accuracy in one table — the argument settles itself.
References
- Dosovitskiy et al., An Image is Worth 16x16 Words (ViT) (2020) — arXiv:2010.11929
- Touvron et al., DeiT (2020) — arXiv:2012.12877
- Liu et al., Swin Transformer (2021) — arXiv:2103.14030
- Radford et al., Learning Transferable Visual Models From Natural Language Supervision (CLIP) (2021) — arXiv:2103.00020
- Caron et al., DINO (2021) — arXiv:2104.14294; Oquab et al., DINOv2 (2023) — arXiv:2304.07193
- Minderer et al., OWL-ViT (2022) — arXiv:2205.06230
- Kirillov et al., Segment Anything (SAM) (2023) — arXiv:2304.02643
- Ravi et al., SAM 2: Segment Anything in Images and Videos (2024) — arXiv:2408.00714
- Zhang et al., MobileSAM (2023) — arXiv:2306.14289; Xiong et al., EfficientSAM (2023) — arXiv:2312.00863
- Liu et al., Grounding DINO (2023) — arXiv:2303.05499; Ren et al., Grounded SAM (2024) — arXiv:2401.14159
- Carion et al., End-to-End Object Detection with Transformers (DETR) (2020) — arXiv:2005.12872
- Zhu et al., Deformable DETR (2020) — arXiv:2010.04159; Li et al., DN-DETR (2022) — arXiv:2203.01305
- Zhao et al., DETRs Beat YOLOs on Real-time Object Detection (RT-DETR) (2023) — arXiv:2304.08069
- Wang et al., YOLOv10: Real-Time End-to-End Object Detection (2024) — arXiv:2405.14458
- Zhou et al., iBOT: Image BERT Pre-Training with Online Tokenizer (2021) — arXiv:2111.07832
- Darcet et al., Vision Transformers Need Registers (2023) — arXiv:2309.16588
- Siméoni et al., DINOv3 (2025) — arXiv:2508.10104
- Liu et al., Visual Instruction Tuning (LLaVA) (2023) — arXiv:2304.08485
- Bai et al., Qwen2.5-VL Technical Report (2025) — arXiv:2502.13923
- Beyer et al., PaliGemma (2024) — arXiv:2407.07726; Grattafiori et al., The Llama 3 Herd of Models (2024) — arXiv:2407.21783
- Rombach et al., High-Resolution Image Synthesis with Latent Diffusion Models (2021) — arXiv:2112.10752
- open_clip — the weights and training code the lab uses
- Cross-track companions: LLM track Phase 04 WARMUP (attention, fully derived); model-accuracy lab-04-vit-dino (DINO, built and tested)
Lab 6-01: Vision Transformer (ViT) from Scratch
Learning Objectives
- Understand patch embedding: images are sequences of patches
- Implement positional encodings (learned 1D)
- Build a full Transformer encoder block (MHSA + FFN + LayerNorm)
- Train ViT on synthetic data
- Visualize attention maps (which patches the CLS token attends to)
- Understand ViT vs CNN inductive biases
ViT Architecture
Image (H×W×C)
│
▼ Patch Embedding: split into N patches, linear projection
[P₁, P₂, ..., Pₙ] ← shape: (N, D)
│
▼ Prepend [CLS] token, add positional embeddings
[CLS, P₁+pos₁, P₂+pos₂, ..., Pₙ+posₙ] ← shape: (N+1, D)
│
▼ L × Transformer Encoder Block:
│ ┌─────────────────────────────────────┐
│ │ x = x + MHSA(LayerNorm(x)) │ (pre-norm, residual)
│ │ x = x + FFN(LayerNorm(x)) │
│ └─────────────────────────────────────┘
│
▼ Extract CLS token → MLP Head
Prediction (n_classes)
Patch Embedding
# Split image into patches and project to embedding dimension D
class PatchEmbedding(nn.Module):
def __init__(self, img_size=224, patch_size=16, in_channels=3, embed_dim=768):
super().__init__()
n_patches = (img_size // patch_size) ** 2
self.proj = nn.Conv2d(in_channels, embed_dim,
kernel_size=patch_size, stride=patch_size)
# Conv2d with kernel=stride=patch_size = non-overlapping patch extraction
def forward(self, x): # x: (B, C, H, W)
x = self.proj(x) # (B, D, H/P, W/P)
x = x.flatten(2) # (B, D, N)
x = x.transpose(1, 2) # (B, N, D)
return x
Interview Questions
Q: What is the main inductive bias difference between CNNs and ViTs?
A: CNNs have two strong inductive biases baked in: (1) locality — conv filters only look at local neighbourhoods, (2) translation equivariance — the same filter is applied everywhere. ViTs have neither — attention is global from the start (every patch can attend to every other). This means ViTs need much more data to learn spatial structure from scratch, but can model long-range dependencies CNNs struggle with.
Q: Why is the CLS token used for classification instead of average pooling?
A: The CLS token is a learnable token prepended to the sequence. Through self-attention over L layers, it aggregates information from all patch tokens. It's a design choice from BERT. Average pooling over all patch tokens also works (used in DeiT), sometimes better with sufficient data.
Q: What is the computational complexity of self-attention and why does it matter for high-res images?
A: $O(N^2 \cdot D)$ where N = number of patches. For 224×224 with 16×16 patches: N=196, manageable. For 1024×1024 with 16×16 patches: N=4096, attention matrix is 4096×4096 — 64M entries per head. This is why hierarchical approaches (Swin Transformer, window attention) are used for dense prediction tasks on high-resolution images.
Lab 6-02: CLIP-Style Contrastive Learning
Learning Objectives
- Understand contrastive learning and why it aligns image and text
- Implement InfoNCE (NT-Xent) loss from scratch
- Build an image encoder + text encoder and train jointly
- Demonstrate zero-shot classification
- Understand temperature parameter τ and its effect
CLIP Architecture
Images → Image Encoder (ViT or CNN) → L2-normalized embedding zᴵ ∈ ℝᵈ
Texts → Text Encoder (Transformer) → L2-normalized embedding zᵀ ∈ ℝᵈ
┌──────────────────────┐
Compute │ Similarity matrix S │
S = zᴵ · (zᵀ)ᵀ / τ │
└──────────────────────┘
↓
InfoNCE Loss:
• Row-wise CE (image→text)
• Col-wise CE (text→image)
• Average both directions
InfoNCE Loss
For a batch of N image-text pairs:
$$\mathcal{L}{\text{img→txt}} = -\frac{1}{N}\sum{i=1}^{N}\log\frac{\exp(S_{ii}/\tau)}{\sum_{j=1}^{N}\exp(S_{ij}/\tau)}$$
$$\mathcal{L} = \frac{1}{2}(\mathcal{L}{\text{img→txt}} + \mathcal{L}{\text{txt→img}})$$
The diagonal of S contains positive pairs. Off-diagonal = negatives.
Zero-Shot Classification
# Prompt engineer text embeddings for each class
prompts = [f"a photo of a {cls}" for cls in class_names]
text_embs = encode_text(prompts) # (C, D)
# For each image, find closest text embedding
image_embs = encode_image(images) # (N, D)
similarities = image_embs @ text_embs.T # (N, C)
predictions = similarities.argmax(dim=-1) # no fine-tuning needed!
Interview Questions
Q: Why is temperature τ critical in InfoNCE loss?
A: τ scales the logits before softmax. Low τ → peaked distribution, hard negatives dominate, loss focuses on confusing examples. High τ → flat distribution, treats all negatives equally (less informative). CLIP uses τ as a learned parameter (initialized to 0.07). Too low τ can cause training instability; too high → slow convergence.
Q: What is the alignment-uniformity framework for understanding contrastive loss?
A: Two properties are needed for good embeddings: (1) Alignment: positive pairs should be close (low distance). (2) Uniformity: embeddings should be spread across the unit hypersphere (avoid mode collapse). InfoNCE optimizes both: diagonal terms → alignment, off-diagonal terms → repulsion → uniformity.
Q: CLIP uses 400M image-text pairs. How can you apply CLIP with limited data?
A: (1) Use pre-trained CLIP as a frozen feature extractor — zero-shot baseline. (2) Linear probe: train a linear classifier on top of CLIP features. (3) Prompt tuning (CoOp): learn continuous prompt embeddings, freeze vision/text encoders. (4) CLIP-Adapter: add lightweight adapter layers. All require much less data than full fine-tuning.
Lab 6-03: DINO-Style Self-Supervised Learning
Learning Objectives
- Understand the student-teacher self-supervised paradigm
- Implement EMA (Exponential Moving Average) teacher update
- Build multi-crop strategy: global + local crops
- Implement centering + sharpening (the stability tricks in DINO)
- Train on synthetic data and verify embeddings cluster by class
- Understand why DINO features have excellent k-NN classification performance
DINO Architecture
Image
│
├─── Global crop 1 ─→ Student ─→ softmax(z/τ_s) ← student, sharpened
├─── Global crop 2 ─→ Teacher ─→ softmax((z-c)/τ_t) ← teacher, centered+sharpened
├─── Local crop 1 ─→ Student
└─── Local crop 2 ─→ Student
│
▼ Cross-entropy loss: student ← teacher (teacher's output is the "label")
▼ Teacher receives NO gradient — updated via EMA
θ_teacher ← λ·θ_teacher + (1-λ)·θ_student
center c ← m·c + (1-m)·mean(teacher_output) ← centering prevents collapse
Why These Tricks Are Necessary
| Trick | Problem Solved |
|---|---|
| EMA teacher | Provides stable targets; gradients only flow through student |
| Centering | Prevents mode collapse (all outputs → same prototype) |
| Sharpening (low τ) | Prevents uniform distribution collapse |
| Multi-crop | More views per image → better representations, lower cost |
| Stop-gradient on teacher | Teacher is never directly optimized — momentum update only |
DINO Loss
$$\mathcal{L} = -\sum_{\text{teacher crops}} \sum_{\text{student crops}} p_t \cdot \log p_s$$
where:
- $p_t = \text{softmax}((z_t - c) / \tau_t)$ (centered + sharpened)
- $p_s = \text{softmax}(z_s / \tau_s)$ (sharpened, no centering)
Interview Questions
Q: Why does DINO use a stop-gradient on the teacher and EMA updates instead of simply sharing weights?
A: If teacher = student (shared weights), any collapse mode satisfies the loss (trivially). EMA creates a slowly-moving "ensemble" of student snapshots, providing more stable and higher-quality targets. Stop-gradient prevents the teacher from receiving loss gradients — it only evolves through EMA.
Q: What is the centering operation and why is it necessary?
A: Centering subtracts a running mean c from the teacher's output before softmax. Without it, the teacher can collapse to a single dominant dimension (all embeddings → same output regardless of input). Centering decorrelates this by shifting the mean to zero, making the softmax more uniform across features.
Q: DINO vs MAE vs SimCLR — when would you use each?
A: SimCLR/MoCo: best with large batch sizes; requires negative pairs; strong linear probe accuracy. DINO: strong k-NN accuracy without fine-tuning; learns semantically meaningful patches; no negative pairs needed. MAE: masked autoencoding; better fine-tuning accuracy; faster pre-training; but weaker k-NN. Choose DINO for strong linear probing; MAE for transfer learning tasks.
Lab 6-04: Promptable Segmentation — Build a mini-SAM From Scratch
Mission. You will build the Segment Anything Model's entire architecture at toy scale, in pure PyTorch, on synthetic data, offline, on a CPU: an image encoder that runs once per image, a prompt encoder that turns a mouse click into a token for almost zero FLOPs, and a fast mask decoder with genuine two-way attention that emits three candidate masks plus a self-assessed quality score for each. By the end you will understand — because you implemented every line — why SAM predicts multiple masks for one click, how a model can grade its own output without ground truth, and why the encoder/decoder asymmetry is the single most important serving decision in interactive vision systems.
Table of Contents
- Why this lab exists
- The architecture at a glance
- File map
- Setup and how to run
- The synthetic task and why it contains ambiguity
- Concepts under the hood
- TODO walkthrough
- Success criteria
- From mini-SAM to real SAM and SAM2
- Stretch goals
- Interview Q&A
- References
Why this lab exists
SAM (Segment Anything Model, Kirillov et al. 2023) is usually presented as a dataset story — 1.1 billion masks — but its architecture is first and foremost a systems design lesson, and that lesson survives intact at 1/10,000th the scale:
-
Amortize the expensive part. Segmenting interactively means answering many prompts about the same image. SAM splits the network so that everything prompt-independent (a 632M-parameter ViT-H) runs once and is cached, while everything prompt-dependent (a ~4M-parameter decoder) runs per click in ~10 ms. The user experiences real-time segmentation even though the full model is enormous. If you take away one idea, take this one: find the cut in your computation graph that separates "per-item" from "per-query" work, and cache at that cut.
-
Make prompting near-free. A click is two numbers. SAM encodes it with a fixed random-Fourier positional code plus one learned embedding — a few thousand FLOPs. No conv stack, no MLP tower. Cheap prompts are what make the amortization worthwhile.
-
Design for ambiguity instead of pretending it away. A single click is genuinely underspecified — click on a shirt logo and you might mean the logo, the shirt, or the person. A single-output model trained on such data averages the interpretations and produces a blurry compromise that is no valid answer. SAM outputs K=3 masks, trains only the best-matching one per sample (min-loss-over-candidates), and adds an IoU-prediction head so the model can rank its own candidates at inference, when no ground truth exists.
This lab rebuilds all three decisions small enough to train in ~90 seconds on a laptop CPU, and the tests verify the behaviors (ambiguity divergence, honest self-ranking), not just the shapes.
The architecture at a glance
┌──────────────────────────────────────────────┐
image (1×48×48) ──▶│ ImageEncoder: conv stack, stride 4 │ EXPENSIVE
│ (SAM: ViT-H, 632M params, run ONCE, cached) │ once per image
└──────────────────┬───────────────────────────┘
│ features (64×12×12)
▼
click (x, y) ──▶ PromptEncoder ─── prompt token (1×64) CHEAP
│ γ(p)=[sin 2πBp, cos 2πBp] + label embed per prompt
│
├─ dense PE (144×64: same γ over grid cells)
└─ dense prompt "beacon" (cos-sim of click γ vs each cell γ)
│
▼
┌──────────────────────────────────────────────┐
learned tokens ──▶ │ MaskDecoder: 2 × TwoWayAttentionBlock │ FAST
[iou][m0][m1][m2] │ 1. token self-attention │ per prompt
│ 2. cross-attn tokens → image │
│ 3. MLP on tokens │
│ 4. cross-attn image → tokens ◀── the │
│ (image learns about the prompt) 2nd way │
│ PE re-injected into q,k at EVERY step │
└──────┬────────────────────────┬──────────────┘
│ mask tokens out │ image stream out
▼ ▼
hypernetwork MLPs upscale ×4 (ConvT ×2)
3 weight vectors (16-d) 16ch × 48 × 48
└───────┬────────────────┘
▼ per-pixel dot product
mask logits (3×48×48) iou_pred (3), self-graded
File map
| File | Role |
|---|---|
README.md | This document — theory, walkthrough, references |
lab.py | Skeleton with TODO(1)–TODO(11); every body raises NotImplementedError |
solution.py | Complete reference implementation, heavily commented |
test_lab.py | 14 pytest tests; runs against solution by default, your lab.py via LAB_MODULE=lab |
requirements.txt | torch, numpy, pytest — nothing else, fully offline |
Setup and how to run
Everything runs offline on CPU with the repo virtualenv (Python 3.14, torch ≥ 2.2):
cd AI-Engineer/cv-engineer/phase-06-sota-models/lab-04-promptable-segmentation
# 1. Verify the reference solution passes (≈ 90 s, one shared training run):
/Users/s0x/src/10xdev/ai-enigneer/.venv/bin/python -m pytest test_lab.py -q
# 2. Work through lab.py, then test YOUR implementation:
LAB_MODULE=lab /Users/s0x/src/10xdev/ai-enigneer/.venv/bin/python -m pytest test_lab.py -q
# 3. Train + evaluate the reference end-to-end by hand:
/Users/s0x/src/10xdev/ai-enigneer/.venv/bin/python solution.py
Determinism: all randomness flows from explicit seeds (np.random.default_rng(seed) for data, torch.manual_seed for parameters, a private torch.Generator inside the prompt encoder), so runs are reproducible on a given machine.
The synthetic task and why it contains ambiguity
A sample is a triple (image, point-prompt, ground-truth mask) on a 48×48 canvas:
- Non-overlapping scenes (70%): 1–3 shapes (circle, square, triangle) of random size and intensity on a noisy background, placed by rejection sampling so they never touch. The prompt is a random pixel inside one chosen shape; the GT is that shape's mask. One click → one right answer.
- Overlapping scenes (30%): a small shape drawn fully inside a large one, with distinct intensities so both boundaries are visible. The prompt lands in the overlap region, and the GT is the small shape or the large shape with probability ½ each.
That second family is the point of the lab. The identical (image, click) input appears in the dataset with two different correct answers. A single-mask model minimizing expected loss on such data converges to the average of both masks — a half-confident blob that matches neither interpretation. A multi-mask model with min-loss-over-candidates training can dedicate one candidate to "the small thing under the cursor" and another to "the big thing under the cursor" and win on both. The test suite explicitly checks that your trained candidates disagree on overlap clicks (pairwise IoU between candidates far below 1).
Concepts under the hood
Fourier features and spectral bias
What. Instead of feeding raw coordinates (x, y) ∈ [0,1]² to the network, we project them through a fixed random matrix B ~ N(0, σ²) (shape 2 × D/2) and take γ(p) = [sin(2πBp), cos(2πBp)], giving a D-dimensional code.
Why raw coordinates fail. MLPs exhibit spectral bias: viewed through their neural tangent kernel, they fit low-frequency components of the target function orders of magnitude faster than high-frequency ones. A mask boundary is a high-frequency function of position, and two clicks one pixel apart (Δ=1/48) produce inputs that differ by 2%, so their embeddings — and therefore their masks — stay nearly identical for most of training.
How the fix works. After the random projection, a 1-pixel move rotates each (sin, cos) pair by an angle proportional to that row's frequency. With frequencies drawn at scale σ, nearby points become linearly separable in embedding space from step one; Tancik et al. (2020) show this converts the network's kernel into a stationary, tunable-bandwidth kernel. It is the same idea as the Transformer's sinusoidal positional encoding, with random Gaussian frequencies instead of a fixed geometric ladder. σ trades smoothness for resolution: too low and nearby prompts collapse together, too high and the encoding decorrelates between adjacent feature-grid cells. We use σ=4 for a 12×12 grid.
Crucially, the same basis γ encodes both the click and the feature-grid cell centers (the "dense PE"), so "is this cell under the click?" reduces to a dot product between matching signatures.
Two-way attention
What. Standard attention: every query builds a weighted average of values, with weights softmax(q·kᵀ/√d). Cross-attention lets one sequence (queries) read from another (keys/values). A standard transformer decoder is one-way: object queries read from the image; the image is never updated.
Why SAM goes both ways. SAM's decoder must produce a pixel-dense output (a mask), not just token-level answers. Its trick: masks are decoded as dot products between token vectors and per-pixel features (see hypernetworks below). For that dot product to cut out "the object under the click", the per-pixel features themselves must already encode "how do I relate to this prompt?" — so the decoder needs a path for information to flow into the image stream, not just out of it.
How one block works (implemented in TwoWayAttentionBlock, residual + LayerNorm after every step):
- Token self-attention — the iou/mask/prompt tokens negotiate among themselves; this is where mask tokens coordinate who covers which interpretation.
- Cross-attention, tokens → image — tokens gather visual evidence (what intensity is under the click, how far does that region extend).
- MLP on tokens — per-token nonlinear mixing.
- Cross-attention, image → tokens — the second way: each of the 144 image cells attends over the 5 tokens and updates itself with prompt-conditioned information.
One faithful detail matters enormously in practice: positional encodings are re-added to queries and keys at every attention call (values stay raw), exactly as in SAM's TwoWayTransformer. Residual mixing dilutes a once-added PE layer by layer; if the click-to-cell alignment signal fades from the attention logits, the decoder degenerates into prompt-ignoring "segment every object" behavior — we verified this failure mode empirically while building the lab.
Mask tokens and hypernetworks
What. The decoder prepends 4 learned embeddings to the prompt token: one IoU token and K=3 mask tokens (descendants of DETR's object queries — learned vectors that each "own" one output slot). A hypernetwork is a network whose output is the weights of another network.
How masks are decoded. After the two-way blocks, each mask token is passed through its own small MLP to produce a 16-dimensional weight vector w_k. The image stream is reshaped back to a map and upscaled ×4 by two transposed convolutions (a transposed convolution reverses a stride-2 convolution's geometry: each input cell scatters a learned 2×2 kernel into the output, doubling resolution). The candidate mask is then simply
mask_k[y, x] = ⟨ w_k , features[:, y, x] ⟩
— a per-pixel linear classifier whose weights were generated from the token. All K candidates share every pixel feature and differ only in K tiny vectors, which is why emitting 3 masks costs barely more than emitting 1.
BCE, dice, and focal losses
Binary cross-entropy (BCE) treats each pixel as an independent binary classification: −[t·log p + (1−t)·log(1−p)] averaged over pixels. It gives smooth, well-scaled gradients everywhere but has a structural flaw for segmentation: it weights every pixel equally, and masks are tiny relative to the background (a 30-px shape on a 2304-px canvas is ~1% positive). A model can score excellent average BCE by confidently predicting "background everywhere".
Dice loss fixes the imbalance by scoring region overlap instead of per-pixel accuracy: 1 − 2|P∩T| / (|P|+|T|) computed on sigmoid probabilities (an ε in numerator and denominator keeps it defined for empty masks). Because both terms scale with mask size, a small object contributes as much as a large one. Its weakness is the mirror image of BCE's: gradients are poorly conditioned when predictions are near-empty. Using BCE + dice together — as this lab does — is the standard pairing: BCE supplies stable pixel-level gradients, dice supplies scale-invariant region pressure.
Focal loss (used by real SAM, weighted 20:1 with dice) is BCE reshaped by (1−p_t)^γ: pixels the model already classifies confidently are down-weighted, focusing gradient on hard pixels — which, in segmentation, are precisely the boundary pixels. At our resolution plain BCE converges just as well, so we keep the simpler pairing; swapping in focal is a stretch goal.
Min-loss-over-candidates and the IoU head
Training the K candidates. Every candidate is scored against the single GT mask (BCE + dice), and the mask loss is backpropagated only through the arg-min candidate. On an ambiguous prompt, whichever candidate is currently closest to this sample's interpretation gets pulled further toward it — a winner-take-all dynamic that makes candidates specialize into distinct interpretations instead of all regressing to the blurry mean. This is SAM's exact trick, and it is the multiple-choice-learning literature's answer to multimodal targets.
Training the IoU head. At inference there is no ground truth, so something must rank the 3 candidates. The IoU head is trained with MSE to predict each candidate's actual IoU with the GT (computed on the binarized masks, detached so this loss never reshapes the masks themselves — it only teaches the judge). Supervising all K candidates (not just the winner) is what lets the head rank.
The honest metric. evaluate_iou reports the IoU of the candidate the head ranked first — never the candidate closest to ground truth. Selecting by GT would leak the answer into the metric; head-selected IoU is what a user actually experiences, and it is strictly ≤ the oracle. The gap between the two is exactly "how good is the model at judging itself", a quantity worth monitoring in any deployed system.
Toy-scale concessions and why real SAM does not need them
Three places where this lab deliberately deviates from the paper — each is a lesson about what scale buys:
- Dense prompt beacon. We add
cos-sim(γ(click), γ(cell))as an explicit channel to the decoder's image stream. Real SAM's decoder learns this alignment inside its attention logits over ~10⁵ ViT-scale steps; a 2-block decoder given a few hundred CPU steps does not, and without the beacon it collapses to segmenting every object. (SAM itself adds dense mask-prompt embeddings to the image stream through exactly this pathway, so the mechanism is canonical — only its use for points is our concession.) - Mask-quality statistics for the IoU head. We feed the head four GT-free cues per candidate — area, mean confidence, boundary contrast against the image, inside-mask intensity variance — alongside the token outputs. Real SAM's head reads only the IoU token and learns equivalent evidence end-to-end. The cues are all computable at deployment, so the metric stays honest.
- ε-relaxed winner-take-all (
relax=0.02× mean candidate loss). Pure WTA gives losing candidates zero gradient on samples they lose, so their off-role behavior drifts arbitrarily and becomes unpredictable — for the IoU head, ungradeable. The tiny relaxation keeps losers sane without stopping specialization. SAM's data diversity provides this stabilization for free.
One more empirical finding worth internalizing: with only a few hundred training scenes this model memorizes the training set (train IoU climbs while held-out IoU stalls). Because the generator is nearly free, the fix is simply more data (default 4096 scenes) — a miniature of the SA-1B lesson that data volume is the regularizer.
TODO walkthrough
Work through lab.py in numeric order; each TODO's docstring carries the detailed hints. The order follows the dependency chain:
make_dataset— TODO(1). Rejection-sample non-overlapping scenes; build nested overlap pairs; sample the prompt inside the target (or overlap) region; GT flips small/big on ambiguous samples. Singledefault_rng(seed)for everything — a test checks bit-identical replay.PromptEncoder— TODO(2). Bufferfreqand the label embedding from a private seededtorch.Generator(so two encoders with equal seeds match exactly);fourier,forward,dense_pe(same basis on cell centers),dense_prompt(the beacon).ImageEncoder— TODO(3). Five convs, two of them stride-2: (1, 48, 48) → (64, 12, 12). No prompt anywhere in this class — that is the contract.Attention— TODO(4). By hand: q/k/v/out projections, head split,softmax(q·kᵀ/√d_head), merge. Nonn.MultiheadAttention.TwoWayAttentionBlock— TODO(5). The four steps, post-LN residuals, and PE re-injection into q and k at every call.mask_quality_stats— TODO(6). Morphology viamax_pool2d(dilation) and−max_pool2d(−·)(erosion) to get 1-px inner/outer boundary rings; ε-guard all divisions so empty masks give zeros, not NaNs; everything underno_grad.MaskDecoder— TODO(7). Token concatenation ([iou, m0, m1, m2, prompt]),input_normneck (without it the conv activations' scale drowns the unit-scale PE in the attention logits), beacon injection, blocks, final tokens→image read, upscale, hypernetwork MLPs, einsum masks, stats-conditioned IoU head with sigmoid.MiniSAM— TODO(8). Wire the three parts, but keepencode_image/decode_promptas separate methods — the split is the serving lesson.mask_iou+dice_loss— TODO(9). Reductions over the last two dims so both broadcast over arbitrary leading dims.sam_loss— TODO(10). Per-candidate BCE+dice → argmin → gather winner; +relax× mean; +iou_weight× MSE(iou_pred, detached actual IoU of all candidates).train_mini_sam+evaluate_iou— TODO(11). Adam + cosine decay, seeded minibatch sampler, loss history; evaluation selects each sample's mask byiou_pred.argmaxand reports mean IoU vs GT.
Success criteria
Tests passing is necessary, not sufficient. You are done when you can:
- Explain why clicking the same pixel can legitimately require two different masks, and trace how min-loss-over-candidates converts that ambiguity into candidate specialization (rather than averaging).
- State what breaks if you (a) remove PE re-injection from the attention calls, (b) remove the LayerNorm neck, (c) shrink the dataset to 300 scenes — and why, mechanically, in each case.
- Defend the honest metric: why the eval selects masks with the IoU head instead of picking the candidate closest to GT, and what the oracle-vs-head gap measures.
- Sketch the latency budget of an interactive SAM service and say which components run per-image vs per-click, and where the cache sits.
- Quantitative bars (the tests enforce them): loss falls > 40% (reference: ~80–87%); held-out head-selected mean IoU > 0.5 (reference: ≈ 0.65); on overlap clicks the 3 candidates' mean min pairwise IoU < 0.5 (reference: ≈ 0.03–0.14) with at least 2 non-trivial masks.
From mini-SAM to real SAM and SAM2
What changes at scale — and what does not:
- Image encoder. Ours: 5 convs, ~100K params, 12×12 features. SAM: a ViT-H pre-trained with MAE, 632M params, windowed attention on 1024×1024 inputs producing 64×64×256 embeddings — >99% of total compute, which is precisely why the once-per-image split exists. The contract (prompt-independence) is identical.
- Data engine, not just a dataset. SA-1B's 1.1B masks were produced by a three-stage flywheel: assisted-manual (annotators correct model output), semi-automatic (model proposes, humans fill gaps), fully automatic (a 32×32 point grid prompts the model, masks filtered by the IoU head's own confidence and stability). The IoU head you built is what makes stage three possible — a model that can grade itself can label data for its successor.
- Prompt types. We built foreground points. SAM adds background points (second label embedding), boxes (two corner embeddings), rough masks (conv-embedded dense prompts — the pathway our beacon reuses), and demonstrated text prompts via CLIP embeddings (the ancestor of open-vocabulary pipelines like Grounded-SAM).
- SAM2 (2024) extends promptable segmentation to video: a streaming architecture where each frame's features are enriched by memory attention — cross-attention into a FIFO memory bank of encoded past frames and past mask predictions, plus object pointer tokens. Prompt once on frame 1, and the memory carries the object identity through occlusions across the clip, at ~44 fps. It is the same amortization lesson upgraded to time: encode each frame once, let cheap attention against cached memory replace re-prompting.
- Try the real thing (outside this air-gapped environment):
pip install "git+https://github.com/facebookresearch/sam2.git", download a checkpoint (e.g.sam2.1_hiera_small.pt), thenSAM2ImagePredictor.set_image(img)once followed by manypredict(point_coords=..., multimask_output=True)calls — you will find the API mirrorsencode_image/decode_promptfrom this lab almost method-for-method. Ultralytics and HuggingFacetransformers(facebook/sam2-hiera-small) ship wrappers as well.
Stretch goals
- Box prompts. Encode a box as two corner tokens (each =
γ(corner) + corner-role embedding), concatenate with the point token, and extendmake_datasetto emit boxes. Boxes are near-unambiguous — verify the 3 candidates converge for box prompts while still diverging for overlap points. - Iterative point refinement. SAM's interactive loop: after the first prediction, sample a correction click from the error region (foreground click in false-negative area, background click in false-positive area), feed both points back, repeat. Requires a background-point label embedding. Measure IoU vs number of clicks — it should climb monotonically.
- Distill the encoder. Train a 2-conv "student" encoder to match your trained encoder's features (MSE in feature space), freeze the decoder, and measure the IoU cost of the ~4× cheaper encoder. This is exactly the MobileSAM/EfficientSAM recipe (they distill TinyViT from ViT-H).
- Focal loss. Replace BCE with
(1−p_t)^γ-weighted BCE (γ=2) at SAM's 20:1 focal:dice weighting and compare convergence speed and boundary sharpness. - Remove a load-bearing part. Delete PE re-injection (pass zeros as
token_pe/image_peafter block 1) or the beacon, retrain, and watch held-out IoU collapse — the fastest way to convince yourself why each piece exists.
Interview Q&A
Q1: Why does SAM predict three masks and an IoU score for each, instead of a single mask?
Because a point prompt is inherently ambiguous — a click on a logo is consistent with the logo, the shirt, and the wearer — and ambiguity breaks single-output regression. If one output head is trained on data where the same input has several valid targets, the loss-minimizing prediction is the average of those targets: a blurry mask that is none of the valid answers. SAM instead emits K=3 candidates (empirically enough to cover whole/part/subpart nesting) and, during training, backpropagates the mask loss only through the candidate with the lowest loss on that sample. This winner-take-all assignment lets different mask tokens specialize into different interpretations — the multimodality moves from "impossible for one head" to "one head per mode". But it creates an inference problem: with no ground truth available, something must choose among the 3. That is the IoU head's job: trained with MSE to predict each candidate's actual IoU with ground truth, it lets the model rank its own outputs at deployment (return the argmax, or all three with scores). The same self-grading signal is what filtered SA-1B's fully-automatic masks — the head is both a serving component and a data-engine component. In this lab you can see all of it in miniature: on overlap clicks the trained candidates disagree (pairwise IoU ≈ 0.1), and held-out evaluation selects masks by the head alone.
Q2: Why is the encoder/decoder asymmetry the key serving insight in SAM?
Because it converts an interactive workload from "run a 632M-parameter model per click" into "run it once per image, then run a ~4M-parameter decoder per click". The design commits to a strict information contract: the image encoder never sees the prompt (so its output is a reusable function of the image alone), and the prompt encoder is trivially cheap (Fourier code + one embedding). All prompt-conditioned computation is pushed into a small decoder that fuses cached image features with prompt tokens via two-way attention in ~10 ms. The serving consequences compound: the embedding is computed once and cached (even precomputed offline for whole galleries); the heavy encoder can run server-side on GPU while the decoder runs client-side — in-browser SAM demos ship the decoder as an ONNX model; latency per click is decoder-only, which is what makes the tool feel real-time; and throughput scales with clicks-per-image, which in annotation workloads is high (SA-1B prompted a 32×32 grid = 1024 prompts against one cached embedding). SAM2 extends the same amortization through time: frames are encoded once into a memory bank, and cheap memory attention replaces re-prompting every frame. The general principle — locate the cut between per-item and per-query computation and cache at that boundary — transfers to retrieval (document embeddings vs query), recommendation (item towers vs user request), and LLM serving (KV-cache vs new tokens).
References
- Kirillov, A., Mintun, E., Ravi, N., Mao, H., Rolland, C., Gustafson, L., Xiao, T., Whitehead, S., Berg, A., Lo, W-Y., Dollár, P., Girshick, R. Segment Anything. arXiv:2304.02643 (2023).
- Ravi, N., Gabeur, V., Hu, Y-T., et al. SAM 2: Segment Anything in Images and Videos. arXiv:2408.00714 (2024).
- Lin, T-Y., Goyal, P., Girshick, R., He, K., Dollár, P. Focal Loss for Dense Object Detection. arXiv:1708.02002 (2017).
- Milletari, F., Navab, N., Ahmadi, S-A. V-Net: Fully Convolutional Neural Networks for Volumetric Medical Image Segmentation (the dice-loss paper). arXiv:1606.04797 (2016).
- Tancik, M., Srinivasan, P., Mildenhall, B., et al. Fourier Features Let Networks Learn High Frequency Functions in Low Dimensional Domains. arXiv:2006.10739 (2020).
- Vaswani, A., Shazeer, N., Parmar, N., et al. Attention Is All You Need. arXiv:1706.03762 (2017).
- Carion, N., Massa, F., Synnaeve, G., et al. End-to-End Object Detection with Transformers (DETR — origin of learned query tokens). arXiv:2005.12872 (2020).
- Guzmán-Rivera, A., Batra, D., Kohli, P. Multiple Choice Learning: Learning to Produce Multiple Structured Outputs (the min-loss-over-candidates ancestry). NeurIPS 2012.
Phase 7 — MLOps & Production Deployment
Weeks 17-18 of 20 | Bridge from research to production
What This Phase Covers
This phase teaches the entire model lifecycle from training to production: exporting models to portable formats, building inference APIs, containerizing with Docker, and tracking experiments with MLflow. These skills separate ML engineers who can train models from those who can deploy and maintain them.
Labs
| # | Lab | Core Skills |
|---|---|---|
| 01 | ONNX Export & Optimization | torch.onnx.export, ONNX graph, TensorRT, FP16/INT8 |
| 02 | FastAPI Inference Server | async API, dynamic batching, Prometheus metrics |
| 03 | Docker Deployment | Dockerfile, nvidia-docker, multi-stage build |
| 04 | MLflow Experiment Tracking | runs, artifacts, model registry, autolog |
Why MLOps Matters for Interviews
Most CV engineers have trained models. Fewer have shipped them. Interviewers at companies like Tesla, NVIDIA, and Apple specifically test:
- "How would you deploy this model to serve 10,000 requests/second?"
- "How do you roll back a bad model version?"
- "How do you detect when your model's accuracy degrades in production?"
Deployment Stack Overview
Model (PyTorch .pth)
│
├─→ ONNX (.onnx) ← portable, framework-agnostic
│ └─→ TensorRT ← NVIDIA GPU optimized engine
│
└─→ FastAPI server ← REST API for inference
└─→ Docker ← containerized, reproducible
└─→ Kubernetes (k8s) ← orchestration at scale
GPU/TPU Relevance
- TensorRT: converts FP32 models to FP16/INT8 with ~3-5× speedup on NVIDIA GPUs
- Triton Inference Server: production-grade dynamic batching on GPU clusters
- Quantization-aware training (QAT): prepare models for INT8 before export
Monitoring Checklist
- Inference latency (p50, p95, p99)
- GPU utilization and memory
- Request throughput (RPS)
- Prediction distribution drift
- Error rates (5xx, timeout)
Warmup Guide — MLOps
Zero-to-expert primer for Phase 07: the path from trained checkpoint to operated service — ONNX export, inference servers, containers, experiment tracking, and the monitoring that keeps a deployed model honest.
Table of Contents
- Chapter 1: The Gap Between a Model and a Product
- Chapter 2: ONNX — Export as a Contract
- Chapter 3: Inference Servers — FastAPI and Beyond
- Chapter 4: Preprocessing Parity — the Silent Killer
- Chapter 5: Containers — Reproducible Runtime
- Chapter 6: Experiment Tracking and the Model Registry
- Chapter 7: Monitoring and Drift
- Chapter 8: The CV Serving Performance Playbook
- Chapter 9: torch.compile and the Export Path — the 2026 PyTorch Deployment Story
- Chapter 10: Dedicated Inference Servers — Triton and the Dynamic-Batching Pattern
- Chapter 11: Quantization for CV Serving
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: The Gap Between a Model and a Product
A checkpoint answers "what are the weights"; a product must answer: how do requests reach it, in what format, at what latency and cost, with what failure behavior, which version is running, how do we know it still works on this month's data, and how do we roll back? The recurring industry observation (the Hidden Technical Debt paper's famous figure): the model is a small box in a large system of data plumbing, serving, configuration, and monitoring — and the system, not the model, is where production incidents live. This phase builds the small honest version of each surrounding box; the discipline transfers to any scale.
Chapter 2: ONNX — Export as a Contract
ONNX is the interchange format that decouples training framework from serving runtime (PyTorch → onnxruntime/TensorRT/OpenVINO; the deep compiler view lives in the model-accuracy track's Phase 05 — here, the practitioner's contract):
- Export (
torch.onnx.export, dynamo-based in modern PyTorch) is a capture of your model — and capture has the standard pitfalls: data-dependent Python control flow gets baked, training-mode layers (dropout/BN) must be in eval, and dynamic axes (batch, image size) must be declared or the graph hard-codes your example shape. - The non-negotiable ritual: after export, run identical inputs through PyTorch and onnxruntime and assert numerical agreement (rtol ~1e-3 for FP32 CV models) — on several shapes if axes are dynamic. An unverified export is a rumor. Then benchmark: onnxruntime with graph optimizations enabled is often 1.5–3× PyTorch eager on CPU — free speed, but measured, not assumed.
- What doesn't export: NMS and other post-processing sometimes does (as ONNX ops) and sometimes doesn't (Phase 05 Ch. 2's deployment surprise) — decide explicitly whether post-processing lives in-graph or in app code, and version them together.
Chapter 3: Inference Servers — FastAPI and Beyond
The lab's FastAPI service, and the concepts that outlive the framework choice:
- Load once, serve many: the model loads at startup (lifespan handler), never per request. Sessions/models are process-level singletons.
- Concurrency model: inference is CPU/GPU-bound —
asyncalone doesn't help (the GIL and the compute both block); throughput comes from worker processes (uvicorn/gunicorn workers) sized to cores-per-model-copy, or a dedicated inference runtime behind the API. - Batching: GPUs (and even vectorized CPU) love batches; HTTP delivers requests one at a time. Dynamic batching — queue requests for up to N ms or K items, run one batch — trades a small latency floor for large throughput gains. Triton/ TorchServe provide it off the shelf; the lab implements a micro version to own the concept (the LLM track's Phase 09 is this idea at its most extreme).
- API design for CV: accept common encodings (JPEG/PNG bytes, not raw arrays);
validate sizes and formats before the model; return structured predictions with
model-version metadata in every response (the debugging gift to your future self);
health (
/healthz) and readiness endpoints; explicit timeouts. - Know the landscape one level up: TorchServe/Triton/KServe/BentoML solve batching, multi-model, and GPU scheduling — the lab's hand-rolled server is for understanding, and saying so is the right production posture.
Chapter 4: Preprocessing Parity — the Silent Killer
The most common real-world CV serving bug, promoted to its own chapter: the serving path's preprocessing must be bit-faithful to training's — resize algorithm and library (PIL bilinear ≠ OpenCV bilinear ≠ torchvision antialias settings!), color order (Phase 01's BGR/RGB), normalization stats, dtype/range, crop policy. Each mismatch costs silent accuracy — the model serves, predictions look plausible, quality is just worse, and nothing errors.
Defenses, in order of strength: bake preprocessing into the exported graph where possible (resize+normalize as model layers — the Phase 04/TF Ch. 6 move, equally available in torch); a shared preprocessing module imported by both training and serving (one source of truth); and a golden-input test — N images with stored expected outputs, run in CI against the serving path, catching any drift in the dependency chain (a Pillow upgrade changing default resampling is a real incident class). The capstones inherit this test as a requirement.
Chapter 5: Containers — Reproducible Runtime
Docker for ML, the working subset:
- Why: "works on my machine" is lethal when the machine includes CUDA versions, system codecs (libjpeg versions change pixel values!), and Python dependency trees. The image pins the entire runtime.
- The craft: slim base images (
python:3.11-slim; CUDA runtime images only when GPU-serving), layer ordering for cache efficiency (requirements before code — dependency layers rebuild rarely, code layers often), multi-stage builds (build tools out of the final image),.dockerignore(no datasets/checkpoints in context), non-root user, pinned dependency versions (pip install -rwith hashes or a lock file). - Model weights don't belong in the image for anything beyond toys: mount or download-at-start from a registry/object store keyed by version (Ch. 6) — images ship code, registries ship models, and the two version axes stay independent.
- The mental model: a container is the deployment-time analog of the experiment config (Ch. 6) — the run is reproducible because everything is pinned.
Chapter 6: Experiment Tracking and the Model Registry
MLflow (the lab's tool; the concepts are universal — W&B etc. are isomorphic):
- Tracking: every training run logs params (full config), metrics (curves, not just finals), and artifacts (checkpoints, confusion matrices, sample-prediction grids) to a server. The discipline this enforces is the same ledger ethic every track's capstone demands: no untracked runs — an untracked result cannot be trusted, compared, or reproduced. Autologging gets you 80% for one line.
- The registry: models get names, versions, and stage labels (staging/production/archived) with transition history — "which model is in prod" becomes a query, not a Slack thread. Promotion is a recorded decision with the evaluation evidence attached (Phase 02/08 ethics: the eval that justified promotion is part of the artifact).
- Lineage: a production prediction should be traceable to model version → training run → config + data version → code commit. Each link is one logging call at the right moment; the chain is what incident response runs on.
Chapter 7: Monitoring and Drift
The model is now a service that degrades silently. Three monitoring layers:
- System: latency percentiles (p50/p99), throughput, error rates, GPU/CPU utilization — ordinary SRE, plus model-version tags on every metric.
- Input/data monitoring: the live input distribution vs training — image statistics (brightness, blur/sharpness scores, resolution mix), prediction distribution (class frequencies, confidence histograms). Drift detection is distribution comparison over windows (PSI/KL on binned statistics) — alarms that say "the world changed" before labels arrive. The canonical CV examples: a camera moved/dirtied, a season changed, an upstream app changed JPEG compression.
- Outcome monitoring: when ground truth arrives (delayed labels, human review, downstream corrections), score it — sampled human audit where labels never arrive. The flywheel that closes the loop: production failures get harvested into the eval set and the next training set (every track's evaluation phase preaches this; here is where it physically happens).
And the operational basics that make fixes shippable: shadow deployment (new model scores silently alongside prod), canary rollout (small traffic share + auto-rollback on metric regression), and the rollback path tested before it's needed.
Chapter 8: The CV Serving Performance Playbook
The latency budget of a CV service is usually not dominated by the model: decode (JPEG → pixels), resize, host↔device copies, and post-processing routinely exceed the forward pass. The playbook, in measurement-first order:
- Profile the whole request (timestamps per stage — decode/preprocess/infer/post) before optimizing anything (the universal lesson, again).
- Cheap wins: ONNX runtime/graph optimizations (Ch. 2), FP16 on GPU, right-sized input resolution (accuracy vs latency curve — Phase 05's operating point), turbojpeg/GPU decode when decode dominates.
- Throughput wins: dynamic batching (Ch. 3), worker scaling, pinned-memory async copies.
- Bigger guns, with eval gates: INT8 quantization (calibrated, accuracy-measured — the model-accuracy track's Phase 03 ladder), TensorRT/compiled backends, distilled or smaller architectures.
- Re-measure end-to-end after each step; keep the latency budget table in the repo.
Chapter 9: torch.compile and the Export Path — the 2026 PyTorch Deployment Story
Chapter 2 treated export as a contract with a foreign runtime. Since PyTorch 2 there is a second road — make PyTorch itself fast enough to serve — and its mechanics are worth owning because its failure modes are shape-shaped, and CV is the land of variable shapes.
- Why eager mode leaves speed on the table: eager executes the model as ordinary Python — every op is an interpreter round-trip plus a separate kernel launch, and every intermediate tensor is written to and re-read from GPU global memory. For conv-heavy models the big conv/GEMM kernels dominate and the overhead is modest; but normalizations, activations, and glue ops are memory-bandwidth-bound, and an unfused chain of them can cost as much as the convs. A captured graph enables what per-op execution cannot: fusion (compile a pointwise chain into one kernel, so data stays in registers instead of touching DRAM three times), static memory planning (buffer reuse computed once), and whole-graph scheduling.
- torch.compile mechanics, at working altitude: TorchDynamo hooks Python bytecode execution and traces the tensor operations into an FX graph, recording alongside it a set of guards — the assumptions under which that trace is valid (input shapes and dtypes, Python values that were branched on, module identities). Inductor then compiles the graph into fused Triton kernels (GPU) or vectorized C++ (CPU). On every later call the guards are checked cheaply: hold → run the compiled artifact; fail (a new input shape, a changed flag) → recompile, which costs seconds. Python constructs Dynamo can't trace cause graph breaks: the model still runs, split into compiled segments with eager gaps between — correct, silently slower.
- Guards are the classic CV pitfall — be honest about them. Variable image sizes
(aspect-preserving resize, dynamic crops, variable batch from an upstream batcher)
mean each new shape is a guard failure → a recompile in the serving path → a p99
horror story while p50 looks great. Defenses:
torch.compile(dynamic=True)(symbolic shapes — one graph covers a shape range, at the cost of some shape-specialized optimizations), or shape bucketing — pad/resize inputs to a small fixed set of shapes and warm each one up. And know the failure of the failure: past a recompile limit, Dynamo falls back to eager silently — your "compiled" service may not be. Log recompile counts as a first-class metric. - torch.export + AOTInductor vs ONNX export:
torch.exportis whole-graph, ahead-of-time capture — no eager fallback, so unsupported constructs fail loudly at export time instead of graph-breaking at runtime — and AOTInductor compiles the exported graph into a Python-free artifact (a shared library plus weights) loadable from C++ servers. When each wins in 2026: ONNX when the target is the cross-vendor ecosystem — onnxruntime execution providers, TensorRT, OpenVINO, NPU toolchains — or the serving stack must stay framework-neutral; torch.export/ AOTInductor when you stay inside the PyTorch world and want to skip opset-conversion breakage (the ops that "sometimes don't export" — NMS again, Ch. 2). Both are maintained first-class paths; Chapter 2's verification ritual applies identically to either — an unverified compiled artifact is still a rumor. - ExecuTorch, one paragraph: the mobile/edge leg of the same story —
torch.exportgraphs lowered to a compact runtime whose delegates hand subgraphs to device accelerators (XNNPACK on CPU, Core ML, Qualcomm QNN). It is PyTorch's counterpart to the TFLite pipeline in Phase 04: same export-then-delegate shape, same quantize-and-verify obligations. - Practical serving guidance: compile in the serving process at startup (compiled state is process-local unless you configure a compile cache); send warmup requests covering every expected shape bucket before the readiness probe reports ready (Ch. 3's health-vs-readiness distinction earns its keep here); pin or bucket input shapes; and alert on the recompile counter — it is the smoking gun for the Q&A below.
The deep dive behind this chapter is the model-accuracy track's Phase 04 (Dynamo/FX/Inductor internals).
Chapter 10: Dedicated Inference Servers — Triton and the Dynamic-Batching Pattern
The Chapter 3 FastAPI loop is the right learning server and, unmodified, a throughput ceiling: one request per forward pass means the GPU runs at batch-1 — mostly idle — because HTTP has no notion of cross-request batching. The lab's micro batcher taught the concept; the production version needs per-model queues and schedulers, priorities, multiple model instances, versioned reloads, and metrics — undifferentiated heavy lifting, which is exactly what a dedicated inference server is.
- Dynamic batching from first principles: the server keeps a queue per model;
requests accumulate until
max_batch_sizeis reached ormax_queue_delayexpires, whichever comes first; one forward pass serves the whole batch. The dial: longer delay → fuller batches → higher throughput, but every request pays the queue floor and tail-couples to its batchmates — the same trade Chapter 3 and its Q&A quantified, now as two config numbers. It is also the same queue-vs-utilization math the LLM track pushes to its extreme with continuous batching; the CV version is simpler because forward passes are uniform-length. - Triton Inference Server anatomy (NVIDIA's, and the current default answer):
- Model repository: a directory tree —
model-name/version/artifact— as the unit of deployment; models load/unload by polling or explicit API, so rollout is file placement plus a version bump. - Per-model config (
config.pbtxt): declares inputs/outputs (names, dtypes, shapes),max_batch_size, and thedynamic_batchingblock (queue delay, preferred batch sizes) — batching is configuration, not code you maintain. - Instance groups: N execution instances of a model per GPU (or pinned across GPUs); concurrent instances overlap compute with transfers and let several small models — or several copies of one — keep a large GPU busy.
- Ensembles and BLS: an ensemble is a server-side DAG of models (decode/preprocess → model → postprocess, with pre/post as Python-backend models) — which moves preprocessing parity (Ch. 4) into the versioned serving graph; BLS (Business Logic Scripting) is Python that calls models programmatically when the pipeline needs real control flow.
- Multi-framework backends: ONNX Runtime, TensorRT, PyTorch/LibTorch, Python,
OpenVINO behind one API — plus standardized Prometheus metrics and
perf_analyzerfor load-testing the exact config you wrote.
- Model repository: a directory tree —
- When Triton over FastAPI, honestly: it earns its complexity when GPUs must run hot at scale, when several models share hardware, or when you want fleet-standard metrics and deployment machinery. It is overkill for one small CPU model at modest traffic — a FastAPI + onnxruntime container (Labs 02–03) is simpler to debug, deploy, and staff for. Knowing which regime you are in, and saying so, is the production posture this guide keeps preaching.
- TorchServe, one line of 2026 realism: it moved to limited-maintenance status in 2025 (no new features, minimal fixes) — betting new infrastructure on it now is a risk you would have to defend; the ecosystem consolidated on Triton, KServe, BentoML, and Ray Serve.
Chapter 11: Quantization for CV Serving
Chapter 8 stationed INT8 among the "bigger guns"; here is the mechanism, because you cannot eval-gate what you don't understand.
- INT8 PTQ (post-training quantization) from first principles: represent FP32 values with 8-bit integers via an affine map \( x \approx s,(q - z) \) — scale \( s \), zero-point \( z \), 256 representable levels. Weights are known at conversion time and quantize directly (symmetric, \( z = 0 \)). Activations depend on inputs, so their ranges come from calibration: run a few hundred representative images through the FP32 model, record per-tensor activation statistics, and choose ranges — min/max, percentile, or entropy/KL-based clipping that sacrifices outliers to buy resolution where the distribution's mass lives. Calibration data quality is a silent accuracy input: calibrate on clean daytime images and deploy on night-time dashcam, and every range is wrong.
- Per-tensor vs per-channel: one \( (s, z) \) pair for a whole tensor versus one per output channel. Conv weights need per-channel: filter magnitudes routinely span orders of magnitude across channels, and a single tensor-wide scale sized for the largest channel crushes the small ones toward zero. The standard settlement: per-channel weights, per-tensor activations.
- What breaks, and how it hides: outlier activations stretch a range so the useful mass gets a handful of levels; fine spatial features and small objects degrade first and silently — a classifier's top-1 may drop 0.3 while a detector's AP_small drops 4. The non-negotiable rule: re-run the full evaluation suite — mAP with the size breakdown, per-class numbers, the golden-input set (Ch. 4) — before an INT8 artifact is promoted. A single headline metric is how quantization regressions reach production.
- QAT (quantization-aware training) in one paragraph: insert fake-quantize ops (quantize→dequantize round trips) into the training graph so the forward pass experiences quantization error while gradients flow through via the straight-through estimator; the weights migrate to quantization-friendly minima. It costs a training run and the pipeline plumbing, and recovers most of what PTQ loses on sensitive models — which is why it sits after PTQ on the ladder, not before.
- FP16/BF16 is the free first step on GPU: half the memory traffic, tensor-core throughput, and effectively zero accuracy change for CV inference (Ch. 8 already stations it before INT8); BF16 keeps FP32's exponent range, so overflow paranoia disappears. FP8 is the 2026 arrival on Hopper/Blackwell-class hardware — mainstream in LLM serving, reaching CV through TensorRT — same discipline: calibrate the scales, verify on the full suite.
- The decision ladder: FP16/BF16 → INT8 PTQ → QAT → smaller/distilled model. Each rung costs more engineering; each is gated by the full eval; you step down only when the cheaper rung measurably misses your latency or accuracy bar — write the measurement into the decision record (Ch. 6's promotion-with-evidence ethic).
The deep dive behind this chapter is the model-accuracy track's Phase 03 (quantization and compression at full depth).
Lab Walkthrough Guidance
Order: labs 01→04 as numbered.
- Lab 01 (ONNX export): export your Phase 05 detector or Phase 03 classifier with dynamic batch; run the verification ritual (multi-shape numerical parity) and the PyTorch-vs-onnxruntime benchmark table; decide and document where NMS lives.
- Lab 02 (FastAPI service): lifespan-loaded model, bytes-in/structured-JSON-out,
validation, version metadata, health endpoint; add the micro dynamic batcher and
show its throughput/latency trade with a load test (
locust/hey); write the golden-input test (Ch. 4). - Lab 03 (Docker): containerize lab 02 with multi-stage build + pinned deps; weights mounted, not baked; measure image size before/after the craft items; compose a two-service stack (API + tracking server).
- Lab 04 (MLflow): instrument your Phase 03/05 training scripts; run a small sweep, compare in the UI; register the winner, promote staging→production with the eval evidence attached; have the serving container resolve "production" from the registry at startup — the full loop, closed.
Success Criteria
You are ready for Phase 08 when you can, from memory:
- Name the export pitfalls and recite the verification ritual; argue where post-processing should live.
- Explain why async ≠ throughput for inference, and what dynamic batching trades.
- List five preprocessing-parity failure modes and the three defenses.
- Apply the Docker craft list and justify weights-outside-images.
- Describe tracking/registry/lineage and the promotion-with-evidence rule.
- Sketch the three monitoring layers with one CV-specific drift example each.
- Order the performance playbook and defend measurement-first.
- Explain what a captured graph buys over eager execution, what a guard is, and why variable image sizes make torch.compile a p99 hazard — plus the two defenses.
- Sketch Triton's anatomy (repository, per-model config, instance groups, ensembles) and argue both when it beats a FastAPI container and when it's overkill.
- Walk INT8 PTQ end to end (scale/zero-point, calibration, per-channel weights), and defend the full-eval-suite gate and the FP16 → PTQ → QAT → smaller-model ladder.
Interview Q&A
Q: Accuracy in production is 8 points below your offline eval. Where do you look? In likelihood order: preprocessing parity (resize library/antialias, BGR/RGB, normalization — run the golden-input test first; it localizes this family instantly); distribution shift (compare live input stats vs training — camera, compression, season); eval set unrepresentativeness (offline set too clean — the gap was always there); version skew (is prod serving the model you think? — check the version-metadata field you wisely put in every response); label-definition drift in how production "correct" is being judged. The ordered list, with the golden-input test as step one, is the answer.
Q: Why is dynamic batching a bigger deal for GPU serving than CPU, and what's its cost? GPU kernels amortize launch overhead and saturate parallelism with batches — batch-8 often costs barely more wall-clock than batch-1, so batching multiplies throughput; CPU inference scales more linearly per item (vectorization helps but less dramatically). The cost: a queuing delay floor (max-wait window) added to every request's latency, plus tail coupling (your request waits on the batch's slowest companion). Tune window/size against the p99 SLO — and below certain traffic, the batch never fills and the feature is pure latency tax; turn it off.
Q: Walk me through deploying a retrained model safely. Registry promotion with attached eval (paired comparison vs current prod on the fixed eval set + the golden inputs); shadow deployment scoring live traffic silently — compare prediction distributions and disagreement rates; canary to ~5% with auto-rollback on system + quality metrics; ramp; keep the old version warm for instant rollback; harvest disagreement cases into the eval set. Each step exists because some team skipped it once — being able to say what each step catches is the senior version of the answer.
Q: Your p99 latency doubled after enabling torch.compile in prod — what happened?
Recompilation — almost certainly guard failures on dynamic shapes. Each compiled graph
is valid only under its guards (recorded input shapes, dtypes, branched-on values);
production CV traffic with variable image sizes or variable batch sizes from an
upstream batcher presents shapes the warmup never saw, each one failing guards and
triggering a recompile whose seconds of compile time get charged to some unlucky
request. The signature: p50 fine (hot shapes hit cached graphs), p99 wrecked (novel
shapes pay compilation). Confirm it: enable Dynamo's recompile logging / export the
recompile counter, and correlate p99 spikes with first-seen input shapes. Fix it:
bucket or pad inputs to a small fixed shape set and send warmup requests through
every bucket before the readiness probe passes; or dynamic=True and accept some
kernel-quality loss. Then check the failure of the failure: past the recompile limit
Dynamo silently falls back to eager — you may now be paying compile stalls without
even keeping compiled speed. Secondary suspects worth naming to show breadth: warmup
not re-run after a deploy/restart, and CUDA-graph capture interacting badly with
variable batch sizes.
Q: Design the batching config for a GPU box serving 3 CV models with a 100 ms SLO.
Start from the budget, not the config: 100 ms p99 end-to-end minus network, decode/
preprocess, and post-processing (Ch. 8 says these dominate — measure them) leaves
maybe 50–60 ms for queue + forward. Per model, measure the latency-vs-batch-size
curve (perf_analyzer), pick the largest batch whose forward time leaves headroom
inside that budget, and set max_queue_delay ≈ budget − forward-at-max-batch −
headroom — e.g., a model at 15 ms/batch-8 can afford a ~20–30 ms delay cap. Then the
part single-model thinking misses: three models contend — kernels serialize and
SMs are shared, so each model's tail lengthens under the mix; configure instance
groups so small models overlap, and validate against the mixed production-like
load, never per-model microbenchmarks. Respect the traffic floor: a low-QPS model's
queue never fills, so its delay window is pure latency tax (Ch. 3's honest case) —
set its delay near zero or disable batching for it. Finish like an operator: p99
alerting per model, the config in version control, and a load test that replays the
real request mix before any of it ships.
References
- Sculley et al., Hidden Technical Debt in Machine Learning Systems (NeurIPS 2015) — the small-box figure
- torch.onnx export docs and onnxruntime performance docs
- FastAPI docs — lifespan, concurrency notes; Triton Inference Server docs — read the dynamic-batching section even if not using Triton
- Docker best practices for Python and multi-stage builds
- MLflow docs — tracking + model registry
- Breck et al., The ML Test Score (Google, 2017) — the production-readiness rubric worth self-grading against
- Ansel et al., PyTorch 2: Faster Machine Learning Through Dynamic Python Bytecode Transformation and Graph Compilation (ASPLOS 2024) — arXiv:2403.00873 — the TorchDynamo/Inductor paper
- torch.compile docs, torch.export/AOTInductor docs, and ExecuTorch docs — the three legs of the native deployment path
- Triton model configuration docs —
config.pbtxt, dynamic batching, instance groups - Wu et al., Integer Quantization for Deep Learning Inference: Principles and Empirical Evaluation (2020) — arXiv:2004.09602 — the INT8 whitepaper
- Gholami et al., A Survey of Quantization Methods for Efficient Neural Network Inference (2021) — arXiv:2103.13630
- The model-accuracy track's Phase 05 (ONNX/compilers at depth) and the LLM track's Phase 09 (serving at its hardest)
- The model-accuracy track's Phase 03 (quantization at depth) and Phase 04 (torch.compile/FX internals) — the deep dives behind Chapters 9 and 11
Lab 7-01: ONNX Export & Model Optimization
Learning Goals
- Export PyTorch models to ONNX with proper dynamic axes
- Inspect and validate ONNX graphs
- Profile FP32 vs FP16 inference latency
- Understand quantization (INT8) trade-offs
- Apply TorchScript as a lightweight alternative
Core Concepts
Why ONNX?
ONNX (Open Neural Network Exchange) is an open format that makes models portable across frameworks and runtimes:
- Train in PyTorch → deploy in C++, TensorFlow, CoreML, or TensorRT
- ONNX Runtime (ORT) is often faster than vanilla PyTorch on CPU
- TensorRT converts ONNX to GPU-optimized engines
Export Mechanics
import torch
model = MyModel()
model.eval()
dummy_input = torch.randn(1, 3, 224, 224)
torch.onnx.export(
model,
dummy_input,
"model.onnx",
input_names=["image"],
output_names=["logits"],
dynamic_axes={
"image": {0: "batch_size"}, # batch dim is dynamic
"logits": {0: "batch_size"},
},
opset_version=17,
do_constant_folding=True, # fuse constant ops
)
Graph Validation
import onnx
model_proto = onnx.load("model.onnx")
onnx.checker.check_model(model_proto) # raises exception if invalid
print(onnx.helper.printable_graph(model_proto.graph))
Inference with ONNX Runtime
import onnxruntime as ort
import numpy as np
sess = ort.InferenceSession("model.onnx",
providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
# CUDAExecutionProvider uses GPU if available, falls back to CPU
inp = np.random.randn(1, 3, 224, 224).astype(np.float32)
outputs = sess.run(None, {"image": inp})
print(outputs[0].shape)
FP16 Quantization with ONNX Runtime
from onnxruntime.transformers import optimizer
from onnxruntime.quantization import quantize_dynamic, QuantType
# Dynamic INT8 quantization (CPU-only)
quantize_dynamic("model.onnx", "model_int8.onnx",
weight_type=QuantType.QInt8)
Latency Math
$$\text{Throughput} = \frac{1}{\text{latency_per_batch}} \times \text{batch_size}$$
For a model with 10ms latency at batch=1:
- FP32: 10ms → 100 fps
- FP16: ~5ms → 200 fps
- INT8: ~3ms → 333 fps
- TensorRT FP16: ~2ms → 500 fps
Interview Questions
Q: What does do_constant_folding=True do?
A: Pre-computes operations whose inputs are known at export time (e.g., batch norm statistics after folding), removing them from the inference graph.
Q: What's a dynamic axis? When do you need one?
A: A tensor dimension that isn't fixed at export time. Batch size is almost always dynamic. If your model handles variable-length sequences or variable image sizes, those dims must also be dynamic.
Q: What's the difference between FP16 and INT8?
A: FP16 uses 16-bit floating point (range: ~6×10⁻⁵ to 65504). INT8 uses 8-bit integer (range: -128 to 127). INT8 requires calibration data to compute activation ranges; FP16 is lossless for most models. INT8 is ~2× faster than FP16 but risks > 1% accuracy loss without QAT.
Q: When would you use TorchScript instead of ONNX?
A: TorchScript is better when deploying within NVIDIA's ecosystem, when you need Python-free C++ deployment, or when your model has Python control flow (if/else, loops) that ONNX can't represent well.
Lab 7-02: FastAPI Inference Server
Learning Goals
- Build a production-ready REST API for ML model inference
- Implement async request handling and dynamic batching
- Add health checks, Prometheus metrics, and request logging
- Handle concurrent requests without blocking the GPU
Core Concepts
Why FastAPI?
FastAPI is the standard for Python ML serving because:
- Native async support via
asyncio - Automatic OpenAPI/Swagger docs
- Pydantic validation for request/response schemas
- 2-3× faster than Flask for concurrent workloads
Dynamic Batching
GPU utilization is maximized by batching requests together. The key tradeoff:
Latency ↑ (wait for batch to fill)
vs
Throughput ↑ (process more requests per second)
Strategy: Collect requests for a configurable window (e.g., 10ms), then process as one batch.
import asyncio
batch_queue: asyncio.Queue = asyncio.Queue()
async def batch_processor():
while True:
batch, futures = [], []
# Collect requests for max 10ms or until batch is full
deadline = asyncio.get_event_loop().time() + 0.010
while len(batch) < MAX_BATCH and asyncio.get_event_loop().time() < deadline:
try:
item, future = await asyncio.wait_for(
batch_queue.get(), timeout=max(0, deadline - asyncio.get_event_loop().time())
)
batch.append(item)
futures.append(future)
except asyncio.TimeoutError:
break
if batch:
results = model_inference(torch.stack(batch))
for result, future in zip(results, futures):
future.set_result(result)
Request/Response Schemas
from pydantic import BaseModel
import base64
class PredictRequest(BaseModel):
image_b64: str # Base64-encoded image
confidence_threshold: float = 0.5
class Detection(BaseModel):
label: str
confidence: float
bbox: list[float] # [x1, y1, x2, y2]
class PredictResponse(BaseModel):
detections: list[Detection]
inference_ms: float
Prometheus Metrics
from prometheus_client import Counter, Histogram, Gauge, generate_latest
REQUEST_COUNT = Counter("inference_requests_total", "Total inference requests")
LATENCY = Histogram("inference_latency_seconds", "Inference latency",
buckets=[.005, .01, .025, .05, .1, .25, .5, 1])
BATCH_SIZE = Histogram("inference_batch_size", "Batch sizes processed",
buckets=[1, 2, 4, 8, 16, 32])
@app.get("/metrics")
async def metrics():
return Response(generate_latest(), media_type="text/plain")
Interview Questions
Q: How do you handle a slow model that takes 500ms per request?
A: Use a background worker pool with a queue. Requests post to the queue and poll for results. This prevents blocking and allows concurrency. Alternatively, use Celery + Redis for distributed task queues.
Q: What's the difference between async def and def in FastAPI?
A: async def handlers are run in the async event loop — good for I/O-bound work. def handlers run in a thread pool — FastAPI handles this automatically. For CPU-bound inference, use def or offload to a ProcessPoolExecutor to avoid blocking the event loop.
Q: How do you prevent OOM on the GPU server?
A: Cap concurrent requests with a asyncio.Semaphore(MAX_CONCURRENT=4). Also limit input image size and batch size. Add an /health check that monitors GPU memory usage.
Lab 7-03: Docker Deployment
Learning Goals
- Write a production Dockerfile for a PyTorch inference service
- Use multi-stage builds to minimize image size
- Configure nvidia-docker2 for GPU access in containers
- Set resource limits and health checks in docker-compose
Core Concepts
Multi-Stage Dockerfile
# ── Stage 1: Builder (install deps, compile wheels) ──────────────────────────
FROM python:3.11-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
# ── Stage 2: Runtime (copy only what's needed) ───────────────────────────────
FROM python:3.11-slim AS runtime
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /root/.local /root/.local
# Copy application code
COPY solution.py .
ENV PATH=/root/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1
# Health check
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
EXPOSE 8000
CMD ["uvicorn", "solution:app", "--host", "0.0.0.0", "--port", "8000"]
GPU-Enabled Dockerfile
# Use NVIDIA CUDA base image for GPU support
FROM nvcr.io/nvidia/pytorch:24.01-py3 AS runtime
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY solution.py .
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "solution:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
docker-compose with GPU
version: "3.9"
services:
inference:
build: .
ports:
- "8000:8000"
deploy:
resources:
limits:
cpus: "4.0"
memory: 8G
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
environment:
- CUDA_VISIBLE_DEVICES=0
volumes:
- ./outputs:/app/outputs
restart: unless-stopped
Key Docker Commands
# Build image
docker build -t cv-inference:latest .
# Run with GPU
docker run --gpus all -p 8000:8000 cv-inference:latest
# Check container health
docker inspect --format='{{.State.Health.Status}}' <container_id>
# View logs
docker logs -f <container_id>
# Resource stats
docker stats <container_id>
Interview Questions
Q: Why use multi-stage builds for ML containers?
A: PyTorch + dependencies can be 3-5 GB. Multi-stage builds separate the compilation/installation environment from the runtime. The final image contains only the installed packages, not build tools, reducing size by 30-60%.
Q: How do you handle model weights in a Docker container?
A: Three options: (1) Bake into the image with COPY — simple but makes the image large; (2) Mount as a Docker volume — flexible, image stays small; (3) Download at startup from S3/GCS — best for production with versioned models. Option 3 is preferred: use boto3 or gsutil to pull the specific model version on container startup.
Q: What's the difference between docker run --gpus all and a CPU container?
A: --gpus all requires nvidia-container-toolkit installed on the host. It exposes CUDA devices to the container. Without it, CUDA_VISIBLE_DEVICES is empty and PyTorch falls back to CPU. In Kubernetes, this is handled by the NVIDIA GPU device plugin (nvidia.com/gpu: 1 in resource requests).
Lab 7-04: MLflow Experiment Tracking
Learning Goals
- Track ML experiments with MLflow: parameters, metrics, and artifacts
- Compare runs across experiments
- Register and version models in the MLflow Model Registry
- Use
mlflow.pytorch.autolog()for zero-boilerplate tracking
Core Concepts
MLflow Architecture
MLflow Tracking Server
├── Experiments (logical grouping)
│ └── Runs (one training run)
│ ├── Parameters (hyperparameters)
│ ├── Metrics (loss, accuracy per step)
│ └── Artifacts (model weights, plots, code)
└── Model Registry
└── Registered Models
└── Versions (staging → production)
Basic Usage
import mlflow
mlflow.set_experiment("chest_xray_classifier")
with mlflow.start_run(run_name="densenet121_v3") as run:
# Log hyperparameters (logged once)
mlflow.log_params({
"model": "DenseNet121",
"optimizer": "Adam",
"lr": 1e-4,
"batch_size": 32,
"epochs": 50,
})
for epoch in range(epochs):
train_loss = train_one_epoch(...)
val_auc = evaluate(...)
# Log metrics per step
mlflow.log_metrics({
"train_loss": train_loss,
"val_auc": val_auc,
}, step=epoch)
# Log trained model
mlflow.pytorch.log_model(
model,
artifact_path="model",
registered_model_name="chest_xray_classifier",
)
# Log any file as artifact
mlflow.log_artifact("outputs/roc_curve.png")
print(f"Run ID: {run.info.run_id}")
Model Registry Workflow
from mlflow.tracking import MlflowClient
client = MlflowClient()
# Transition to staging
client.transition_model_version_stage(
name="chest_xray_classifier",
version=3,
stage="Staging",
)
# Load model for inference
model = mlflow.pytorch.load_model(
"models:/chest_xray_classifier/Staging"
)
Autolog
mlflow.pytorch.autolog(
log_every_n_epoch=1,
log_models=True,
checkpoint=False, # don't log every checkpoint
)
# Now just train — MLflow captures everything automatically
trainer.fit(model, dataloader)
Interview Questions
Q: What's the difference between log_param and log_metric?
A: Parameters are static hyperparameters logged once (learning rate, model architecture). Metrics are time-series values logged per step/epoch (loss, accuracy). MLflow stores metrics with a step index so you can plot them over training.
Q: How do you compare 10 runs and find the best model?
A: Use client.search_runs(experiment_ids=["1"], order_by=["metrics.val_auc DESC"], max_results=10). This returns runs sorted by validation AUC. You can also use the MLflow UI at mlflow ui --port 5000.
Q: What's the Model Registry used for?
A: It provides a governance layer: models move through stages (None → Staging → Production → Archived). This enables CI/CD for ML — automated tests must pass before a model moves to Production. Multiple teams can see what's deployed without digging through run IDs.
Phase 8 — Capstone Projects
Weeks 19-20 of 20 | Portfolio-worthy end-to-end systems
Overview
These three capstone projects demonstrate that you can build complete, production-quality CV systems, not just train models. Each project combines skills from all previous phases into a coherent deliverable you can present in interviews.
Projects
| # | Project | Key Skills Demonstrated |
|---|---|---|
| 01 | Real-Time Object Detection Pipeline | YOLOv8-style inference + FastAPI + Docker + monitoring |
| 02 | Face Recognition System | Face detection + ArcFace embedding + FAISS search |
| 03 | Medical Image Segmentation | U-Net + Dice loss + MLflow + ONNX export |
How to Present These in Interviews
The Portfolio Narrative
Don't just say "I trained a YOLO model." Say:
"I built a real-time detection system that processes synthetic camera feeds at 30 FPS, deployed it as a FastAPI service with dynamic batching, containerized it with Docker, and added latency/throughput monitoring. The end-to-end pipeline goes from raw frames to JSON detection events in under 80ms."
The Numbers Rule
Every capstone should have:
- Latency benchmark (ms at p50 and p95)
- Throughput (fps or requests/second)
- Accuracy metric (mAP, Dice, top-1 accuracy)
- Model size (MB) and parameter count
What Interviewers Look For
- End-to-end thinking: can you take a problem from data to deployment?
- Engineering discipline: clean code, proper abstractions, error handling
- Metric-driven mindset: do you know how good your system actually is?
- Production awareness: can it handle load? can you monitor it?
Warmup Guide — Capstone
Orientation for Phase 08. Three capstones — realtime detection, face recognition, medical segmentation — each composing Phases 00–07 into a system with measured properties and a domain-specific hard part. This warmup maps the composition and sets the bar.
Table of Contents
- Chapter 1: What the Capstones Certify
- Chapter 2: Capstone 01 — Realtime Detection: the Latency Contract
- Chapter 3: Capstone 02 — Face Recognition: Open-Set Thinking
- Chapter 4: Capstone 03 — Medical Segmentation: Stakes and Statistics
- Chapter 5: The Shared Engineering Bar
- Chapter 6: Portfolio Conversion
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: What the Capstones Certify
Same shift as every track's capstone (the model-accuracy and LLM capstone warmups state it identically — it's one discipline): decisions over implementations, numbers over claims, failure handling over happy paths. The CV-specific addition: each capstone has a domain hard part that the naive port of earlier labs gets wrong — realtime's latency contract, recognition's open-set problem, medical's label-scarcity-plus-stakes. The capstone is passed when the domain hard part is named, measured, and engineered for — not when the demo runs.
Chapter 2: Capstone 01 — Realtime Detection: the Latency Contract
Compose: Phase 05 (YOLO fine-tune) + Phase 01 (tracking) + Phase 07 (serving/perf playbook).
The hard part: "realtime" is a contract, not an adjective — state it as numbers (e.g., 720p @ 25 FPS sustained, p99 end-to-end < 80 ms, on this hardware) and engineer the whole pipeline to it:
- The budget table is the centerpiece artifact: capture → decode → preprocess → inference → NMS/post → tracking → render, measured (Phase 07 Ch. 8's playbook). Expect decode and pre/post to rival inference; optimize what the table says.
- Tracking is the realtime multiplier: detector at 10 FPS + light tracker (SORT: Kalman + IoU association — Phase 01 Ch. 7) in between = smooth 30 FPS output with per-object identity; detection-interval vs drift is a tunable you sweep and plot.
- Throughput mechanics: frame dropping policy (process-latest beats process-all — a queue that backs up is a latency bomb), resolution/model-size operating point (Phase 05 Lab 01's curve, now load-bearing), FP16/INT8 with eval gates.
- Honest evaluation: sustained-load measurement (thermals throttle — minutes, not seconds), accuracy under the realtime config (the deployed operating point, not the offline best), and the failure demo: what happens at 2× expected object density.
Chapter 3: Capstone 02 — Face Recognition: Open-Set Thinking
Compose: Phase 03/06 (embedding backbone) + Phase 02 (evaluation discipline) + Phase 07 (serving).
The hard part: recognition is not classification — the people at deployment time were not in the training set. The architecture is metric learning: an embedding network maps faces to vectors where same-person distance < different-person distance; "who is this" = nearest enrolled embedding if close enough.
- The pipeline: detect face → align (landmarks → canonical pose — alignment quality moves accuracy more than model choice; Phase 01 geometry pays off) → embed (a pretrained margin-loss network — ArcFace-class: softmax with an angular margin that forces inter-class separation on the hypersphere; understand the loss even though you won't train it from scratch) → match against the enrolled gallery.
- The threshold is the product decision: same-person/different-person distance distributions overlap; the threshold sets FAR (false accept) vs FRR (false reject), and the right point depends on the application (unlocking a phone vs flagging a watchlist are opposite asymmetries — Phase 02 Ch. 6's cost-based threshold logic, with teeth). Deliverable: the ROC/DET curve on your gallery + the chosen operating point defended in writing.
- Open-set evaluation: include impostors (people not enrolled) in every eval; report identification (rank-1) and verification (FAR@FRR) metrics; small galleries flatter — say so.
- The responsibility chapter: face recognition has documented demographic accuracy disparities (NIST FRVT findings) and real misuse potential. The capstone takes a position: evaluate across demographic slices of your eval data where feasible, document limitations explicitly, and state appropriate-use boundaries in the README. Treating this as integral engineering — not a disclaimer — is part of the bar.
Chapter 4: Capstone 03 — Medical Segmentation: Stakes and Statistics
Compose: Phase 05 (U-Net) + Phase 02 (evaluation/statistics) + Phase 06 (foundation features, optionally) + Phase 07 (reproducibility).
The hard part: small data, expensive expert labels, and consequences — the domain where this curriculum's evaluation ethics either hold or are exposed.
- Data reality: dozens-to-hundreds of studies, not millions of images. Augmentation is load-bearing (elastic deformations — the U-Net paper's original trick — plus intensity transforms matched to the modality); patient-level splits are non-negotiable (slices from one patient straddling train/test is Phase 02's group leakage at its most flagrant — and the most common published error in the field).
- Metrics that match the clinic: Dice (overlap) + boundary distance metrics (Hausdorff 95 — a segmentation can have great Dice and clinically unacceptable boundary excursions); per-case distributions, not just means (the worst case is the clinically relevant one — report it); confidence intervals over cases (n is small; Phase 02 Ch. 7's spread, mandatory here).
- Uncertainty and abstention: a model that flags low-confidence cases for human review is deployable; one that silently segments everything is not. Cheap version: test-time augmentation variance or MC-dropout as an uncertainty proxy, with a flag-rate vs error-caught curve.
- Reproducibility as ethics: config + seed + data manifest per result (Phase 07 Ch. 6's lineage) — in a domain where results inform care decisions, the ledger is not optional hygiene.
Chapter 5: The Shared Engineering Bar
Identical to the other tracks' capstone bars (one curriculum, one discipline):
- Walking skeleton first, then deepen; demo exists from day two.
- Eval plan written before the build — each capstone's metrics, splits, and acceptance numbers declared up front (Ch. 2–4 give the domain-correct metrics).
- Config-driven, seeded, ledgered: every reported number reproducible from a config file; the results table is append-only.
- Golden-input tests + preprocessing parity (Phase 07 Ch. 4) in CI for each serving path.
- One honest limitation paragraph per repo, plus the domain hard part explicitly addressed (latency contract / open-set + responsibility / patient-level stats).
- The 90-second demo path on a clean machine.
Chapter 6: Portfolio Conversion
- README order: money table/plot first (the budget table; the DET curve; the per-case Dice distribution), run-it second, design notes third.
- One rehearsed 2-minute walkthrough per capstone: problem → domain hard part → key decision with evidence → the number → the limitation.
- Cross-link the lineage: "tracking from Phase 01 Lab 04, operating-point method from Phase 05 Lab 01, parity tests from Phase 07 Lab 02" — showing the curriculum composing is itself the portfolio's story.
Lab Walkthrough Guidance
Sequencing: any order works; realtime detection first is recommended (fastest feedback loop, most reusable infrastructure). Per-capstone steps live in each README; the cross-cutting protocol:
- Write the eval plan and the domain-hard-part statement before code.
- Skeleton → golden tests → deepen → ledger throughout.
- Timebox 1–2 weeks each; cut scope, not corners — a smaller system meeting its stated contract beats a sprawling one meeting none.
Success Criteria
The capstone phase — and the track — is complete when:
- Each capstone meets its own declared acceptance numbers, reproducibly from config.
- Capstone 01 ships the measured budget table and sustains its FPS/latency contract under load.
- Capstone 02 ships the DET/ROC curve with a defended operating point, open-set evaluation, and the responsibility section.
- Capstone 03 ships patient-level splits, per-case Dice + HD95 distributions with CIs, and an abstention mechanism with its flag-rate curve.
- All three pass the 90-second demo test and carry their limitation paragraphs.
Interview Q&A
Q: Your realtime demo runs 30 FPS on your laptop and 11 FPS on the deployment box. Walk through it. Budget table on the target first (never extrapolate across hardware): usual suspects — no GPU/different GPU (inference 3×), CPU decode bottleneck (different codec/turbojpeg absence), thermal throttling (sustained vs burst — measure minutes in), Python worker count vs cores, and resolution/model mismatch in the deployed config. Fix what the table indicts; then re-state the contract for the actual hardware — perhaps with the smaller model at the measured operating point. Hardware-specific measurement as reflex is the graded behavior.
Q: Why can't you ship a face recognizer trained as a classifier over your user base? Closed-set classification fails structurally: new users require retraining (a softmax head per population), unenrolled people get confidently misassigned (no "none of the above"), and per-user data is tiny. Metric learning fixes all three: enrollment is embedding storage (no retraining), thresholds give a principled reject option, and the embedding generalizes from a large disjoint training population. Recognizing classification-vs-open-set as an architecture decision, not an accuracy tweak, is the point.
Q: Your medical model's mean Dice is 0.91 but the clinician rejects it. What might the mean be hiding? The distribution: a few catastrophic cases (Dice 0.3 on atypical anatomy) inside a high mean — clinical acceptability is governed by the worst case, hence per-case plots and HD95 (boundary excursions Dice can't see). Also: patient-level leakage inflating the number, eval cases unrepresentative of the clinic's scanner/protocol mix, and no abstention path (the clinician needs to know when not to trust it). The answer's spine: means hide tails; medicine lives in the tails.
References
- Bewley et al., SORT (2016) — arXiv:1602.00763 and Wojke et al., DeepSORT (2017)
- Deng et al., ArcFace (2018) — arXiv:1801.07698
- NIST FRVT reports — the demographic-effects findings, primary source
- Isensee et al., nnU-Net (2020) — arXiv:1904.08128 — what strong medical-segmentation methodology looks like
- Maier-Hein et al., Metrics Reloaded (2023) — arXiv:2206.01653 — choosing biomedical metrics correctly
- The sibling capstone warmups: model-accuracy Phase 11, LLM track Phase 11 — one engineering bar, three domains
Capstone 01: Real-Time Object Detection Pipeline
Project Goal
Build an end-to-end real-time object detection system:
- Synthetic video frame generator (no camera required)
- YOLOv8-style detection model (lightweight custom CNN)
- FastAPI inference server with dynamic batching
- Latency + throughput benchmark dashboard
Architecture
Synthetic Frame Generator
│ (30 fps, 640×480)
▼
Preprocessing Service ← resize, normalize
│
▼
Detection Model ← anchor-free FCOS-style head
│ (bounding boxes + class scores)
▼
NMS Post-processing ← torchvision.ops.nms
│
▼
FastAPI /predict endpoint ← JSON response
│
▼
Performance Dashboard ← matplotlib saved plots
Key Metrics to Report
- Model parameters: < 5M (fast enough for real-time)
- p50 inference latency: target < 30ms (CPU), < 5ms (GPU)
- Throughput: target > 30 fps (CPU), > 200 fps (GPU)
- mAP@0.5 on synthetic dataset
What You Learn
- Anchor-free detection head (FCOS-style) vs anchor-based (YOLOv5-style)
- Non-maximum suppression implementation from scratch
- End-to-end pipeline integration
- Performance profiling with
torch.profiler
Capstone 02: Face Recognition System
Project Goal
Build an end-to-end face recognition pipeline:
- Synthetic face dataset (gaussian blobs as face embeddings)
- ArcFace-style margin loss training
- FAISS embedding index for fast nearest-neighbor search
- Cosine similarity matching with configurable threshold
- Full evaluation: FAR vs FRR tradeoff curve
Architecture
Enrollment Phase:
Face Image → CNN Encoder → 512-dim L2-normalized embedding → FAISS Index
Inference Phase:
Query Image → CNN Encoder → Query Embedding
→ FAISS search (top-k nearest neighbors)
→ Cosine similarity threshold decision
→ MATCH / NO MATCH
Key Concepts
ArcFace Margin Loss
Standard softmax loss treats all wrong classes equally. ArcFace adds an angular margin $m$ in the embedding space: $$L = -\log \frac{e^{s(\cos(\theta_{y_i} + m))}}{e^{s(\cos(\theta_{y_i} + m))} + \sum_{j \neq y_i} e^{s \cos \theta_j}}$$
This forces embeddings of the same identity to cluster tightly, and different identities to have large angular separation.
FAISS Index Selection
| Index Type | Speed | Memory | Accuracy | Use When |
|---|---|---|---|---|
| FlatIP | Slowest | High | Exact | < 100K vectors |
| IVFFlat | Fast | Medium | Exact (within cluster) | 100K–10M |
| IVFPQ | Fastest | Low | Approx | > 10M |
Metrics to Report
- TAR@FAR=0.01%: True Accept Rate when False Accept Rate = 0.01%
- EER: Equal Error Rate (FAR = FRR)
- AUC: Area under the ROC curve
- Top-1 Accuracy: correct match at rank 1
Capstone 03: Medical Image Segmentation
Project Goal
Build a complete medical image segmentation system:
- Synthetic CT/MRI-like images with circular lesions
- U-Net from scratch with skip connections
- Dice + BCE combined loss
- MLflow experiment tracking
- ONNX export for deployment
- Full evaluation: Dice, IoU, pixel accuracy
Why Medical Segmentation?
Medical image segmentation is a top hiring domain for CV engineers at companies like:
- NVIDIA: Clara AI healthcare platform
- GE Healthcare / Siemens Healthineers: automated diagnostic tools
- PathAI / Paige.AI: pathology analysis
- Google Health: DeepMind AlphaFold, diabetic retinopathy screening
Architecture: U-Net
Input (1, H, W)
│
▼
Encoder:
Conv(1→32)→BN→ReLU → skip1 (32, H, W)
MaxPool
Conv(32→64)→BN→ReLU → skip2 (64, H/2, W/2)
MaxPool
Conv(64→128)→BN→ReLU → skip3 (128, H/4, W/4)
MaxPool
Bottleneck:
Conv(128→256)→BN→ReLU
Decoder:
Upsample → Cat(skip3) → Conv(384→128)
Upsample → Cat(skip2) → Conv(192→64)
Upsample → Cat(skip1) → Conv(96→32)
Output:
Conv(32→1) → Sigmoid → mask (1, H, W)
Loss Function: Dice + BCE
$$L = \alpha \cdot L_{BCE} + (1 - \alpha) \cdot L_{Dice}$$ $$L_{Dice} = 1 - \frac{2 \sum p_i g_i}{\sum p_i + \sum g_i + \epsilon}$$
BCE handles class imbalance at pixel level. Dice directly optimizes the overlap metric you care about.
Metrics
- Dice Score: primary metric (closer to 1.0 = better)
- IoU (Jaccard): $\frac{|P \cap G|}{|P \cup G|}$
- Pixel Accuracy: fraction of correctly classified pixels
CV Engineer Interview Prep — Concepts Cheatsheet
Quick reference for every topic that comes up in CV engineer interviews. Use this the week before your interview for rapid review.
1. Convolutional Neural Networks
Core Operations
| Operation | Formula | Purpose |
|---|---|---|
| Convolution | $y_{i,j} = \sum_{m,n} x_{i+m,j+n} \cdot k_{m,n}$ | Feature extraction |
| Max pooling | $y = \max_{w \times w \text{ region}} x$ | Spatial invariance, dimensionality reduction |
| Global avg pool | $y = \frac{1}{HW} \sum_{i,j} x_{i,j}$ | Replace FC layers, parameter reduction |
| Depthwise sep conv | DW conv + pointwise conv | MobileNet: 8-9× fewer FLOPs than regular conv |
Receptive Field
$$RF_k = RF_{k-1} + (f_k - 1) \cdot \prod_{i<k} s_i$$
where $f_k$ = kernel size at layer $k$, $s_i$ = stride at layer $i$.
Dilation: multiply receptive field without reducing spatial resolution. Dilated conv with rate $d$: gaps of $d-1$ between kernel elements. $RF = (2d+1) \times (2d+1)$ for 3×3 kernel.
2. Optimization
Gradient Descent Variants
| Method | Update | When to use |
|---|---|---|
| SGD | $\theta \leftarrow \theta - \alpha g$ | Large datasets, sparse updates |
| SGD+Momentum | $v \leftarrow \beta v + g$; $\theta \leftarrow \theta - \alpha v$ | Faster convergence, escapes local minima |
| RMSprop | $v \leftarrow \beta v + (1-\beta)g^2$; $\theta \leftarrow \theta - \alpha g/\sqrt{v+\epsilon}$ | Non-stationary, RNNs |
| Adam | 1st + 2nd moment with bias correction | Default choice for most tasks |
| AdamW | Adam + decoupled weight decay | Transformers, large models |
Learning Rate Schedules
- Warmup + cosine decay: standard for transformers. Prevents early instability.
- OneCycleLR: fast training, often best for CNNs.
- Linear scaling rule: multiply LR by $k$ when batch size is $k \times$ baseline.
- Gradient clipping: clip norm to 1.0 — prevents exploding gradients in RNNs/transformers.
3. Regularization
| Technique | How it works | When to use |
|---|---|---|
| L2 (weight decay) | Penalize $|\theta|^2$ — shrinks weights toward 0 | Always, standard |
| Dropout | Zero activations with prob $p$, scale by $1/(1-p)$ | FC layers, transformers |
| Batch Norm | Normalize activations within batch | CNNs, stabilizes training |
| Data augmentation | Artificially expand training set | All tasks |
| Label smoothing | Replace hard 0/1 with $\epsilon/(C-1)$ / $1-\epsilon$ | Classification, large datasets |
| Mixup | Blend two images: $\tilde{x} = \lambda x_i + (1-\lambda)x_j$ | Classification, detection |
| CutMix | Cut patch from one image, paste into another | Segmentation awareness |
4. Architectures
ResNet — Skip Connections
$$\mathcal{F}(x) = H(x) - x \rightarrow \text{learn residual, not full mapping}$$
Key insight: gradient can flow directly through skip connection. Solves vanishing gradient for 100+ layer networks.
Bottleneck block: 1×1→3×3→1×1 convolutions. Reduces channels before 3×3, expands after. 4× fewer FLOPs than basic block at same capacity.
EfficientNet — Compound Scaling
Scale depth $d = \alpha^\phi$, width $w = \beta^\phi$, resolution $r = \gamma^\phi$ such that $\alpha\beta^2\gamma^2 \approx 2$ (FLOPs double per step).
Vision Transformer (ViT)
- Split image into $P \times P$ patches (typically 16×16)
- Linear projection → sequence of tokens
- Add [CLS] token + positional embeddings
- Stack Transformer encoder layers
- Classify using [CLS] output
Limitation: requires more data than CNNs (no inductive bias). Pretrain on JFT-300M or use DeiT data augmentation.
5. Object Detection
Single-Stage vs Two-Stage
| Single-Stage (YOLO, SSD) | Two-Stage (Faster R-CNN) | |
|---|---|---|
| Speed | Fast (30-100+ FPS) | Slow (5-15 FPS) |
| Accuracy | Good | Better (especially small objects) |
| Anchors | Yes (YOLO v3-v5) or no (v8) | Yes (RPN) |
| Use case | Real-time | High-accuracy offline |
Key Metrics
- mAP@0.5: IoU threshold = 0.5 for TP/FP determination
- mAP@0.5:0.95: COCO metric, average over [0.5, 0.55, ..., 0.95]
- AP50 > 0.7: production-ready for most applications
6. Loss Functions Summary
| Loss | Formula | Use case |
|---|---|---|
| MSE | $\frac{1}{N}\sum(y-\hat{y})^2$ | Regression (sensitive to outliers) |
| Smooth L1 | Quadratic for $ | e |
| BCE | $-[y\log p + (1-y)\log(1-p)]$ | Binary classification |
| Cross-entropy | $-\sum y_c \log p_c$ | Multi-class classification |
| Focal | $-(1-p_t)^\gamma \log(p_t)$ | Class-imbalanced detection |
| Dice | $1 - \frac{2 | A\cap B |
| CIoU | $1 - \text{IoU} + \text{distance} + \text{aspect ratio}$ | Box regression |
| Triplet | $\max(d(a,p) - d(a,n) + \text{margin}, 0)$ | Metric learning, face recognition |
7. Normalization Layers
| Layer | Normalized over | Use case |
|---|---|---|
| Batch Norm | Per-channel, over batch+spatial | CNNs (batch ≥ 4) |
| Layer Norm | Per-sample, over all features | Transformers, NLP |
| Instance Norm | Per-channel, per-sample | Style transfer |
| Group Norm | Per-channel group, per-sample | Detection (small batch) |
| Sync BN | Like BN but sync across DDP ranks | Distributed training |
Why BN fails with batch=1: variance estimate is 0, no normalization happens. Use GN or IN instead.
8. GPU/Hardware
Memory Breakdown for Training (ResNet-50, batch=64)
- Parameters (FP32): 25M × 4 = 100 MB
- Gradients: same as params = 100 MB
- Optimizer state (Adam): 2× params = 200 MB
- Activations (for backprop): ~1-5 GB (dominant cost)
Reducing Memory
- Mixed precision (FP16/BF16): halve parameter+gradient memory
- Gradient checkpointing: recompute activations on backward, save only checkpoints
- FSDP: shard model+optimizer across GPUs
- Reduce batch size: decrease activation memory
Throughput Bottlenecks
- Kernel launch overhead: use larger batches
- Memory bandwidth: use tensor cores (multiple of 8 dims)
- Data loading: use pin_memory=True, num_workers=4-8
- PCIe bandwidth: use CUDA streams, async transfers
9. Common Interview Pitfalls
"What's the difference between overfitting and high variance?"
They're the same thing. Overfitting = high variance = model memorizes training noise, fails to generalize.
"When does batch norm hurt?"
- Very small batches (< 4) — variance estimate unreliable
- Very deep networks with gradient checkpointing — BN stats can be stale
- Online fine-tuning with different distribution — BN running stats mismatch
"How do you debug a model that won't train?"
- Check loss on a single batch first — should decrease with enough capacity
- Verify data loading (visualize a batch)
- Check for NaN/Inf in outputs (exploding gradients or bad initialization)
- Monitor gradient norms per layer
- Reduce to simplest possible model, add complexity incrementally
10. System Design Quick Reference
5-Step Framework
- Clarify: Latency? Accuracy? Scale? Online or batch?
- Estimate: Data size, compute, bandwidth needed
- Design: Pipeline stages, data flow
- Scale: Bottlenecks, horizontal scaling, caching
- Monitor: Metrics, alerts, drift detection
Common Tradeoffs
- Latency vs Throughput: batch size increases throughput, increases latency
- Accuracy vs Speed: smaller model, quantization, pruning
- Real-time vs Batch: streaming (Kafka + GPU workers) vs MapReduce
- Consistency vs Availability (CAP): detection results cached may be stale
CV Engineer Interview Prep — Algorithms & Data Structures
Focus areas: problems that appear in ML/CV engineer coding screens. Pattern-first: learn the pattern, then apply it across problems.
1. Sliding Window — Video/Temporal Processing
Pattern
Maintain a window of fixed or variable size. Expand right, shrink left when condition violated. Time: O(N) Space: O(W) where W = window size.
Problem: Maximum subarray (Kadane's algorithm)
def max_subarray(arr: list) -> int:
"""Max sum contiguous subarray. Used in temporal attention."""
max_sum = cur_sum = arr[0]
for x in arr[1:]:
cur_sum = max(x, cur_sum + x)
max_sum = max(max_sum, cur_sum)
return max_sum
Problem: Sliding window max (monotonic deque)
from collections import deque
def sliding_window_max(nums: list, k: int) -> list:
"""
Max in every k-length window. Used in temporal max pooling.
O(N) using monotonic decreasing deque.
"""
dq = deque() # stores indices, front = max
result = []
for i, x in enumerate(nums):
# Remove elements outside window
while dq and dq[0] < i - k + 1:
dq.popleft()
# Maintain decreasing order
while dq and nums[dq[-1]] < x:
dq.pop()
dq.append(i)
if i >= k - 1:
result.append(nums[dq[0]])
return result
# Example: max activation in temporal window
# nums = [1,3,−1,−3,5,3,6,7], k=3
# → [3, 3, 5, 5, 6, 7]
2. Two Pointers — Array Manipulation
Problem: Remove duplicates in sorted array (in-place)
def remove_duplicates(nums: list) -> int:
"""Used in NMS de-duplication patterns."""
if not nums:
return 0
slow = 0
for fast in range(1, len(nums)):
if nums[fast] != nums[slow]:
slow += 1
nums[slow] = nums[fast]
return slow + 1
Problem: Merge intervals (used in temporal track merging)
def merge_intervals(intervals: list) -> list:
"""
Merge overlapping intervals.
Used in object track merging, frame deduplication.
"""
intervals.sort(key=lambda x: x[0])
merged = [intervals[0]]
for start, end in intervals[1:]:
if start <= merged[-1][1]:
merged[-1][1] = max(merged[-1][1], end)
else:
merged.append([start, end])
return merged
3. Binary Search — Threshold Finding
Pattern
Use binary search whenever you can define a monotonic predicate.
Problem: Find best confidence threshold
def find_threshold(scores: list, labels: list, target_precision: float) -> float:
"""
Binary search for minimum threshold that achieves target precision.
"""
def precision_at_thresh(t: float) -> float:
preds = [s >= t for s in scores]
tp = sum(p and l for p, l in zip(preds, labels))
fp = sum(p and not l for p, l in zip(preds, labels))
return tp / (tp + fp + 1e-8) if (tp + fp) > 0 else 0.0
lo, hi = 0.0, 1.0
for _ in range(50): # binary search on real values
mid = (lo + hi) / 2
if precision_at_thresh(mid) < target_precision:
lo = mid
else:
hi = mid
return hi
Problem: Minimum batch size to keep latency ≤ budget
def max_feasible_batch(latency_fn, max_latency_ms: float) -> int:
"""Binary search over batch sizes. Assumes latency is monotone."""
lo, hi = 1, 512
while lo < hi:
mid = (lo + hi + 1) // 2
if latency_fn(mid) <= max_latency_ms:
lo = mid
else:
hi = mid - 1
return lo
4. Heap / Priority Queue
Problem: Top-K detections by score
import heapq
def topk_detections(detections: list, k: int) -> list:
"""
detections: list of (score, box) tuples
Returns top-K by score in O(N log K).
"""
# Min-heap of size K
heap = []
for score, box in detections:
heapq.heappush(heap, (score, box))
if len(heap) > k:
heapq.heappop(heap)
return sorted(heap, reverse=True)
Problem: K-th largest element (Quickselect O(N) avg)
import random
def kth_largest(nums: list, k: int) -> int:
"""Quickselect — average O(N), worst O(N²). Useful in score thresholding."""
k = len(nums) - k # convert to 0-indexed kth smallest
def quickselect(lo, hi):
pivot = nums[hi]
p = lo
for i in range(lo, hi):
if nums[i] <= pivot:
nums[i], nums[p] = nums[p], nums[i]
p += 1
nums[p], nums[hi] = nums[hi], nums[p]
if p == k: return nums[p]
if p < k: return quickselect(p+1, hi)
return quickselect(lo, p-1)
return quickselect(0, len(nums)-1)
5. Graph Algorithms — Scene Graphs & Dependencies
Problem: Topological sort (pipeline dependency resolution)
from collections import deque
def topological_sort(n: int, edges: list) -> list:
"""
Kahn's algorithm. Used in ML pipeline DAG scheduling.
edges: [(u, v)] meaning u must come before v
Returns: topological order, or [] if cycle detected.
"""
adj = [[] for _ in range(n)]
in_degree = [0] * n
for u, v in edges:
adj[u].append(v)
in_degree[v] += 1
queue = deque(i for i in range(n) if in_degree[i] == 0)
order = []
while queue:
u = queue.popleft()
order.append(u)
for v in adj[u]:
in_degree[v] -= 1
if in_degree[v] == 0:
queue.append(v)
return order if len(order) == n else []
Problem: Connected components (tracking scene objects)
def count_components(n: int, edges: list) -> int:
"""Union-Find (DSU) — O(N α(N)) for connected components."""
parent = list(range(n))
rank = [0] * n
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]] # path compression
x = parent[x]
return x
def union(x, y):
px, py = find(x), find(y)
if px == py: return False
if rank[px] < rank[py]: px, py = py, px
parent[py] = px
if rank[px] == rank[py]: rank[px] += 1
return True
components = n
for u, v in edges:
if union(u, v):
components -= 1
return components
6. Dynamic Programming — Sequence Problems
Problem: Longest common subsequence (tracking trajectory matching)
def lcs(s1: list, s2: list) -> int:
"""
O(N*M). Used in tracking: match predicted tracks to detections.
"""
m, n = len(s1), len(s2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i-1] == s2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
return dp[m][n]
Problem: Hungarian algorithm (assignment problem)
# Used in SORT/DeepSORT to match tracks to detections
# In practice, use scipy.optimize.linear_sum_assignment
from scipy.optimize import linear_sum_assignment
import numpy as np
def match_tracks_to_detections(cost_matrix: np.ndarray,
threshold: float = 0.7) -> list:
"""
cost_matrix: (N_tracks, N_detections) IoU cost (1 - IoU)
Returns list of (track_idx, det_idx) matched pairs.
"""
row_ind, col_ind = linear_sum_assignment(cost_matrix)
matches = []
for r, c in zip(row_ind, col_ind):
if cost_matrix[r, c] < threshold:
matches.append((r, c))
return matches
7. String / Hashing — Data Deduplication
Problem: Find duplicate frames (perceptual hashing)
def dhash(image_array, hash_size: int = 8) -> int:
"""
Difference hash for near-duplicate image detection.
Compare each pixel to right neighbor → 64-bit hash.
"""
import numpy as np
# Resize to (hash_size+1, hash_size)
img = image_array
# Flatten difference comparisons to bits
diff = img[:, 1:] > img[:, :-1] # (H, W-1) bool array
return sum(bool(v) << i for i, v in enumerate(diff.flatten()))
def hamming_distance(h1: int, h2: int) -> int:
"""Count differing bits. <= 10 bits → likely duplicate."""
return bin(h1 ^ h2).count('1')
8. Complexity Cheatsheet for CV Operations
| Operation | Time | Space | Notes |
|---|---|---|---|
| NMS (naive) | O(N²) | O(N) | N = detections per image |
| NMS (sorted) | O(N log N + N²) | O(N) | Sort once, then scan |
| Batched NMS (torchvision) | O(N log N) | O(N) | Uses radix sort + vectorized IoU |
| K-Means (k iters) | O(k·N·D·iters) | O(k·D) | N=samples, D=dims |
| PCA (SVD) | O(min(N,D)²·max(N,D)) | O(D²) | Use randomized SVD for large D |
| IoU matrix (N×M boxes) | O(N·M) | O(N·M) | Vectorized with broadcasting |
| Convolution (1 layer) | O(C_in·C_out·K²·H·W) | O(C_out·H·W) | Most compute in deep layers |
| Attention (full) | O(N²·D) | O(N²) | N=sequence length, D=dim |
| Attention (linear) | O(N·D²) | O(N·D) | Performer, Linformer variants |
| FAISS flat search | O(N·D) | O(N·D) | Brute force cosine/L2 |
| FAISS IVF search | O(N/k·D) | O(N·D) | k=num centroids |
9. Interview Coding Patterns
Pattern 1: "Implement X from scratch" — Template
# 1. Start with the math definition
# 2. Handle edge cases first
# 3. Implement the naive O(N²) version
# 4. Optimize to O(N log N) or O(N) if needed
# 5. Add a 3-line test at the end
Pattern 2: Vectorize with numpy/torch
# Avoid Python loops when operating on arrays
# Prefer broadcasting over explicit indexing
# Example: pairwise L2 distances
def pairwise_l2(A, B):
# A: (N, D), B: (M, D)
# Naive: O(N*M*D) loop
# Fast: |a - b|² = |a|² + |b|² - 2 a·b^T
A_sq = (A ** 2).sum(dim=1, keepdim=True) # (N, 1)
B_sq = (B ** 2).sum(dim=1, keepdim=True).T # (1, M)
return torch.sqrt((A_sq + B_sq - 2 * A @ B.T).clamp(min=0))
Pattern 3: Memory-efficient computation
# For large N, compute in chunks to avoid OOM
def chunked_pairwise(A, B, chunk_size=1024):
results = []
for i in range(0, len(A), chunk_size):
results.append(pairwise_l2(A[i:i+chunk_size], B))
return torch.cat(results)
Interview Prep — System Design Walkthroughs
Five complete system design answers with diagrams, tradeoffs, and estimates. Practice answering each in 45 minutes. The structure: Clarify → Estimate → Design → Scale → Monitor.
Walkthrough 1: Real-Time Object Detection at Scale
Prompt: Design a system to run object detection on 1000 camera feeds in real time.
Step 1 — Clarify Requirements
- Latency: < 200ms end-to-end (camera → alert)
- Throughput: 1000 cameras × 30 FPS = 30,000 frames/second
- Accuracy: mAP@0.5 > 0.65 on your object classes
- Scale: horizontally scalable to 10,000 cameras
Step 2 — Back-of-Envelope Estimates
- YOLOv8m: ~25ms/frame on A100 (batch=1)
- Batch=32 → ~2ms/frame effective → 500 fps/GPU
- 30,000 FPS ÷ 500 = 60 A100 GPUs (add 30% buffer → 80 GPUs)
- Storage: 1000 cams × 30 FPS × 50 KB/frame = 1.5 GB/s compressed → 5 TB/hour
Step 3 — Architecture
Cameras (RTSP)
│ (PyAV / FFmpeg)
▼
Kafka (topic: raw_frames) ← partitioned by camera_id
│
├─ GPU Worker Pool (80× A100)
│ ├─ Dynamic batching (wait ≤ 5ms or 32 frames)
│ ├─ TensorRT FP16 engine
│ └─ NMS post-processing (torchvision.ops.batched_nms)
│
├─→ Kafka (topic: detections) ← JSON events
│
├─→ TimescaleDB (time-series metrics per camera)
│
└─→ Alert Service (thresholds → PagerDuty / Slack)
Step 4 — Scale Decisions
| Decision | Choice | Why |
|---|---|---|
| Frame queue | Kafka | Back-pressure, replay, fan-out |
| Batching strategy | Dynamic (max 32, max 5ms) | Balance latency vs throughput |
| GPU scheduling | NVIDIA Triton | Built-in dynamic batching |
| Model format | TensorRT FP16 | 3-5× faster than PyTorch |
| Camera sharding | camera_id % n_partitions | Even load distribution |
Step 5 — Monitor
- GPU utilization (target > 80%)
- Queue lag (Kafka consumer lag < 1000 messages)
- p99 inference latency
- mAP drift (weekly evaluation against labeled validation set)
- False positive rate per camera (per-site calibration needed)
Walkthrough 2: Face Recognition System
Prompt: Design a face recognition system for a 10,000-employee company.
Step 1 — Clarify
- Use case: door access control (security) + attendance tracking
- Latency: < 500ms for live access decisions
- Scale: 10K employees, ~50 doors, ~1000 face lookups/minute peak
- Accuracy: FAR (False Accept Rate) < 0.01%, FRR (False Reject Rate) < 1%
Step 2 — Estimates
- MTCNN face detection: ~20ms/frame
- ArcFace embedding: ~10ms/frame (ResNet-50 backbone)
- FAISS flat search over 10K faces: ~1ms
- Total: ~31ms → well within 500ms budget
- Storage: 10K employees × 1 embedding × 512 floats × 4 bytes = 20 MB (trivial)
Step 3 — Architecture
Camera Frame
│
▼
Face Detection (MTCNN) ← detect + align face to 112×112
│
▼
Quality Filter ← reject blurry, occluded, non-frontal
│ (Laplacian variance > 100, face area > 5% of frame)
▼
ArcFace Embedding ← 512-dim L2-normalized vector
│
▼
FAISS IndexFlatIP ← cosine similarity search
│ similarity threshold: 0.65
├── Match found → employee_id → access_log → grant/deny
└── No match → flag for security review
Step 4 — Database & Enrollment
# Enrollment: add new employee
embedding = arcface_model(align_face(img)) # (512,) normalized
faiss_index.add(embedding.reshape(1, -1))
employee_db[faiss_index.ntotal - 1] = employee_id
# For production: use IndexIVFFlat for > 1M faces
# nlist = 100 centroids, nprobe = 10 → 10x speedup vs flat
index = faiss.IndexIVFFlat(quantizer, 512, 100, faiss.METRIC_INNER_PRODUCT)
index.train(all_embeddings)
Step 5 — Anti-Spoofing
- Liveness detection: check for eye blink, head movement, or use IR depth camera
- Presentation attack: binary classifier on face texture (MobileNetV2, trained on fake face datasets)
- Audit log: store encrypted embedding + timestamp for compliance
Walkthrough 3: Video Content Moderation
Prompt: Design a system that moderates 1M videos/day for inappropriate content.
Step 1 — Clarify
- Latency: async (within 5 minutes of upload is fine)
- Scale: 1M videos/day = ~12 videos/second average, 100 peak
- Content: violence, adult content, hate symbols
- SLA: < 0.1% harmful content reaches users
Step 2 — Pipeline Design
Video Upload (S3)
│
▼
Frame Sampling Service ← 1 fps for most videos
│ skip identical frames (perceptual hash)
▼
Multi-Label Classifier ← EfficientNet-B4 → 5 categories
│ batch=64, A100, ~500 fps
▼
Risk Scorer ← max(category_scores) × duration_weight
│
├── score < 0.3 → Auto-Approve
├── score 0.3-0.7 → Human Review Queue (Mechanical Turk)
└── score > 0.7 → Auto-Reject + notify uploader
Step 3 — Frame Sampling Strategy
def sample_frames(video_path, fps=1.0, max_frames=300):
"""
1 FPS + dedup = covers 99% of content with minimal compute.
"""
cap = cv2.VideoCapture(video_path)
video_fps = cap.get(cv2.CAP_PROP_FPS)
interval = max(1, int(video_fps / fps))
frames, prev_hash = [], None
i = 0
while cap.isOpened():
ret, frame = cap.read()
if not ret: break
if i % interval == 0:
h = dhash(cv2.resize(frame, (8, 8)))
if prev_hash is None or bin(h ^ prev_hash).count('1') > 5:
frames.append(frame)
prev_hash = h
i += 1
return frames[:max_frames]
Step 4 — Human Review Optimization
- Prioritize: sort review queue by (risk_score × video_length)
- Context: show reviewer 3 highest-risk frames + metadata
- Feedback loop: reviewer decisions → retrain classifier weekly
- Active learning: add uncertain predictions (0.4-0.6 score) to training set
Walkthrough 4: Autonomous Vehicle Perception Pipeline
Prompt: Design the perception stack for a Level 2 ADAS system.
Step 1 — Requirements
- Sensors: 8 cameras (surround), 1 LiDAR, 4 radar
- Latency: < 50ms end-to-end (33ms = 30 Hz)
- Safety: must detect pedestrians at 50m with > 99.9% recall
- Compute: embedded (NVIDIA Orin, 254 TOPS)
Step 2 — Architecture
Cameras (8×) → ISP → JPEG decode → GPU memory
LiDAR → Point cloud → voxelization
Radar → CFAR detection → velocity clusters
│
▼
┌─── BEV Feature Extractor ───┐
│ Camera: LSS / BEVFusion │
│ LiDAR: PointPillars │
└─────────────────────────────┘
│ Bird's Eye View (BEV) feature map
▼
┌─── 3D Object Detection ─────┐ CenterPoint / DETR3D
├─── Lane Detection ──────────┤ BezierLaneNet
└─── Occupancy Prediction ────┘ Tesla Occupancy Networks
│
▼
Sensor Fusion ← Kalman Filter per track
│
▼
HD Map + Ego Pose ← RT-SLAM / GPS+IMU
│
▼
Planning Interface ← object list + velocity vectors
Step 3 — Latency Budget (50ms)
| Stage | Budget |
|---|---|
| Sensor capture + DMA | 5ms |
| Preprocessing (debayer, resize) | 3ms |
| BEV feature extraction | 18ms |
| Detection heads | 8ms |
| Sensor fusion + tracking | 5ms |
| Output marshaling | 1ms |
| Total | 40ms (10ms margin) |
Step 4 — Safety Considerations
- Redundancy: radar provides independent velocity estimates
- OOD detection: uncertainty heads on detection model; trigger conservative behavior
- Temporal consistency: detections must be tracked ≥ 3 frames before acting on them
- Simulation testing: 1 billion virtual miles before road testing
Walkthrough 5: Medical Image Diagnosis System
Prompt: Design an AI system to assist radiologists reading chest X-rays.
Step 1 — Clarify
- Task: multi-label classification (14 pathologies) + localization
- Scale: 10K X-rays/day, 200 hospitals
- Latency: < 5 seconds (radiologist sees AI result before reading)
- Regulatory: FDA 510(k) clearance needed → explainability required
Step 2 — Architecture
DICOM Upload (hospital PACS)
│
▼
DICOM Parser + Normalization ← pydicom, window-level normalization
│
▼
Quality Filter ← check for rotation, artifacts, exposure
│
▼
DenseNet-121 (CheXNet-style) ← pretrained on CheXpert/NIH-14
│
├── 14 pathology scores ← sigmoid output, 0-1 confidence
│
└── GradCAM heatmaps ← highlight regions driving prediction
│
▼
Radiologist Dashboard ← highlight boxes + confidence scores
│
▼
Human Decision ← radiologist confirms/overrides
│
▼
Feedback Loop ← confirmed cases → re-training dataset
Step 3 — MLflow Experiment Tracking
import mlflow
with mlflow.start_run(run_name="densenet121_chexpert_v3"):
mlflow.log_params({"model": "DenseNet121", "pretrain": "CheXpert", "epochs": 50})
for epoch in range(epochs):
metrics = evaluate(model, val_loader)
mlflow.log_metrics(metrics, step=epoch)
mlflow.pytorch.log_model(model, "model",
registered_model_name="chest_xray_classifier")
Step 4 — Regulatory Compliance
- Explainability: GradCAM mandatory for FDA submission
- Bias auditing: validate AUC separately for age/gender/race subgroups
- Model versioning: every deployed model version tracked in registry
- Shadow deployment: new model runs in parallel with existing for 30 days before replacement
- Uncertainty quantification: MC Dropout → flag low-confidence cases for mandatory human review
Interview Prep — Behavioral Questions
STAR format: Situation → Task → Action → Result Prepare 2-3 specific stories for each theme. Numbers and outcomes matter.
Theme 1: Handling Model Failures in Production
Sample Question
"Tell me about a time a model you deployed caused a problem in production."
STAR Template
Situation: Describe the model, deployment context, and what went wrong.
Task: What was your responsibility when the issue occurred?
Action: Walk through your debugging process step by step.
Result: Quantify the impact and what you changed to prevent recurrence.
Strong Answer Framework
- State the failure mode clearly: drift, edge case, wrong metric
- Describe your monitoring that caught it (or didn't)
- Explain your root cause analysis
- Describe the fix: model update, fallback rule, threshold change
- Describe what you put in place afterward
Example Talking Points
- "We deployed a person detection model for access control. After 3 weeks, the FPR spiked from 0.2% to 4% — we investigated and found the camera's autofocus behavior had changed after a firmware update, introducing motion blur we hadn't seen in training. We added online evaluation against a held-out labeled set that ran every 6 hours, and added blur detection to the preprocessing pipeline to reject low-quality frames."
- "Our object detection AP dropped from 0.72 to 0.58 silently over a month. We had no input distribution monitoring. After this, I implemented data drift detection using KL divergence on predicted class distributions compared to training distribution."
Key Points to Hit
- Show ownership: you caught it or helped catch it
- Show engineering rigor: systematic debugging, not guessing
- Show learning: what monitoring/process you added afterward
Theme 2: Technical Leadership & Cross-Team Collaboration
Sample Questions
- "Describe a time you influenced a team that wasn't directly reporting to you."
- "Tell me about a time you drove a technical decision that was controversial."
STAR Template
Situation: Team structure, competing priorities, technical disagreement.
Task: What outcome did you need to achieve?
Action: How did you make your case? What data did you use?
Result: Was your approach adopted? What was the business impact?
Strong Answer Framework
- Acknowledge the competing viewpoint fairly
- Describe the analysis or prototype you built to make your case
- Describe how you communicated it (design doc, A/B test, demo)
- Note how you handled disagreement professionally
Example Talking Points
- "We had a debate about whether to use YOLOv8 or a two-stage detector for our warehouse robots. The product team wanted accuracy; infra team wanted to keep latency under 50ms. I ran a two-week spike with both models on our actual hardware, documented the Pareto frontier of accuracy vs latency, and proposed YOLOv8m with TensorRT. This data-driven approach won over both teams."
- "I proposed migrating our inference stack to Triton Inference Server. The engineering team was skeptical of the migration effort. I built a proof-of-concept over a weekend, showed 3× throughput improvement, and documented the migration path step-by-step to reduce risk perception."
Theme 3: Dealing with Ambiguous Requirements
Sample Questions
- "Tell me about a time you had to make a decision without all the information you needed."
- "Describe a project where requirements changed significantly mid-way."
STAR Template
Situation: What was unclear? What were the risks of getting it wrong?
Task: What did you need to deliver and by when?
Action: How did you structure the ambiguity? What questions did you ask?
Result: How did the project turn out? What would you do differently?
Strong Answer Framework
- Show you actively reduced ambiguity rather than waiting
- Describe how you defined the MVP and deferred non-essential work
- Show how you managed stakeholder expectations around uncertainty
- Highlight what you learned about scoping
Example Talking Points
- "We were asked to 'improve the detection accuracy' on a manufacturing line, with no baseline metric, no labeled dataset, and no definition of success. I spent the first week establishing baselines — ran our existing model, collected 500 labeled ground-truth frames from the line, and wrote a one-pager defining what 'good' meant: mAP@0.5 > 0.80 at < 50ms latency. This became the success criteria the whole team aligned on."
- "Mid-project, the product team changed the target from 5 classes to 12. I flagged that this would require 5× more labeled data and an additional 3 weeks. We agreed to release v1 with 5 classes and v2 with all 12. This prevented a slip while keeping momentum."
Theme 4: Technical Deep-Dives & Problem Solving
Sample Questions
- "Walk me through the most technically challenging project you've worked on."
- "Describe a time you had to learn a new technology quickly."
STAR Template
Situation: What was the hard technical problem?
Task: What did success look like?
Action: What was your approach to breaking down the problem?
Result: What did you achieve? What did you learn?
Strong Answer Framework
- Be specific — don't generalize ("I worked on computer vision")
- Explain why it was hard (technical, not just time pressure)
- Show systematic problem-solving: hypothesis → experiment → conclusion
- Quantify the improvement
Example Talking Points
- "I had to optimize a segmentation model to run at 30 FPS on an embedded NVIDIA Orin. Starting point was 8 FPS. I profiled with
nsysand found 60% of time was spent in the decoder upsampling. I replaced bilinear + conv with a lightweight learned upsampler, exported with TensorRT FP16, and achieved 34 FPS — a 4.25× improvement." - "I needed to understand RAFT (optical flow) for a video stabilization project in 3 days. I read the paper, ran the official code, then re-implemented the correlation volume from scratch. Understanding it from first principles let me debug a numerical precision bug that the pretrained weights obscured."
Theme 5: Mentorship & Growing Others
Sample Questions
- "Tell me about a time you mentored a junior engineer."
- "Describe how you've contributed to your team's technical growth."
STAR Template
Situation: Who were you mentoring? What was their challenge?
Task: What were you trying to help them achieve?
Action: What specific steps did you take?
Result: What progress did they make?
Example Talking Points
- "A junior engineer on my team was struggling to get a detection model to converge. Rather than debugging for them, I sat down and taught them how to read loss curves and gradient norms systematically. I showed them how to first overfit a single batch, then scale up. Within a week they were self-sufficient with training debugging."
- "I wrote a 'Model Debugging Checklist' for my team: 10 questions to answer before escalating a training problem. It reduced the time from 'model not working' to 'root cause found' by about 60%."
Questions to Ask the Interviewer
Technical Questions
- "What does the model deployment pipeline look like today? What's the main bottleneck?"
- "How do you evaluate model drift in production? What triggers a re-train?"
- "What's the typical ratio of data labeling / model training / deployment work for the team?"
- "What's the hardest CV problem you're currently trying to solve?"
Team & Culture
- "How does the team handle disagreements on technical direction?"
- "What does career growth look like for a CV/ML engineer here?"
- "What are the biggest technical challenges the team will face in the next 12 months?"
Process
- "How long does a new model typically take from first experiment to production?"
- "How do you balance shipping quickly vs building maintainable systems?"
Negotiation & Offer Notes
- Always negotiate. The first offer is rarely the final offer.
- Anchors: competing offers, market data (levels.fyi, blind), your current package
- For ML engineer roles, equity + bonus often exceed base for senior levels — ask for details
- Negotiate title and scope too — "Senior ML Engineer" vs "Staff" is a big career difference
- Get promises in writing (team, project, compute budget)
System Design for CV Engineers
These documents are the highest-leverage study material for senior CV interviews. "Design a real-time video analytics system" is one of the most common system design questions at FAANG-level companies.
Documents
| File | Topic |
|---|---|
| 01-cv-pipeline-design.md | End-to-end scalable CV system architecture |
| 02-real-time-video-analytics.md | Streaming, Kafka, async inference at scale |
| 03-distributed-training.md | DDP, FSDP, gradient accumulation, mixed precision |
| 04-gpu-tpu-acceleration.md | CUDA memory model, TensorRT, TPU/XLA, hardware selection |
| 05-model-serving-at-scale.md | Triton, batching, SLA tradeoffs, horizontal scaling |
How to Use These
- Read one document per day — don't rush
- Draw architecture diagrams on paper
- Identify tradeoffs — there are no right answers in system design, only tradeoffs
- Practice the interview format: "First let me clarify requirements... The key constraints are... Let me walk through the architecture..."
System Design Interview Framework
-
Clarify requirements (5 min)
- Functional: what does it do?
- Non-functional: latency, throughput, accuracy, cost?
- Scale: images/sec, cameras, users?
-
Estimate scale (3 min)
- "100 cameras × 30 FPS = 3,000 frames/sec"
- "Each inference ~20ms → need 60 parallel workers"
-
High-level design (10 min)
- Draw boxes: ingestion → processing → storage → serving
- Identify the most critical component
-
Deep dive (15 min)
- Pick the hard problem (usually: GPU inference at scale, or real-time latency)
- Discuss alternatives, their tradeoffs
-
Handle scale, failures, monitoring (5 min)
- What breaks at 10× load?
- How do you detect model degradation in production?
End-to-End Scalable CV Pipeline Design
Reference architecture for production CV systems. Read this before any system design interview.
Generic CV Pipeline Stages
┌─────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌─────────┐
│ Data │ │ Pre- │ │ Inference │ │ Post- │ │ Storage │
│ Ingest │───▶│ process │───▶│ (GPU/TPU) │───▶│ process │───▶│ & Serve │
│ │ │ │ │ │ │ │ │ │
└─────────┘ └──────────┘ └──────────────┘ └──────────┘ └─────────┘
RTSP/S3 Resize/ TensorRT/ NMS/ DB/S3/
Kafka/ Normalize PyTorch/ Track/ Kafka/
REST Augment ONNX Filter Redis
Data Ingestion Patterns
Push vs Pull
| Pattern | When to Use |
|---|---|
| Pull (worker polls queue) | Batch processing, variable load |
| Push (cameras push to endpoint) | Low-latency, event-driven |
| Stream (Kafka/Kinesis) | High-throughput, durable, replayable |
Protocol Choices
- RTSP: cameras → edge decoder → Kafka (standard for IP cameras)
- HTTP multipart: browser webcams, mobile apps
- gRPC streaming: low-latency bidirectional (good for robots, edge devices)
- WebRTC: browser real-time (if you need sub-second latency to browser)
Preprocessing Pipeline
CPU-side (before GPU)
# Maximize CPU preprocessing throughput
from concurrent.futures import ThreadPoolExecutor
def preprocess_worker(raw_bytes: bytes) -> np.ndarray:
img = cv2.imdecode(np.frombuffer(raw_bytes, np.uint8), cv2.IMREAD_COLOR)
img = cv2.resize(img, (640, 640))
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
return img.astype(np.float32) / 255.0
# Use multiple threads for I/O + decode
with ThreadPoolExecutor(max_workers=8) as pool:
frames = list(pool.map(preprocess_worker, raw_batch))
GPU-side (CUDA preprocessing)
For high throughput, move preprocessing to GPU with TorchVision or DALI:
# NVIDIA DALI — GPU-accelerated data pipeline
from nvidia.dali import pipeline_def, fn
@pipeline_def(batch_size=64, num_threads=4, device_id=0)
def video_pipeline(file_list):
jpegs, labels = fn.readers.file(file_root=file_list)
images = fn.decoders.image(jpegs, device='mixed') # decode on GPU
images = fn.resize(images, resize_shorter=640)
images = fn.crop_mirror_normalize(images,
mean=[0.485*255, 0.456*255, 0.406*255],
std=[0.229*255, 0.224*255, 0.225*255],
output_layout='CHW')
return images, labels
DALI can eliminate the CPU preprocessing bottleneck entirely for high-FPS pipelines.
Post-processing
Non-Maximum Suppression (NMS)
After object detection, many overlapping boxes exist. NMS selects the best:
def nms(boxes: np.ndarray, scores: np.ndarray,
iou_threshold: float = 0.45) -> list[int]:
"""
Classic NMS (greedy). Operates in O(N²) but N is small after conf filtering.
boxes: (N, 4) in [x1, y1, x2, y2]
scores: (N,)
Returns: list of kept indices
"""
x1, y1, x2, y2 = boxes[:,0], boxes[:,1], boxes[:,2], boxes[:,3]
areas = (x2 - x1) * (y2 - y1)
order = scores.argsort()[::-1] # highest score first
keep = []
while order.size > 0:
i = order[0]
keep.append(i)
# Compute IoU with all remaining boxes
xx1 = np.maximum(x1[i], x1[order[1:]])
yy1 = np.maximum(y1[i], y1[order[1:]])
xx2 = np.minimum(x2[i], x2[order[1:]])
yy2 = np.minimum(y2[i], y2[order[1:]])
inter = np.maximum(0, xx2 - xx1) * np.maximum(0, yy2 - yy1)
iou = inter / (areas[i] + areas[order[1:]] - inter + 1e-8)
order = order[1:][iou <= iou_threshold]
return keep
Soft-NMS: Instead of discarding boxes with IoU > threshold, reduce their score by a Gaussian function of IoU. Better for crowded scenes (pedestrian detection).
WBF (Weighted Boxes Fusion): Ensemble NMS for combining predictions from multiple models — weights boxes by confidence and averages them. Better than voting-based NMS for model ensembles.
Multi-Object Tracking
After detection, link boxes across frames:
Frame t: [box_A, box_B, box_C]
Frame t+1: [box_D, box_E, box_F]
Assignment problem: which detections in t+1 correspond to t?
Solution: Hungarian algorithm on IoU cost matrix
SORT (Simple Online and Realtime Tracking):
- Predict box positions using Kalman filter
- Match predictions to detections via IoU + Hungarian assignment
- Unmatched detections → new tracks; unmatched tracks → deleted after K frames
DeepSORT: Adds Re-ID embedding (appearance features) to SORT's IoU matching. Reduces ID switches in crowded scenes. The Re-ID model runs as a separate lightweight CNN.
Storage Architecture
Hot / Warm / Cold Tiering
Hot (Redis): Current detections, live dashboard data TTL: 5 minutes
Warm (PostgreSQL): Event records, track histories, aggregates TTL: 90 days
Cold (S3/GCS): Raw video clips, model outputs, audit logs TTL: 7 years
Database Schema for CV Events
CREATE TABLE detections (
id UUID PRIMARY KEY,
camera_id VARCHAR(50) NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
class_id SMALLINT NOT NULL,
confidence FLOAT4 NOT NULL,
bbox FLOAT4[4] NOT NULL, -- [x1,y1,x2,y2] normalized
track_id INTEGER, -- NULL if no tracking
clip_s3_key VARCHAR(255) -- link to video clip
) PARTITION BY RANGE (timestamp); -- partition by day for query performance
-- Index for common queries
CREATE INDEX ON detections (camera_id, timestamp DESC);
CREATE INDEX ON detections (class_id, timestamp DESC);
Security Considerations
- Camera streams: Authenticate RTSP with digest auth or mTLS
- API: Rate limiting per API key; validate input dimensions before GPU (prevent resource exhaustion)
- Model: Adversarial robustness — test against common perturbations
- PII: GDPR compliance — blur faces before storing video if cameras capture public areas
- Model exfiltration: Don't expose raw model weights; use encrypted containers or TEE (Trusted Execution Environments) for sensitive models
# Input validation (prevent resource exhaustion attacks)
def validate_image_input(img: np.ndarray) -> None:
if img.ndim not in (2, 3):
raise ValueError("Image must be 2D or 3D array")
if img.shape[0] > 4096 or img.shape[1] > 4096:
raise ValueError("Image too large (max 4096×4096)")
if img.dtype not in (np.uint8, np.float32):
raise ValueError("Unsupported dtype")
Real-Time Video Analytics System Design
Interview Question: "Design a system that processes 1,000 live camera streams to detect safety violations in a factory, with results displayed on a dashboard within 3 seconds."
Step 1: Clarify Requirements
Functional:
- Ingest 1,000 RTSP camera streams at 30 FPS
- Run object detection + classification per frame
- Alert on violations within 3 seconds of occurrence
- Store events with video clips for review
- Dashboard showing live status per camera
Non-functional:
- Latency: < 3 second end-to-end (ingestion → alert)
- Availability: 99.9% (factory safety system)
- Throughput: 1,000 streams × 30 FPS = 30,000 frames/second
- Scale: eventually 10,000 cameras
Step 2: Back-of-Envelope Math
Streams: 1,000 cameras × 30 FPS = 30,000 frames/sec
Frame size: 1920×1080 × 3 bytes (BGR) = 6.2 MB raw
After H.264 decode: ~0.2 MB per frame
Total ingestion bandwidth: 30,000 × 0.2 MB = 6 GB/s raw data
YOLOv8m inference:
- GPU: ~8ms per frame at batch_size=1
- At batch_size=32: ~15ms → ~2,100 frames/sec per A100
- Needed: 30,000 / 2,100 ≈ 15 A100 GPUs for real-time
Storage:
- Events only (1% of frames): 300 frames/sec × 0.2 MB = 60 MB/s = 5 TB/day
- Retain 30 days: 150 TB → Object storage (S3/GCS)
High-Level Architecture
┌─────────────────────────────────────────────┐
│ Camera Network (RTSP) │
│ Cam 1 ... Cam 1000 (H.264/H.265 streams) │
└──────────────────┬──────────────────────────┘
│ RTSP pull
┌──────────────────▼──────────────────────────┐
│ Ingest Layer │
│ ┌───────────┐ ┌───────────┐ ┌─────────┐ │
│ │ Ingest-01 │ │ Ingest-02 │ │ ... │ │
│ │ (FFmpeg + │ │ (FFmpeg) │ │ │ │
│ │ 50 cams) │ │ 50 cams │ │ 20 pods│ │
│ └─────┬─────┘ └─────┬─────┘ └────┬────┘ │
└────────┼──────────────┼──────────────┼──────┘
│ │ │
┌────────▼──────────────▼──────────────▼──────┐
│ Apache Kafka │
│ Topic: frames (1000 partitions) │
│ Partition key: camera_id │
│ Retention: 2 hours │
└─────────────────────┬────────────────────────┘
│
┌─────────────────────▼────────────────────────┐
│ Inference Workers │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ GPU Worker │ │ GPU Worker │ × 15 pods │
│ │ A100 80GB │ │ A100 80GB │ │
│ │ batch=32 │ │ batch=32 │ │
│ │ TensorRT │ │ TensorRT │ │
│ └──────┬──────┘ └──────┬──────┘ │
└─────────┼────────────────┼───────────────────┘
│ │
┌────────────▼────────────────▼────────────────┐
│ Results Kafka Topic │
└──────────────────────┬─────────────────────┘
┌─────────────────┼─────────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌────────────┐ ┌──────────────────┐
│ Alert Service │ │ Event DB │ │ Dashboard Service │
│ (PagerDuty/ │ │(PostgreSQL)│ │(WebSocket → UI) │
│ SMS/Email) │ │+ S3 clips │ │ │
└──────────────┘ └────────────┘ └──────────────────┘
Component Deep Dives
Ingest Layer
Each ingest pod handles 50 RTSP streams using FFmpeg. Key design decisions:
# ingest_worker.py — one process per camera
import av # PyAV — Python bindings for FFmpeg
def ingest_camera(camera_url: str, camera_id: str, kafka_producer):
container = av.open(camera_url)
stream = container.streams.video[0]
stream.codec_context.skip_frame = 'NONREF' # Only keyframes + P-frames
frame_count = 0
for packet in container.demux(stream):
for frame in packet.decode():
frame_count += 1
# Process only every 3rd frame (10 FPS effective) to save GPU compute
if frame_count % 3 != 0:
continue
# Convert to numpy and encode as JPEG (10-30× smaller than raw)
img = frame.to_ndarray(format='bgr24')
_, encoded = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, 85])
kafka_producer.send('frames', key=camera_id.encode(), value={
'camera_id': camera_id,
'timestamp': frame.pts * stream.time_base,
'frame': encoded.tobytes()
})
Backpressure: Kafka consumer groups allow inference workers to pull at their own rate. If GPU workers are slow, frames back up in Kafka (retained for 2 hours). The ingest layer never blocks.
GPU Inference Worker
# inference_worker.py
from kafka import KafkaConsumer
import torch
import tensorrt as trt
from collections import defaultdict
class BatchInferenceWorker:
def __init__(self, model_path: str, batch_size: int = 32):
self.engine = load_trt_engine(model_path)
self.batch_size = batch_size
self.pending = []
def run(self, consumer: KafkaConsumer):
for message in consumer:
self.pending.append(message)
# Batch up frames from multiple cameras for GPU efficiency
if len(self.pending) >= self.batch_size:
self._process_batch()
def _process_batch(self):
frames = [decode_jpeg(m.value['frame']) for m in self.pending]
# Preprocess: resize, normalize
batch = preprocess_batch(frames) # (B, 3, 640, 640) on GPU
detections = self.engine.infer(batch) # TensorRT inference
# Post-process: NMS per image
results = [apply_nms(det, conf_thresh=0.5, iou_thresh=0.45)
for det in detections]
# Publish results
for msg, result in zip(self.pending, results):
publish_result(msg, result)
self.pending.clear()
Dynamic batching: Don't wait for a full batch — set a max_wait_ms=20. If 32 frames arrive within 20ms, great. If only 10 arrive, process them. This bounds added latency.
Frame Sampling Strategy
Full 30 FPS is usually wasteful. Use adaptive sampling:
- Static cameras (factory floor): 5-10 FPS is sufficient for violation detection
- Moving cameras (pan-tilt-zoom): Use motion detection to trigger higher FPS
- Event-based: Run background subtraction cheaply (CPU), only send GPU frames with detected motion
This can reduce GPU load by 5-10×.
Scalability
Horizontal Scaling
All components are stateless (Kafka decouples producers from consumers):
10 cameras → 1 ingest pod, 1 GPU worker
1,000 cameras → 20 ingest pods, 15 GPU workers
10,000 cameras → 200 ingest pods, 150 GPU workers (linear!)
Auto-scaling
# Kubernetes HPA for inference workers
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
metrics:
- type: External
external:
metric:
name: kafka_consumer_group_lag
target:
type: AverageValue
averageValue: "1000" # Scale up if >1000 frames lag per worker
Failure Handling
| Failure | Recovery |
|---|---|
| Ingest pod crashes | Kubernetes restarts in <30s; camera reconnects automatically |
| GPU worker crashes | Kafka offset not committed → messages re-delivered to other workers |
| Kafka broker fails | Replication factor=3: other brokers have the data |
| Model returns wrong results | Rollback via model versioning (MLflow); shadow mode deployment |
Latency Budget
Camera capture: 0 ms (starting point)
H.264 encode at camera: 33 ms (1 frame at 30fps)
Network transmission: 10 ms (LAN)
Kafka ingest: 5 ms
Queue wait (max): 50 ms (at peak load)
GPU decode + preprocess: 5 ms
TensorRT inference: 8 ms
Post-process (NMS): 2 ms
Kafka result publish: 3 ms
Alert service: 5 ms
─────────────────────────────
Total: ~121 ms ← Well within 3 second SLA
The 3-second SLA is easy to meet. The real engineering challenge is maintaining <200ms end-to-end at P99 under load spikes.
Monitoring & Observability
# Key metrics to track
METRICS = {
'frame_processing_latency_p99': 'SLA alert if > 500ms',
'kafka_consumer_lag': 'Indicates worker capacity',
'gpu_utilization': 'Should be 70-90% at steady state',
'gpu_memory_used': 'Alert if > 90% (OOM risk)',
'inference_accuracy': 'Shadow-test with human labels',
'false_positive_rate': 'Alert fatigue metric',
'frames_per_second_processed': 'Throughput tracking',
}
Model drift detection: Deploy a periodic job that takes a stratified sample of processed frames, runs human review on 0.1%, and computes accuracy drift. Alert if accuracy drops >3% from baseline.
Distributed Training Architecture
Scaling training from 1 GPU to 100s of GPUs — theory, implementation, and tradeoffs.
Why Distributed Training?
| Constraint | Solution |
|---|---|
| Model doesn't fit in 1 GPU | Model parallelism, FSDP |
| Training too slow | Data parallelism (DDP) |
| Both | Hybrid parallelism (3D parallelism) |
Data Parallelism — DDP
Concept: Each GPU holds a full copy of the model. Each step:
- Split the mini-batch across N GPUs (each sees batch_size/N samples)
- Each GPU computes forward + backward independently
- AllReduce gradients across all GPUs (ring-allreduce via NCCL)
- All GPUs update identically → models stay in sync
Key property: DDP is mathematically equivalent to training with a global batch size of N × batch_size_per_gpu. This is why you scale the learning rate: lr = base_lr × N (linear scaling rule, Goyal et al.).
# Launch: torchrun --nproc_per_node=8 train.py
import torch
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
def main():
dist.init_process_group("nccl") # NCCL for GPU-GPU, gloo for CPU
rank = dist.get_rank() # This process's GPU index (0-7)
local_rank = rank % torch.cuda.device_count()
model = MyModel().to(local_rank)
model = DDP(model, device_ids=[local_rank],
find_unused_parameters=False) # False = faster
# Each rank sees a different shard of data
sampler = DistributedSampler(dataset, num_replicas=dist.get_world_size(),
rank=rank, shuffle=True)
loader = DataLoader(dataset, sampler=sampler, batch_size=64,
pin_memory=True, num_workers=4)
for epoch in range(n_epochs):
sampler.set_epoch(epoch) # Required for proper shuffling!
for batch in loader:
# Forward + backward same as single-GPU
loss = model(batch)
loss.backward() # DDP hooks trigger AllReduce here
optimizer.step()
optimizer.zero_grad()
NCCL AllReduce
Ring-allreduce: each GPU sends and receives gradients in a ring topology.
- Communication cost: $2(N-1)/N \times \text{gradient_size}$ — nearly independent of N!
- For N=8 GPUs: 87.5% of gradient data transmitted (vs naive: 7× for a parameter server)
- NVLink bandwidth (A100): 600 GB/s bidirectional → AllReduce of 1GB params in ~1.7ms
Gradient Accumulation
Simulate a larger batch size without more GPU memory:
ACCUMULATE_STEPS = 8 # Effective batch = 8 × per_step_batch
optimizer.zero_grad()
for step, (x, y) in enumerate(loader):
with torch.cuda.amp.autocast():
loss = model(x, y) / ACCUMULATE_STEPS # Normalize loss!
scaler.scale(loss).backward()
# Gradients accumulate in .grad buffers
if (step + 1) % ACCUMULATE_STEPS == 0:
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
scaler.step(optimizer)
scaler.update()
optimizer.zero_grad()
With DDP: Use model.no_sync() context manager for accumulation steps to avoid expensive AllReduce on every backward — only sync on the last accumulation step:
for i, (x, y) in enumerate(loader):
sync_context = contextlib.nullcontext() if (i+1) % ACCUM == 0 else model.no_sync()
with sync_context:
loss = model(x, y) / ACCUM
loss.backward()
if (i+1) % ACCUM == 0:
optimizer.step(); optimizer.zero_grad()
FSDP — Fully Sharded Data Parallel
For models too large for 1 GPU (ViT-H, LLMs). FSDP shards model parameters, gradients, and optimizer states across GPUs:
DDP (N=4 GPUs):
GPU0: full model copy (10GB) + 10GB gradients + 20GB optim states = 40GB
GPU1: full model copy (10GB) + 10GB gradients + 20GB optim states = 40GB
FSDP (N=4 GPUs):
GPU0: 1/4 of params (2.5GB) + 1/4 gradients (2.5GB) + 1/4 optim (5GB) = 10GB ✅
GPU1: 1/4 of params ...
During forward: GPU0 broadcasts its shard to others → full layer weights
→ runs layer → discards non-owned params
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
from torch.distributed.fsdp import MixedPrecision, ShardingStrategy
mp_policy = MixedPrecision(
param_dtype=torch.bfloat16,
reduce_dtype=torch.bfloat16,
buffer_dtype=torch.bfloat16,
)
model = FSDP(model,
sharding_strategy=ShardingStrategy.FULL_SHARD,
mixed_precision=mp_policy,
auto_wrap_policy=transformer_auto_wrap_policy, # shard at attention layer
)
3D Parallelism (LLM scale)
Used by GPT-4, Gemini for trillion-parameter models:
Tensor Parallelism (TP)
Split single layer across GPUs
◄─────────────────────────►
┌────┬────┐ ┌────┬────┐
│TP0 │TP1 │ │TP0 │TP1 │ ← Pipeline Stage 0 (layers 1-12)
└────┴────┘ └────┴────┘
┌────┬────┐ ┌────┬────┐
│TP0 │TP1 │ │TP0 │TP1 │ ← Pipeline Stage 1 (layers 13-24)
└────┴────┘ └────┴────┘
▲ ▲
Pipeline Parallelism (PP): stages on different GPU groups
Data Parallelism (DP): entire pipeline replicated for batch throughput
Training Efficiency Tips
Gradient Checkpointing (Activation Checkpointing)
Forward pass stores only a subset of activations; recomputes the rest during backward.
- Memory: 60-70% reduction in activation memory
- Speed: ~30% slower (extra forward passes)
from torch.utils.checkpoint import checkpoint_sequential
# Recompute activations every 4 layers during backward
output = checkpoint_sequential(model.layers, segments=len(model.layers)//4, input=x)
torch.compile (PyTorch 2.0+)
model = torch.compile(model, mode='max-autotune')
# mode options:
# 'default' — balanced (safe, ~20% speedup)
# 'reduce-overhead' — reduces Python overhead (small models)
# 'max-autotune' — profile all kernel configurations (slow compile, fastest runtime)
Communication Overlap
DDP overlaps gradient computation with AllReduce — as soon as a layer's backward is computed, its gradients start being reduced while later layers continue backward. This is automatic in DDP.
Interview Questions
Q: How does DistributedDataParallel achieve linear scaling efficiency?
A: DDP achieves near-linear scaling due to communication-compute overlap and ring-allreduce efficiency. After each layer's backward pass completes, DDP immediately starts AllReducing those gradients while computing gradients for earlier layers — so communication and computation happen in parallel. Ring-allreduce has communication cost roughly independent of the number of GPUs (it grows as 2(N-1)/N × gradient_size). In practice, DDP on 8 A100s with NVLink achieves ~7.5× speedup (93% efficiency) due to NVLink's 600 GB/s bandwidth.
Q: When would you use FSDP over DDP?
A: Use FSDP when the model + optimizer states don't fit on a single GPU. With DDP, each GPU needs: 2 bytes (fp16 param) + 2 bytes (fp16 grad) + 8 bytes (fp32 master weight + Adam states) ≈ 12 bytes/param. A 1B parameter model needs 12GB per GPU — feasible. A 10B model needs 120GB per GPU — impossible even on A100 (80GB). FSDP shards everything across GPUs, so the per-GPU memory is 1/N. The tradeoff: FSDP has higher communication overhead (AllGather before each layer's forward) but that's necessary when you have no choice.
Q: You scale DDP from 1 to 8 GPUs and the training loss curves don't match. Why?
A: Several causes: (1) Learning rate not scaled: with 8× larger effective batch, you need ~2.83× higher LR (sqrt scaling) or linear scaling + warmup. (2) BatchNorm statistics: each GPU computes BN stats on its local data shard (batch/8), leading to noisy stats. Fix: use torch.nn.SyncBatchNorm.convert_sync_batchnorm(model) to synchronize BN across GPUs. (3) DistributedSampler epoch not set: without sampler.set_epoch(epoch), each epoch sees the same data order on each GPU, breaking the i.i.d. assumption.
GPU, TPU & AI Accelerator Architecture
"Pick the right hardware for the job" is a system design competency that separates senior from mid-level CV engineers.
The Memory Hierarchy Problem
GPUs are bandwidth-bound, not compute-bound for most CV workloads. The bottleneck is moving data between:
┌──────────────────────────────────────────────────────────┐
│ Host (CPU) DRAM ~50 GB/s (PCIe 4.0 ×16) │
│ ↕ PCIe │
│ GPU HBM (VRAM) ~2 TB/s (A100: 2TB/s, H100: 3.3TB/s)│
│ ↕ │
│ L2 Cache ~5 TB/s │
│ ↕ │
│ L1/Shared Mem ~20 TB/s │
│ ↕ │
│ Registers ~80 TB/s │
└──────────────────────────────────────────────────────────┘
Key insight: Minimize CPU↔GPU data transfers. Keep data resident on GPU across operations.
CUDA Programming Model
Thread Hierarchy
Grid
└── Block (max 1024 threads)
└── Thread
- Warp: 32 threads that execute in lockstep (SIMT). Divergent branches (if/else) cause warp divergence — half the warp is idle.
- Occupancy: ratio of active warps to maximum possible. Higher occupancy hides memory latency.
- Shared memory: 48–96 KB per SM, acts as programmer-controlled L1 cache. Critical for tiled matrix multiplication.
Memory Types
| Memory | Scope | Lifetime | Speed |
|---|---|---|---|
| Register | Thread | Kernel | Fastest |
| Shared | Block | Kernel | ~20 TB/s |
| L1/L2 Cache | SM / GPU | Kernel | Auto-managed |
| Global (HBM) | All threads | Application | ~2 TB/s |
| Pinned (host) | CPU | Application | ~50 GB/s (zero-copy capable) |
| Unified | CPU+GPU | Application | Slower (page faults) |
PyTorch CUDA Best Practices
# ✅ Pin memory for faster CPU→GPU transfer
loader = DataLoader(dataset, pin_memory=True, num_workers=4)
# ✅ Non-blocking transfer (overlaps with compute)
x = x.to(device, non_blocking=True)
# ✅ Mixed precision: uses Tensor Cores (2-4× throughput)
with torch.cuda.amp.autocast():
output = model(input)
# ✅ Torch.compile (PyTorch 2.0): fuses ops, reduces kernel launches
model = torch.compile(model) # ~1.5-3× speedup on A100
# ✅ Profile to find actual bottleneck
with torch.profiler.profile(
activities=[torch.profiler.ProfilerActivity.CPU,
torch.profiler.ProfilerActivity.CUDA],
with_stack=True
) as prof:
model(x)
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=10))
# ❌ Never do this — creates a new CUDA context copy
for batch in loader:
model(batch.cuda()) # if model is on CPU — silent correctness bug
TensorRT: Production GPU Inference
TensorRT is NVIDIA's inference optimizer. Converts a trained model into an optimized engine:
Optimization Steps
- Graph fusion: Fuse Conv+BN+ReLU into a single kernel (fewer memory round-trips)
- Precision calibration: FP32 → FP16 or INT8 with minimal accuracy loss
- Kernel auto-tuning: Benchmarks multiple CUDA kernel implementations, picks fastest for your GPU
- Layer/tensor fusion: Reduce memory allocation overhead
Precision vs Speed (A100 SXM):
| Precision | Tensor Core TFLOPS | Memory | Use Case |
|---|---|---|---|
| FP32 | 19.5 | 100% | Training, debugging |
| TF32 | 156 | 100% | Default PyTorch training on A100 |
| FP16 | 312 | 50% | Training (AMP), inference |
| BF16 | 312 | 50% | Training (more stable than FP16) |
| INT8 | 624 | 25% | Deployment inference |
| INT4 | 1248 | 12.5% | LLM serving (emerging) |
TensorRT Python (ONNX export path)
import torch
import onnx
import tensorrt as trt
# Step 1: Export to ONNX
model.eval()
dummy_input = torch.randn(1, 3, 640, 640, device='cuda')
torch.onnx.export(
model, dummy_input, "model.onnx",
input_names=['images'], output_names=['output'],
dynamic_axes={'images': {0: 'batch'}, 'output': {0: 'batch'}},
opset_version=17
)
# Step 2: Build TensorRT engine
TRT_LOGGER = trt.Logger(trt.Logger.WARNING)
builder = trt.Builder(TRT_LOGGER)
config = builder.create_builder_config()
config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 2 << 30) # 2GB
config.set_flag(trt.BuilderFlag.FP16) # Enable FP16
network = builder.create_network(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)
parser = trt.OnnxParser(network, TRT_LOGGER)
with open("model.onnx", 'rb') as f:
parser.parse(f.read())
engine = builder.build_serialized_network(network, config)
with open("model.trt", 'wb') as f:
f.write(engine)
# Step 3: Inference is ~2-4× faster than PyTorch eager mode
TPU Architecture (Google Cloud)
TPUs are designed specifically for matrix multiply (the dominant operation in deep learning).
MXU (Matrix Multiply Unit)
The TPU v4 contains 4 chips, each with:
- 2 MXUs: 128×128 systolic arrays (each can do 32,768 multiplications per cycle)
- HBM: 32 GB per chip
- Interconnect: High-bandwidth ICI for multi-chip (pod) setups
Why systolic array? Data flows through a grid of processing elements — each element does one multiply-accumulate. Data reuse is built into hardware, eliminating bandwidth bottleneck for large matrix multiplies.
TPU vs GPU for CV
| Aspect | GPU (A100) | TPU v4 |
|---|---|---|
| Flexibility | High (arbitrary CUDA ops) | Low (XLA compiler must handle) |
| Custom ops | Easy | Hard (must be XLA-compatible) |
| Memory | 80 GB | 32 GB/chip |
| Multi-device | NVLink/PCIe | ICI fabric (seamless) |
| Best for | Research, inference | Large-scale training (LLMs, ViT) |
| Cost (cloud) | $3-8/hr | $2-6/chip/hr |
| Framework | PyTorch/TF/JAX | JAX (best), TF, PyTorch/XLA |
JAX on TPU
import jax
import jax.numpy as jnp
from jax import jit, vmap, grad
# Functional, immutable — perfect for TPU's stateless execution model
@jit # compile with XLA → fast on TPU
def forward(params, x):
return jnp.dot(x, params['W']) + params['b']
# vmap: vectorize over batch dimension without explicit loops
batched_forward = vmap(forward, in_axes=(None, 0))
# grad: automatic differentiation (functional, no .backward())
grad_fn = grad(lambda p, x, y: jnp.mean((batched_forward(p, x) - y)**2))
# pmap: data-parallel over multiple TPU cores
parallel_forward = jax.pmap(forward)
When to Choose TPU
- Training Vision Transformers (ViT), BERT-scale models
- Large-batch training where GPU memory limits batch size
- When using JAX/Flax (native TPU framework)
- NOT recommended: models with dynamic shapes, complex custom CUDA ops
Hardware Selection Guide
Inference: Latency vs Throughput
Latency requirements:
< 10ms → GPU (A10G, T4) with TensorRT + FP16
10-100ms → GPU or CPU (depends on model size)
> 100ms → CPU may be sufficient (saves cost)
Throughput requirements:
> 1000 req/s → GPU cluster with batching (Triton Inference Server)
→ Consider NVIDIA A100 with batch_size=64+
Training Hardware
Dataset size:
Small (< 100k images): Single RTX 4090 (24GB, consumer GPU)
Medium (< 1M images): Single A100 (80GB) or 4× A6000
Large (> 10M images): Multi-GPU DDP (8× A100) or TPU pod
Real-world CV System Hardware Stack
Edge (camera): NVIDIA Jetson AGX Orin (275 TOPS, 64GB unified memory)
On-premise: 4× A100 80GB SXM + NVLink (for training)
Cloud inference: AWS g4dn.xlarge (T4 GPU, $0.53/hr) with auto-scaling
Cloud training: AWS p4d.24xlarge (8× A100, $32/hr)
Interview Questions
Q: A team wants to deploy a YOLOv8 model that runs at 30ms on A100 in PyTorch. The customer needs 10ms. What would you do?
A: I'd attack this in order of impact:
- TensorRT conversion with FP16: typically 2-4× speedup → might reach 8-15ms
- INT8 quantization if accuracy permits: another 1.5-2× on top of FP16
- Input resolution reduction: YOLOv8 at 416px vs 640px is ~2× faster
- torch.compile if staying in PyTorch: ~20-40% speedup with minimal effort
- Model distillation: train a smaller student model (YOLOv8n vs v8x)
- Hardware upgrade: T4→A10G→A100 — not a code change, but immediate
- Batching: if the use case allows, batch multiple frames together
Q: Explain the difference between DataParallel and DistributedDataParallel.
A: DataParallel (DP) uses one process, one Python GIL, replicates the model to N GPUs, splits the batch, runs forward on each, gathers outputs to GPU 0 for loss computation, then scatters gradients. Problems: (1) GIL bottleneck — Python threads can't truly parallelize, (2) GPU 0 is the gathering bottleneck — it sees more load than others (load imbalance), (3) memory overhead from gathered activations on GPU 0.
DistributedDataParallel (DDP) spawns one process per GPU. Each process has its own model replica, optimizer, and data loader. After each backward pass, gradients are synchronized via AllReduce (ring-allreduce in NCCL). No single GPU bottleneck. Scales linearly. DDP is always preferred for multi-GPU training — DP is legacy.
Q: Why is mixed precision training numerically unstable, and how does GradScaler fix it?
A: FP16 has a limited dynamic range (~6×10⁻⁸ to 65,504). Gradients during training are often very small (especially early epochs or with small learning rates) and can underflow to zero in FP16 — this is called gradient underflow. GradScaler multiplies the loss by a large scale factor (e.g., 2¹⁰) before backward, so gradients are in FP16's representable range. After backward, it unscales the gradients before the optimizer step, and checks for inf/NaN. If found, it skips the optimizer step and reduces the scale factor. If not found for many steps, it increases the scale factor. The forward pass (activations, weights) stays in FP16 for speed; master weights are kept in FP32 for accuracy.
Model Serving at Scale
"It works in training" is not enough. This doc covers everything needed to serve CV models reliably at production load.
Latency vs Throughput Tradeoff
Latency: time for a single request (p50/p95/p99)
Throughput: requests processed per second (RPS)
They're fundamentally in tension:
- Batching increases throughput (GPU utilization goes up) but adds latency (waiting to fill the batch)
- Single-request serving minimizes latency but wastes GPU (utilization may be 5%)
Throughput vs Latency for YOLOv8m on A100:
batch_size=1: 8ms latency, 125 RPS, GPU util=12%
batch_size=8: 12ms latency, 667 RPS, GPU util=45%
batch_size=32: 22ms latency, 1455 RPS, GPU util=78%
batch_size=64: 35ms latency, 1828 RPS, GPU util=90%
batch_size=128: 60ms latency, 2133 RPS, GPU util=95%
Design rule: Choose the largest batch size where latency stays within SLA.
NVIDIA Triton Inference Server
Triton is the production standard for serving CV/ML models at scale.
Why Triton?
- Multi-framework: PyTorch (TorchScript), ONNX, TensorRT, TensorFlow, Python backends
- Dynamic batching: collects requests arriving within a configurable window and batches them automatically — no client-side batching needed
- Concurrent model instances: run N copies of the model simultaneously on one GPU
- Model pipelines: chain models (preprocessor → detector → classifier) in a single server
- gRPC + HTTP: standardized API with metrics, health checks
Configuration
model_repository/
└── yolov8_detector/
├── config.pbtxt
└── 1/
└── model.plan (TensorRT engine)
# config.pbtxt
name: "yolov8_detector"
backend: "tensorrt"
max_batch_size: 64
input [{ name: "images" data_type: TYPE_FP32 dims: [3, 640, 640] }]
output [{ name: "output0" data_type: TYPE_FP32 dims: [-1, 8400] }]
dynamic_batching {
preferred_batch_size: [8, 16, 32, 64]
max_queue_delay_microseconds: 5000 # wait up to 5ms to fill batch
}
instance_group [{ kind: KIND_GPU count: 2 }] # 2 model instances per GPU
Python Client
import tritonclient.grpc as grpcclient
import numpy as np
client = grpcclient.InferenceServerClient("triton-server:8001")
# Async client for maximum throughput
async def infer(image_batch: np.ndarray):
inputs = [grpcclient.InferInput("images", image_batch.shape, "FP32")]
inputs[0].set_data_from_numpy(image_batch)
outputs = [grpcclient.InferRequestedOutput("output0")]
result = await client.async_infer("yolov8_detector", inputs, outputs=outputs)
return result.as_numpy("output0")
FastAPI Inference Microservice
For teams not using Triton — a clean FastAPI pattern:
# app.py
from fastapi import FastAPI, File, UploadFile
from contextlib import asynccontextmanager
import torch
import asyncio
from typing import AsyncIterator
import numpy as np
import cv2
# ── Model Loading ──────────────────────────────────────────────────
model = None
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
global model
model = load_model("yolov8m.pt") # loaded once at startup
model.eval()
yield
# Cleanup on shutdown
app = FastAPI(lifespan=lifespan)
# ── Request Batching Queue ─────────────────────────────────────────
class BatchProcessor:
def __init__(self, max_batch: int = 32, max_wait_ms: float = 10):
self.queue: asyncio.Queue = asyncio.Queue()
self.max_batch = max_batch
self.max_wait_ms = max_wait_ms
async def add_request(self, img: np.ndarray) -> list:
future = asyncio.get_event_loop().create_future()
await self.queue.put((img, future))
return await future # blocks until result ready
async def worker(self):
"""Background task collecting and batching requests."""
while True:
batch_imgs, batch_futures = [], []
deadline = asyncio.get_event_loop().time() + self.max_wait_ms / 1000
# Collect up to max_batch requests
while len(batch_imgs) < self.max_batch:
try:
timeout = deadline - asyncio.get_event_loop().time()
if timeout <= 0: break
img, fut = await asyncio.wait_for(self.queue.get(), timeout)
batch_imgs.append(img)
batch_futures.append(fut)
except asyncio.TimeoutError:
break
if not batch_imgs:
await asyncio.sleep(0.001)
continue
# Run batch inference
results = run_inference_batch(batch_imgs)
for fut, res in zip(batch_futures, results):
fut.set_result(res)
batcher = BatchProcessor()
@app.post("/detect")
async def detect(file: UploadFile = File(...)):
contents = await file.read()
img = cv2.imdecode(np.frombuffer(contents, np.uint8), cv2.IMREAD_COLOR)
detections = await batcher.add_request(img)
return {"detections": detections}
@app.get("/health")
async def health():
return {"status": "ok", "gpu": torch.cuda.is_available()}
Horizontal Scaling with Kubernetes
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: cv-inference
spec:
replicas: 4
selector:
matchLabels: { app: cv-inference }
template:
spec:
containers:
- name: cv-inference
image: myregistry/cv-inference:v2.1
resources:
limits:
nvidia.com/gpu: "1"
memory: "16Gi"
requests:
nvidia.com/gpu: "1"
memory: "8Gi"
env:
- name: MODEL_PATH
value: "s3://models/yolov8m-trt-fp16.engine"
readinessProbe:
httpGet: { path: /health, port: 8080 }
initialDelaySeconds: 30 # Model load time
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
minReplicas: 2
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 70 }
- type: Pods
pods:
metric:
name: requests_per_second
target:
type: AverageValue
averageValue: "100"
Caching & Pre-computation
Not all frames need real-time inference. Strategic caching:
# Frame deduplication using perceptual hash
import imagehash
from PIL import Image
def phash_key(frame: np.ndarray, threshold: int = 10) -> str:
pil = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
return str(imagehash.phash(pil))
class InferenceCache:
def __init__(self, redis_client, ttl_sec: int = 2):
self.redis = redis_client
self.ttl = ttl_sec
def get(self, frame: np.ndarray):
key = phash_key(frame)
cached = self.redis.get(key)
if cached:
return deserialize(cached), True # cache hit
return None, False
def set(self, frame: np.ndarray, result: dict):
key = phash_key(frame)
self.redis.setex(key, self.ttl, serialize(result))
For static cameras: consecutive frames of an empty scene are perceptually identical. Cache hit rate can be 80-90%, reducing GPU load dramatically.
Model Versioning & Blue-Green Deployment
# Model registry pattern (MLflow-compatible)
class ModelRegistry:
def __init__(self):
self.versions = {
"production": load_model("v2.1"),
"staging": load_model("v2.2"), # new version being validated
}
self.shadow_fraction = 0.05 # 5% of traffic to shadow
def infer(self, x, *, shadow: bool = False):
prod_result = self.versions["production"](x)
if shadow and random.random() < self.shadow_fraction:
# Run staging model on same input, log comparison
staging_result = self.versions["staging"](x)
log_comparison(prod_result, staging_result) # async
return prod_result # always return production result to user
Interview Questions
Q: Design the batching strategy for a real-time video analytics API with a 100ms SLA.
A: First, I'd establish the math: at 100ms SLA, we can afford at most 80ms queuing + processing time (leaving buffer). With TensorRT YOLOv8 at ~8ms inference time, I'd set max_queue_delay to 50ms with preferred batch sizes of 8, 16, 32. Clients hitting us from 100 cameras at 10 FPS each = 1,000 RPS. At batch_size=32 taking ~22ms, we need 1000/1455 ≈ 1 A100 for steady-state. I'd scale to 2 for redundancy. For burst capacity, set HPA to trigger at 70% queue saturation.
Q: How would you do A/B testing for a new model version in production?
A: I'd use a shadow deployment first: route 5% of production traffic to the new model, compare outputs with production model, but always return production results to users. Monitor: mAP on a labeled evaluation set, latency p99, GPU memory, false positive rate. After 24 hours if metrics look good, promote to a canary (10% of traffic actually served from new model), monitor for user-visible metrics (false alert rate). If metrics hold for 48 hours, full rollout via blue-green: spin up new deployment, switch load balancer, keep old deployment on standby for 24 hours in case of rollback.
Q: How do you handle model warm-up in production?
A: A freshly loaded model has cold CUDA caches — the first few inferences are 2-5× slower than steady-state (GPU kernel compilation happens on first run). Best practices: (1) Kubernetes readiness probe set to 30s+ delay after container start, (2) Run N warmup inferences during lifespan startup before marking service as ready, (3) Use torch.compile or TensorRT which pre-compiles kernels at build time, eliminating first-inference lag entirely.