« All Roles

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

WeekPhaseFocus
10Python advanced patterns + NumPy for image ops
20Math for ML: linear algebra, calculus, probability
31Image basics, color spaces, histograms
41Filtering, morphology, edge detection
51Feature detection (Harris, SIFT, ORB), optical flow, tracking
61–2Camera calibration + ML fundamentals kickoff
72Data preprocessing, evaluation metrics (mAP, IoU, AUC)
83PyTorch: tensors, autograd, building nn.Module
93Training loops, loss functions, optimizers
103–4DataLoaders + TensorFlow/Keras API
114–5TF custom training + CNN fundamentals
125Transfer learning: ResNet, EfficientNet, MobileNet
135Object detection: YOLOv8 + Faster R-CNN
145Semantic segmentation: U-Net, DeepLabV3+
155–6Instance segmentation + Vision Transformers
166CLIP, SAM & open-vocab detection, RT-DETR, VLMs, Diffusion basics
177MLOps: ONNX export, TensorRT, FastAPI inference
187Docker, cloud (AWS/GCP), MLflow
198Capstone Project 1 + 2
208 + PrepCapstone Project 3 + full interview prep review

Each Lab Structure

Every lab contains the following files:

FilePurpose
README.mdDeep theory, math derivations, algorithm internals, interview Q&A
lab.pyGuided exercise with # TODO markers — fill in the blanks
solution.pyComplete, production-quality solution with inline commentary
exploration.ipynbJupyter notebook for visual/interactive exploration (select phases)
requirements.txtPinned pip dependencies
DATASETS.mdDownload 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

TierSetupBest For
MinimalCPU-only laptopPhases 0–2, small experiments
MidNVIDIA RTX 3060+ (8 GB)Phases 3–6 comfortably
RecommendedNVIDIA RTX 4090 / A100 (24+ GB)Phase 5+ with large batch sizes
CloudGoogle Colab Pro / Kaggle / Lambda LabsOn-demand GPU, no hardware cost
EnterpriseTPU v4 (GCP) / AWS TrainiumDistributed training at scale

System Design Philosophy

Throughout this curriculum, every non-trivial solution is built with production scalability in mind:

  1. Throughput — how many images/second can this pipeline handle?
  2. Latency — what is the P99 inference time?
  3. Hardware efficiency — are we saturating GPU/TPU? What's the memory footprint?
  4. Fault tolerance — what happens if a model server crashes?
  5. 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

LabTopicKey Skills
lab-01-python-advancedPython internals & patternsgenerators, decorators, dataclass, __slots__, context managers, type hints
lab-02-numpy-matplotlibNumPy for image opsbroadcasting, fancy indexing, strides, image manipulation, visualization
lab-03-math-for-mlLinear algebra + calculus + probabilitySVD, 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

The subset of "advanced Python" that ML codebases lean on daily — learn these as reading skills first:

  • Mutability and references: assignment never copies; b = a aliases. 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. Know copy vs deepcopy and 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 just f = 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 ndarray is 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 need contiguous memory (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 in uint8 overflows silently (200 + 100 = 44); convert to float32 for 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.Generator with 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 and cmap='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 (savefig with 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:

  1. Explain views vs copies via strides, and the uint8-overflow habit.
  2. State the broadcasting rule exactly and produce the all-pairs distance trick.
  3. Write a decorator and a context manager without reference.
  4. Hand-compute a chain-rule gradient and verify it numerically.
  5. Derive softmax+cross-entropy's $p - y$ gradient.
  6. 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

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:

LibraryShapeChannel orderDtype
OpenCV(H, W, C)BGRuint8
PyTorch(C, H, W)RGBfloat32 [0,1]
TensorFlow/Keras(H, W, C)RGBfloat32 [0,1]
Matplotlib(H, W, C)RGBuint8 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:

  1. If shapes have different number of dimensions, prepend 1s to the smaller shape.
  2. Dimensions must be equal or one must be 1.
  3. 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

FunctionUse Case
np.clipPrevent overflow after arithmetic (e.g., after adding noise)
np.padAdd padding before convolution
np.rollCircular shift (useful for augmentation)
np.einsumEfficient batched dot products, attention scores
np.linalg.svdPCA, image compression, denoising
np.fft.fft2Frequency domain analysis, filtering
np.lib.stride_tricks.sliding_window_viewEfficient 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

NormFormulaUse in CV/ML
L1 ($\ell_1$)$\sum_ix_i
L2 ($\ell_2$)$\sqrt{\sum_i x_i^2}$Ridge regression, weight decay, distance between embeddings
L∞$\max_ix_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() returns np.ndarray — no special types.
  • In-place vs copy: Many functions have dst parameter. When None, a new array is allocated.
  • Data types matter: Many functions expect uint8 (0–255); filters need float32 or float64; always check img.dtype.

Labs

LabTopicKey APIs
lab-01-image-basicsColor spaces, histograms, pixel opscv2.imread, cvtColor, calcHist, equalizeHist
lab-02-filtering-morphologySpatial filtering, edge detectionGaussianBlur, Canny, morphologyEx, findContours
lab-03-feature-detectionKeypoints, descriptors, matchingSIFT, ORB, BFMatcher, findHomography
lab-04-optical-flow-trackingMotion estimation, object trackingcalcOpticalFlowPyrLK, calcOpticalFlowFarneback, TrackerCSRT
lab-05-camera-calibrationCamera geometry, calibrationcalibrateCamera, 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

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 goodFeaturesToTrack is 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: goodFeaturesToTrackcalcOpticalFlowPyrLK). 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:

  1. State the uint8/float, BGR/RGB, and (y, x) conventions and the bug each causes.
  2. Explain Gaussian separability's O(k²)→O(k) and when median/bilateral beat Gaussian.
  3. Walk Canny's four stages and the hysteresis design pattern.
  4. Build (on paper) the Otsu→morphology→contours measurement pipeline and say when it beats deep learning.
  5. Explain ratio test + RANSAC and the hypothesize-verify pattern.
  6. State brightness constancy, the aperture problem, and why LK tracks corners.
  7. 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 BGRcv2.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:

  1. Perceptually uniform: Euclidean distance in LAB correlates with human-perceived color difference
  2. Separates luminance from chrominance: The L channel is a pure grayscale image unaffected by color
  3. 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):

OperationDefinitionUse case
ErosionA pixel survives only if all pixels in SE are foregroundRemove small noise, thin objects
DilationA pixel becomes foreground if any pixel in SE is foregroundFill holes, thicken objects
OpeningErosion then dilationRemove small isolated foreground regions (noise)
ClosingDilation then erosionFill small holes in foreground regions
GradientDilation − ErosionExtract object edges/borders
Top-hatImage − OpeningHighlight 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:

  1. Gaussian blur: $I_{\sigma} = G_\sigma * I$ — suppress noise
  2. Gradient computation: $G_x, G_y$ via Sobel. Compute magnitude $M$ and direction $\theta$
  3. 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.
  4. Double thresholding: Classify pixels as:
    • Strong edge: $M > T_{high}$
    • Weak edge: $T_{low} < M \leq T_{high}$
    • Non-edge: $M \leq T_{low}$
  5. 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 largeBoth largeCorner — large variation in all directions
One large, one smallEdge — large variation in one direction only
Both smallBoth smallFlat — 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:

FeatureSIFTORBAKAZE
Descriptor size128 float32 = 512 bytes256 bits = 32 bytes61 bytes
Scale invariant
Rotation invariant
Affine invariantPartial
SpeedSlowFastMedium
Patent (2024)FreeFreeFree

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:

  1. Randomly sample 4 point pairs (minimum to solve homography)
  2. Compute $H$ from these 4 pairs
  3. Count inliers: points where $|Hx - x'| < \epsilon$ ("reprojection error")
  4. Keep the $H$ with the most inliers
  5. 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

MethodTypeSpeedAccuracyUse Case
Lucas-KanadeSparseFastHigh on cornersTrack specific features
FarnebackDenseMediumMedium everywhereFull motion analysis
DeepFlow/RAFTDense DLSlow (GPU)BestProduction video

What the Lab Covers

FunctionConceptComplexity
create_synthetic_video()Controlled ground-truth motion-
lucas_kanade_demo()Sparse LK with goodFeaturesToTrackMedium
farneback_dense_demo()Dense flow + HSV visualizationMedium
optical_flow_magnitude_demo()Motion heatmap, background subtractionEasy
multi_scale_pyramid_demo()Pyramid LK for large motionHard

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)

  1. Observe a planar pattern (chessboard) from $\geq 3$ different orientations
  2. Compute homography $H_i$ between pattern plane and image for each view
  3. Each homography gives 2 constraints on $\mathbf{K}$
  4. 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

FunctionConcept
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

LabTopicKey Skills
lab-01-sklearn-pipelineSVM, Random Forest, cross-validationML pipeline, hyperparameter search
lab-02-data-preprocessingNormalization, augmentation, class imbalanceAlbumentations, SMOTE, focal loss
lab-03-model-evaluationConfusion matrix, mAP, ROC-AUCEvaluation 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

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_weight first (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:

  1. Define generalization gap and explain why the test set speaks once.
  2. Diagnose bias-vs-variance from learning curves and prescribe per diagnosis.
  3. Name four leakage modes with their split-design fixes.
  4. Say which models need scaling and why trees don't.
  5. Choose PR-AUC vs ROC-AUC under imbalance and defend a threshold from costs.
  6. 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:

  1. A bootstrap sample (random sample with replacement) of the training set
  2. 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

CategoryOperationsPreserves Label?
Geometricflip, rotate, scale, crop, perspectiveUsually yes
Photometricbrightness, contrast, hue, saturationAlways
Noise/BlurGaussian noise, motion blur, JPEG artifactsAlways
RegularizationCutout, CutMix, MixUpRequires label mixing
Domain-specificElastic 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

MethodHowProCon
Class weightsweight[c] = N / (K × N_c)No data neededDoesn't help with hard negatives
Oversampling (SMOTE)Synthesize minority examplesFixes marginal distributionDoesn't apply to images
UndersamplingRandomly drop majorityFastLoses information
Focal LossDown-weight easy negativesBest for detectionNeeds tuning of γ
Data collectionMore minority examplesCorrect fixExpensive

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 PositivePredicted Negative
Actually PositiveTPFN
Actually NegativeFPTN

$$\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$:

  1. Sort all detections by confidence score (descending)
  2. For each detection: TP if IoU with a GT box ≥ threshold, else FP
  3. Compute precision/recall curve
  4. 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

FunctionConceptInterview 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 + ColumnTransformer for reproducible feature engineering
  • Implement cross-validation strategies for imbalanced datasets
  • Run GridSearchCV / RandomizedSearchCV and 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

LabTopicKey Skills
lab-01-pytorch-tensors-autogradTensors, autograd, custom backwardCUDA, mixed precision
lab-02-training-loopDataLoader, training loop, optimizersAMP, gradient accumulation
lab-03-cnn-from-scratchBuild ResNet-like CNNBatchNorm, skip connections
lab-04-transfer-learningFine-tune pretrained modelsFeature extraction vs fine-tuning
lab-05-distributed-trainingDDP, gradient accumulationMulti-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

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 — hence optimizer.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.compile later 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 .grad by default; intermediate gradients need retain_grad() — relevant when debugging.
  • Verify understanding once: implement a custom torch.autograd.Function with 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 — use nn.ModuleList).
  • train() vs eval() mode: flips Dropout and BatchNorm behavior. Forgetting model.eval() at validation (or train() after) is bug bestiary #2 — symptoms: noisy val metrics or frozen-stats training.
  • Losses: CrossEntropyLoss takes 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()
BugSymptom
missing zero_gradloss decreases then oscillates; effective LR wrong
missing model.eval() / no_grad at valval noisy, OOM at eval, slow eval
softmax before CrossEntropyLosstrains, but worse — doubly-squashed gradients
accumulating loss (not loss.item()) into a listmemory leak — the whole graph retained per step
labels/logits shape or dtype mismatchsometimes an error; sometimes silent broadcasting garbage (Phase 00 Ch. 3's warning, realized)
transforms with stale normalization statsaccuracy 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:
    1. Linear probe (freeze all, train a new head): tiny data, similar domain.
    2. Partial fine-tune (unfreeze last block(s) + head): the workhorse middle.
    3. 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 with DistributedDataParallel; use DistributedSampler (and set_epoch for 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 .grad accumulation 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:

  1. Explain device semantics, the .item() sync trap, and NCHW vs HWC.
  2. Describe graph construction/backward, why zero_grad, and what detach does.
  3. Recite five bestiary bugs with symptoms.
  4. Do conv output-size and receptive-field arithmetic; explain BN's train/eval split.
  5. Tell the ResNet degradation story and connect residuals to the other gradient highways you know.
  6. Choose a transfer regime from data size × domain gap and list the three mechanical musts.
  7. 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

Lab 3-01: PyTorch Tensors & Autograd

Learning Goals

  • Master tensor operations and their CUDA equivalents
  • Understand PyTorch's dynamic computation graph
  • Use autograd to 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

SchedulerBehaviorBest For
StepLRDecay by $\gamma$ every $k$ epochsSimple baselines
CosineAnnealingLRCosine decay to $\eta_{min}$ResNets, most CNNs
OneCycleLRWarmup → peak → cosine decay (1 cycle)Fast training (less epochs)
ReduceLROnPlateauReduce LR when metric plateausWhen you don't know n_epochs
WarmupCosineLinear warmup + cosineTransformers

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

SectionContent
SyntheticImageDatasetCustom Dataset + DataLoader + pin_memory
SimpleCNN3-block CNN with BatchNorm
EarlyStoppingPatience-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

ModelBlocksParamsTop-1 (ImageNet)
ResNet-18Basic11.7M69.8%
ResNet-34Basic21.8M73.3%
ResNet-50Bottleneck25.6M76.1%
ResNet-101Bottleneck44.5M77.4%
ResNet-152Bottleneck60.2M78.3%

What the Lab Covers

FunctionConcept
vanishing_gradient_demo()Gradient norms per layer, plain vs ResNet
BasicBlockExact paper implementation with downsample
BottleneckBlockChannel 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

StrategyWhenTrainable ParamsData Needed
Feature extraction< 1K images, similar domainOnly new headVery little
Partial fine-tuning1K-10K imagesLast N layers + headModerate
Full fine-tuning> 10K images, different domainAll layersMore

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

  1. Freeze all except head → train 1-2 epochs
  2. Unfreeze last block → train 1-2 epochs
  3. Unfreeze more blocks → train with lower LR
  4. 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

FunctionConcept
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)

DimensionWhat's splitFor
Data parallelMini-batchFast, always use
Tensor parallelIndividual weight matricesLarge FC/attention
Pipeline parallelModel layers across GPUsHuge models (GPT-3+)

What the Lab Covers

FunctionConcept
DDP_TEMPLATEProduction 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.data pipelines, 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

LabTopicKey Concepts
lab-01-keras-functional-apiKeras Functional APImulti-input, shared layers, custom layers
lab-02-tf-data-pipelinetf.data Input Pipelines.map(), .batch(), .prefetch(), augmentation
lab-03-tflite-edge-deployTFLite Conversion & QuantizationINT8 post-training quantization, benchmarking

TF vs PyTorch Cheatsheet

ConceptPyTorchTensorFlow/Keras
Model definitionnn.Moduletf.keras.Model or Functional API
Forward passmodel(x)model(x) or model.predict(x)
Training loopmanualmodel.fit() or manual
Lossnn.CrossEntropyLosstf.keras.losses.SparseCategoricalCrossentropy
Optimizertorch.optim.Adamtf.keras.optimizers.Adam
Datasettorch.utils.data.Datasettf.data.Dataset
Exporttorch.onnx.export / TorchScriptmodel.save() (SavedModel) / TFLite
Gradient tapeloss.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

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 Layernn.Module, tf.dataDataLoader, 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:
    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)
    
    Because the graph is explicit data, Keras can validate shapes at construction time, model.summary() and plot_model render it, and serialization is exact — the payoff of declaring structure rather than executing it.
  • Subclassing (keras.Model with call()): 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):

  1. Python side effects run at trace time only: a print() fires once; tf.print fires every call. A Python counter increments once. If you need stateful behavior, use tf.Variable.
  2. 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_signature with None dims).
  3. Data-dependent Python control flow doesn't survive tracing: if tensor > 0: fails or bakes one branch; AutoGraph converts simple cases to tf.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 (keeps fit's machinery — callbacks, distribution — while customizing the step), or go fully manual with tf.GradientTape:
    with 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))
    
    The tape is autograd made explicit and scoped — record only inside the with. Note the training=True/False argument (TF's explicit version of model.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 = False for the probe stage — with the documented BN subtlety (frozen BN should run in inference mode; Keras handles it when trainable=False, if you pass training=False correctly — 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):

  1. Plain conversion (FP32): graph ops → TFLite kernels; size from graph trimming.
  2. Dynamic-range quantization: weights INT8, activations FP32 — ~4× smaller, modest speedup, near-zero accuracy risk; the default first rung.
  3. 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).
  4. 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, and cache — 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:

  1. Place a modeling task on the Sequential/Functional/Subclassing spectrum with the data-structure-vs-code rationale.
  2. State the three tf.function rules and the bug each prevents; explain trace-time vs run-time.
  3. Write the canonical tf.data pipeline and justify each stage's order; name the input-bound diagnosis tool.
  4. Use train_step/GradientTape and the training= flag correctly.
  5. Describe SavedModel signatures and the export-verify ritual.
  6. 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

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.Layer subclasses
  • 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.Dataset pipelines
  • 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.data pipelines 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

RuleWhy
.cache() before .shuffle()Shuffle runs on already-decoded data
.prefetch(AUTOTUNE) lastAlways — overlaps CPU/GPU work
num_parallel_calls=AUTOTUNE in .map()Parallelizes decoding automatically
Augmentation after .batch()GPU can vectorize batched operations
drop_remainder=TrueFixed 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

TypeWeightsActivationsCalibration NeededSize Reduction
FP32float32float32No1x (baseline)
FP16float16float32No~2x
Dynamic INT8int8float32No~4x
Full INT8int8int8YES (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

LabTopicKey Papers
lab-01YOLOv8 — training, evaluation, TensorRT exportUltralytics YOLOv8 (2023)
lab-02Faster R-CNN — two-stage detection from scratchRen et al., 2015
lab-03U-Net — semantic segmentationRonneberger et al., 2015
lab-04Mask R-CNN — instance segmentationHe et al., 2017

Prerequisites

  • Phase 3 complete (PyTorch training loops, ResNet)
  • Phase 4 recommended (TensorFlow/Keras) but not required

Learning Path

  1. Start with YOLOv8 (lab-01) — get end-to-end detection working fast
  2. Study Faster R-CNN theory (lab-02) — understand two-stage detectors deeply
  3. U-Net (lab-03) — most important for medical/industrial CV
  4. 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

Classification answers what; this phase's tasks answer what and where, at increasing granularity:

TaskOutputUnit of prediction
Classificationone labelimage
Object detectionboxes + labels + scoresobject
Semantic segmentationa label per pixelpixel (no instances: all "car" pixels are one class)
Instance segmentationa mask per objectobject × pixel
(Panoptic)both: stuff + thingseverything

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:

  1. 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.
  2. Sweep the score threshold → precision-recall curve per class.
  3. 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).
  4. 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_mosaic disables 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_params format 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:

  1. Choose the minimal sufficient task for three product scenarios and price the annotation delta.
  2. Define IoU, the anchor→offset scheme, and NMS with its production knobs.
  3. Walk the mAP pipeline including COCO's 0.5:0.95 convention and the size breakdown.
  4. Tell the R-CNN→Faster R-CNN story via the shared-computation and learned-proposal insights; explain FPN and RoIAlign.
  5. Explain why one-stage detectors needed focal loss, and what label assignment is.
  6. Draw U-Net and defend the skip connections; state Dice's role under imbalance.
  7. Describe Mask R-CNN's decomposition and its two key design choices.
  8. 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.
  9. 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.
  10. 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

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

FeatureYOLOv5YOLOv8
Detection paradigmAnchor-basedAnchor-free
HeadCoupledDecoupled
Box lossCIoUDFL + CIoU
BackboneCSPDarknetC2f (CSP with 2 bottlenecks)
AugmentationMosaicClose 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

  1. Don't freeze backbone for small datasets (< 1000 images) — YOLOv8 handles this automatically
  2. Close mosaic augmentation last 10 epochs — mosaic creates unrealistic objects at boundaries, hurts final mAP
  3. Use rect=True for variable aspect ratio datasets — reduces padding waste
  4. 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 n and s sizes 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

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:

  1. Stage 1 — Region Proposal Network (RPN): "Where could objects be?"
  2. 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).

TaskOutputExample
Classification1 label per image"This is a cat"
DetectionBounding boxes"Cat at [x1,y1,x2,y2]"
Semantic segmentationLabel per pixelEach pixel = car/road/sky
Instance segmentationLabel+ID per pixelCar #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:

  1. Only train the mask branch for GT-matched positive proposals (IoU > 0.5)
  2. Use the GT class to select which mask channel to compute loss on
  3. Loss: sigmoid BCE on 28×28 binary mask

Inference:

  1. Select mask channel corresponding to predicted class
  2. Apply sigmoid → binary threshold at 0.5
  3. Resize from 28×28 back to proposal bounding box size
  4. Paste into full image canvas

Semantic vs Instance Segmentation

SemanticInstance
Distinguishes instances?NoYes
Same-class objectsSame labelDifferent IDs
Handles overlap?NoYes
OutputH×W label mapN masks per image
Typical architectureFCN, U-Net, DeepLabMask 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

ModelImprovementSpeed
Mask R-CNNBaseline~5 FPS (ResNet-50)
SOLONo RoIs, direct per-position masks~10 FPS
SOLOv2Dynamic convolutions~15 FPS
PointRendRender masks at uncertain boundary points+1-2 mAP
Mask2FormerTransformer-based, universal segmentationSOTA

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

LabTopicKey Concepts
lab-01-vision-transformerViT from scratchPatch embedding, positional encoding, TransformerEncoder, attention maps
lab-02-clip-contrastiveCLIP-style contrastive learningInfoNCE loss, image-text alignment, zero-shot classification
lab-03-dino-self-supervisedDINO-style self-supervisedStudent-teacher with EMA, multi-crop, centering + sharpening
lab-04-promptable-segmentationMini-SAM from scratchPoint-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

ModelBackbonePre-trainingZero-shot?Key Innovation
ResNetCNNSupervisedNoResidual connections
ViT-B/16TransformerSupervised (JFT-300M)NoPatches as tokens
CLIPViT + Text Enc.Contrastive (400M pairs)YesImage-text alignment
DINOViTSelf-supervisedNo (but great features)Student-teacher + EMA
DINOv2/v3ViT (+ registers)Self-supervised (curated LVD)No (frozen-feature default)DINO + iBOT objectives, register tokens, Gram anchoring
SAM / SAM2ViT-H (+ memory)SA-1B / SA-V data engineYesPromptable segmentation; streaming memory for video
OWL-ViT / Grounding DINOViT / DETR-family + textImage-text + detectionYes (open-vocab)Text as the detection label space
DETR / RT-DETRCNN + TransformerSupervisedNoSet prediction + Hungarian matching — no NMS; real-time hybrid encoder
LLaVA / Qwen2.5-VL-classCLIP tower + LLMAlignment + instruction tuningYes (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

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.

  1. 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).
  2. 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).
  3. 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":

SituationDefault answer
Tight edge latency, fixed classes, enough labelsCNN (or small hierarchical ViT) fine-tune — Phase 03/05 playbook
Few labels, common visual domainfrozen DINOv2 features + linear/k-NN head
Classes change at runtime / long-tail vocabularyCLIP zero-shot or open-vocab detector (Ch. 8)
Masks needed, no segmentation labelsSAM/SAM2 prompted (distilled variant at the edge) — Ch. 7
Dense prediction at scalehierarchical 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 datafine-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:

  1. State the inductive-bias-vs-data law and what DeiT changed about it.
  2. Walk ViT's patchify → CLS → position-embedding pipeline and the resolution- interpolation mechanism; explain the patch-size dial.
  3. Write CLIP's symmetric InfoNCE idea and why batch size and temperature matter.
  4. Demonstrate (on paper) zero-shot classification and name CLIP's four weak spots.
  5. Explain DINO's agreement-across-views intuition and its three emergent properties.
  6. Apply the backbone-choice table to three scenarios with cost reasoning.
  7. 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.
  8. 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.
  9. Tell the register-tokens story end to end: the artifact, the hijacked-patches cause, the fix, and what it teaches about diagnosing ViTs.
  10. 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

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

TrickProblem Solved
EMA teacherProvides stable targets; gradients only flow through student
CenteringPrevents mode collapse (all outputs → same prototype)
Sharpening (low τ)Prevents uniform distribution collapse
Multi-cropMore views per image → better representations, lower cost
Stop-gradient on teacherTeacher 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

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:

  1. 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.

  2. 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.

  3. 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

FileRole
README.mdThis document — theory, walkthrough, references
lab.pySkeleton with TODO(1)TODO(11); every body raises NotImplementedError
solution.pyComplete reference implementation, heavily commented
test_lab.py14 pytest tests; runs against solution by default, your lab.py via LAB_MODULE=lab
requirements.txttorch, 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):

  1. Token self-attention — the iou/mask/prompt tokens negotiate among themselves; this is where mask tokens coordinate who covers which interpretation.
  2. Cross-attention, tokens → image — tokens gather visual evidence (what intensity is under the click, how far does that region extend).
  3. MLP on tokens — per-token nonlinear mixing.
  4. Cross-attention, image → tokensthe 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:

  1. 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.)
  2. 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.
  3. ε-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:

  1. 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. Single default_rng(seed) for everything — a test checks bit-identical replay.
  2. PromptEncoder — TODO(2). Buffer freq and the label embedding from a private seeded torch.Generator (so two encoders with equal seeds match exactly); fourier, forward, dense_pe (same basis on cell centers), dense_prompt (the beacon).
  3. 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.
  4. Attention — TODO(4). By hand: q/k/v/out projections, head split, softmax(q·kᵀ/√d_head), merge. No nn.MultiheadAttention.
  5. TwoWayAttentionBlock — TODO(5). The four steps, post-LN residuals, and PE re-injection into q and k at every call.
  6. mask_quality_stats — TODO(6). Morphology via max_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 under no_grad.
  7. MaskDecoder — TODO(7). Token concatenation ([iou, m0, m1, m2, prompt]), input_norm neck (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.
  8. MiniSAM — TODO(8). Wire the three parts, but keep encode_image / decode_prompt as separate methods — the split is the serving lesson.
  9. mask_iou + dice_loss — TODO(9). Reductions over the last two dims so both broadcast over arbitrary leading dims.
  10. sam_loss — TODO(10). Per-candidate BCE+dice → argmin → gather winner; + relax × mean; + iou_weight × MSE(iou_pred, detached actual IoU of all candidates).
  11. train_mini_sam + evaluate_iou — TODO(11). Adam + cosine decay, seeded minibatch sampler, loss history; evaluation selects each sample's mask by iou_pred.argmax and 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), then SAM2ImagePredictor.set_image(img) once followed by many predict(point_coords=..., multimask_output=True) calls — you will find the API mirrors encode_image / decode_prompt from this lab almost method-for-method. Ultralytics and HuggingFace transformers (facebook/sam2-hiera-small) ship wrappers as well.

Stretch goals

  1. Box prompts. Encode a box as two corner tokens (each = γ(corner) + corner-role embedding), concatenate with the point token, and extend make_dataset to emit boxes. Boxes are near-unambiguous — verify the 3 candidates converge for box prompts while still diverging for overlap points.
  2. 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.
  3. 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).
  4. 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.
  5. Remove a load-bearing part. Delete PE re-injection (pass zeros as token_pe/image_pe after 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

#LabCore Skills
01ONNX Export & Optimizationtorch.onnx.export, ONNX graph, TensorRT, FP16/INT8
02FastAPI Inference Serverasync API, dynamic batching, Prometheus metrics
03Docker DeploymentDockerfile, nvidia-docker, multi-stage build
04MLflow Experiment Trackingruns, 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

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 — async alone 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 -r with 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:

  1. System: latency percentiles (p50/p99), throughput, error rates, GPU/CPU utilization — ordinary SRE, plus model-version tags on every metric.
  2. 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.
  3. 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:

  1. Profile the whole request (timestamps per stage — decode/preprocess/infer/post) before optimizing anything (the universal lesson, again).
  2. 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.
  3. Throughput wins: dynamic batching (Ch. 3), worker scaling, pinned-memory async copies.
  4. 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.
  5. 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.export is 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.export graphs 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_size is reached or max_queue_delay expires, 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 the dynamic_batching block (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_analyzer for load-testing the exact config you wrote.
  • 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:

  1. Name the export pitfalls and recite the verification ritual; argue where post-processing should live.
  2. Explain why async ≠ throughput for inference, and what dynamic batching trades.
  3. List five preprocessing-parity failure modes and the three defenses.
  4. Apply the Docker craft list and justify weights-outside-images.
  5. Describe tracking/registry/lineage and the promotion-with-evidence rule.
  6. Sketch the three monitoring layers with one CV-specific drift example each.
  7. Order the performance playbook and defend measurement-first.
  8. 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.
  9. 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.
  10. 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

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

#ProjectKey Skills Demonstrated
01Real-Time Object Detection PipelineYOLOv8-style inference + FastAPI + Docker + monitoring
02Face Recognition SystemFace detection + ArcFace embedding + FAISS search
03Medical Image SegmentationU-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

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:

  1. Write the eval plan and the domain-hard-part statement before code.
  2. Skeleton → golden tests → deepen → ledger throughout.
  3. 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:

  1. Each capstone meets its own declared acceptance numbers, reproducibly from config.
  2. Capstone 01 ships the measured budget table and sustains its FPS/latency contract under load.
  3. Capstone 02 ships the DET/ROC curve with a defended operating point, open-set evaluation, and the responsibility section.
  4. Capstone 03 ships patient-level splits, per-case Dice + HD95 distributions with CIs, and an abstention mechanism with its flag-rate curve.
  5. 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

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 TypeSpeedMemoryAccuracyUse When
FlatIPSlowestHighExact< 100K vectors
IVFFlatFastMediumExact (within cluster)100K–10M
IVFPQFastestLowApprox> 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

OperationFormulaPurpose
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 convDW conv + pointwise convMobileNet: 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

MethodUpdateWhen 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
Adam1st + 2nd moment with bias correctionDefault choice for most tasks
AdamWAdam + decoupled weight decayTransformers, 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

TechniqueHow it worksWhen to use
L2 (weight decay)Penalize $|\theta|^2$ — shrinks weights toward 0Always, standard
DropoutZero activations with prob $p$, scale by $1/(1-p)$FC layers, transformers
Batch NormNormalize activations within batchCNNs, stabilizes training
Data augmentationArtificially expand training setAll tasks
Label smoothingReplace hard 0/1 with $\epsilon/(C-1)$ / $1-\epsilon$Classification, large datasets
MixupBlend two images: $\tilde{x} = \lambda x_i + (1-\lambda)x_j$Classification, detection
CutMixCut patch from one image, paste into anotherSegmentation 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)
SpeedFast (30-100+ FPS)Slow (5-15 FPS)
AccuracyGoodBetter (especially small objects)
AnchorsYes (YOLO v3-v5) or no (v8)Yes (RPN)
Use caseReal-timeHigh-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

LossFormulaUse case
MSE$\frac{1}{N}\sum(y-\hat{y})^2$Regression (sensitive to outliers)
Smooth L1Quadratic 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{2A\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

LayerNormalized overUse case
Batch NormPer-channel, over batch+spatialCNNs (batch ≥ 4)
Layer NormPer-sample, over all featuresTransformers, NLP
Instance NormPer-channel, per-sampleStyle transfer
Group NormPer-channel group, per-sampleDetection (small batch)
Sync BNLike BN but sync across DDP ranksDistributed 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

  1. Mixed precision (FP16/BF16): halve parameter+gradient memory
  2. Gradient checkpointing: recompute activations on backward, save only checkpoints
  3. FSDP: shard model+optimizer across GPUs
  4. Reduce batch size: decrease activation memory

Throughput Bottlenecks

  1. Kernel launch overhead: use larger batches
  2. Memory bandwidth: use tensor cores (multiple of 8 dims)
  3. Data loading: use pin_memory=True, num_workers=4-8
  4. 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?"

  1. Very small batches (< 4) — variance estimate unreliable
  2. Very deep networks with gradient checkpointing — BN stats can be stale
  3. Online fine-tuning with different distribution — BN running stats mismatch

"How do you debug a model that won't train?"

  1. Check loss on a single batch first — should decrease with enough capacity
  2. Verify data loading (visualize a batch)
  3. Check for NaN/Inf in outputs (exploding gradients or bad initialization)
  4. Monitor gradient norms per layer
  5. Reduce to simplest possible model, add complexity incrementally

10. System Design Quick Reference

5-Step Framework

  1. Clarify: Latency? Accuracy? Scale? Online or batch?
  2. Estimate: Data size, compute, bandwidth needed
  3. Design: Pipeline stages, data flow
  4. Scale: Bottlenecks, horizontal scaling, caching
  5. 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

OperationTimeSpaceNotes
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 searchO(N·D)O(N·D)Brute force cosine/L2
FAISS IVF searchO(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

DecisionChoiceWhy
Frame queueKafkaBack-pressure, replay, fan-out
Batching strategyDynamic (max 32, max 5ms)Balance latency vs throughput
GPU schedulingNVIDIA TritonBuilt-in dynamic batching
Model formatTensorRT FP163-5× faster than PyTorch
Camera shardingcamera_id % n_partitionsEven 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)

StageBudget
Sensor capture + DMA5ms
Preprocessing (debayer, resize)3ms
BEV feature extraction18ms
Detection heads8ms
Sensor fusion + tracking5ms
Output marshaling1ms
Total40ms (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

  1. State the failure mode clearly: drift, edge case, wrong metric
  2. Describe your monitoring that caught it (or didn't)
  3. Explain your root cause analysis
  4. Describe the fix: model update, fallback rule, threshold change
  5. 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

  1. Acknowledge the competing viewpoint fairly
  2. Describe the analysis or prototype you built to make your case
  3. Describe how you communicated it (design doc, A/B test, demo)
  4. 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

  1. Show you actively reduced ambiguity rather than waiting
  2. Describe how you defined the MVP and deferred non-essential work
  3. Show how you managed stakeholder expectations around uncertainty
  4. 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

  1. Be specific — don't generalize ("I worked on computer vision")
  2. Explain why it was hard (technical, not just time pressure)
  3. Show systematic problem-solving: hypothesis → experiment → conclusion
  4. 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 nsys and 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

FileTopic
01-cv-pipeline-design.mdEnd-to-end scalable CV system architecture
02-real-time-video-analytics.mdStreaming, Kafka, async inference at scale
03-distributed-training.mdDDP, FSDP, gradient accumulation, mixed precision
04-gpu-tpu-acceleration.mdCUDA memory model, TensorRT, TPU/XLA, hardware selection
05-model-serving-at-scale.mdTriton, batching, SLA tradeoffs, horizontal scaling

How to Use These

  1. Read one document per day — don't rush
  2. Draw architecture diagrams on paper
  3. Identify tradeoffs — there are no right answers in system design, only tradeoffs
  4. Practice the interview format: "First let me clarify requirements... The key constraints are... Let me walk through the architecture..."

System Design Interview Framework

  1. Clarify requirements (5 min)

    • Functional: what does it do?
    • Non-functional: latency, throughput, accuracy, cost?
    • Scale: images/sec, cameras, users?
  2. Estimate scale (3 min)

    • "100 cameras × 30 FPS = 3,000 frames/sec"
    • "Each inference ~20ms → need 60 parallel workers"
  3. High-level design (10 min)

    • Draw boxes: ingestion → processing → storage → serving
    • Identify the most critical component
  4. Deep dive (15 min)

    • Pick the hard problem (usually: GPU inference at scale, or real-time latency)
    • Discuss alternatives, their tradeoffs
  5. 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

PatternWhen 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):

  1. Predict box positions using Kalman filter
  2. Match predictions to detections via IoU + Hungarian assignment
  3. 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

FailureRecovery
Ingest pod crashesKubernetes restarts in <30s; camera reconnects automatically
GPU worker crashesKafka offset not committed → messages re-delivered to other workers
Kafka broker failsReplication factor=3: other brokers have the data
Model returns wrong resultsRollback 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?

ConstraintSolution
Model doesn't fit in 1 GPUModel parallelism, FSDP
Training too slowData parallelism (DDP)
BothHybrid parallelism (3D parallelism)

Data Parallelism — DDP

Concept: Each GPU holds a full copy of the model. Each step:

  1. Split the mini-batch across N GPUs (each sees batch_size/N samples)
  2. Each GPU computes forward + backward independently
  3. AllReduce gradients across all GPUs (ring-allreduce via NCCL)
  4. 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

MemoryScopeLifetimeSpeed
RegisterThreadKernelFastest
SharedBlockKernel~20 TB/s
L1/L2 CacheSM / GPUKernelAuto-managed
Global (HBM)All threadsApplication~2 TB/s
Pinned (host)CPUApplication~50 GB/s (zero-copy capable)
UnifiedCPU+GPUApplicationSlower (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

  1. Graph fusion: Fuse Conv+BN+ReLU into a single kernel (fewer memory round-trips)
  2. Precision calibration: FP32 → FP16 or INT8 with minimal accuracy loss
  3. Kernel auto-tuning: Benchmarks multiple CUDA kernel implementations, picks fastest for your GPU
  4. Layer/tensor fusion: Reduce memory allocation overhead

Precision vs Speed (A100 SXM):

PrecisionTensor Core TFLOPSMemoryUse Case
FP3219.5100%Training, debugging
TF32156100%Default PyTorch training on A100
FP1631250%Training (AMP), inference
BF1631250%Training (more stable than FP16)
INT862425%Deployment inference
INT4124812.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

AspectGPU (A100)TPU v4
FlexibilityHigh (arbitrary CUDA ops)Low (XLA compiler must handle)
Custom opsEasyHard (must be XLA-compatible)
Memory80 GB32 GB/chip
Multi-deviceNVLink/PCIeICI fabric (seamless)
Best forResearch, inferenceLarge-scale training (LLMs, ViT)
Cost (cloud)$3-8/hr$2-6/chip/hr
FrameworkPyTorch/TF/JAXJAX (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:

  1. TensorRT conversion with FP16: typically 2-4× speedup → might reach 8-15ms
  2. INT8 quantization if accuracy permits: another 1.5-2× on top of FP16
  3. Input resolution reduction: YOLOv8 at 416px vs 640px is ~2× faster
  4. torch.compile if staying in PyTorch: ~20-40% speedup with minimal effort
  5. Model distillation: train a smaller student model (YOLOv8n vs v8x)
  6. Hardware upgrade: T4→A10G→A100 — not a code change, but immediate
  7. 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.