ML Python Debugging Interview Problems — Complete Prep Guide

A collection of 62 realistic debugging problems of the kind asked in ML engineer / applied scientist / research engineer interviews — organized in six parts: classic ML & PyTorch (Parts I & III, Problems 1–12 and 23–34), transformers, LLMs & pre-training (Parts II & IV, Problems 13–22, 35–42, and 51–54), generative models — diffusion, flow matching, VAEs, GANs, tokenizers (Part V, Problems 43–46 and 55–58), and post-training, evaluation & serving (Part VI, Problems 47–50 and 59–62). Each problem contains: the buggy code, the observed symptom, a step-by-step diagnostic analysis (how you should reason out loud in the interview), the fix, and the deeper lesson interviewers are probing for.

Frequency tiers — how to prioritize your prep

Every problem is tagged with a frequency tier:

Tiers are role-dependent: for an LLM/post-training role, Problems 13, 14, and 21 behave like Tier 1; for a diffusion/GenAI vision role, Problem 18 does. The tags below note these shifts.

Suggested prep order: all Tier 1 first (Problems 1–5, 7, 10, 13, 14), then the Tier 2 set matching your target role, then Tier 3 as differentiators.


How interviewers evaluate ML debugging

Before the problems, understand what is actually being graded:

  1. Hypothesis-driven debugging. Strong candidates state a hypothesis ("loss is flat, so either gradients aren't flowing or the labels are decoupled from the inputs"), then propose the cheapest experiment to test it (print param.grad, overfit a single batch).
  2. Knowing the classic failure taxonomy. Most ML bugs fall into a few families: data leakage, shape/broadcasting silent errors, optimizer/gradient plumbing, train-vs-eval mode mismatches, numerical instability, and randomness/reproducibility.
  3. The "overfit one batch" reflex. A healthy model must be able to memorize 10 samples. If it can't, the bug is in the plumbing, not the data or the hyperparameters.
  4. Reading symptoms like a clinician. Loss = exactly ln(num_classes) at init and never moving, loss = NaN at step 137, val accuracy of 99.8% that collapses in production — each symptom implicates a specific family of bugs.

Part I — Classic ML & PyTorch fundamentals (Problems 1–12)


Problem 1 — The loss that never moves (optimizer plumbing)

Category: PyTorch training loop / gradient flow Difficulty: Warm-up, but with a twist most candidates miss Frequency tier: Tier 1 — core screener (asked in virtually every ML coding/debugging interview, all roles)

Buggy code

import torch
import torch.nn as nn

class MLP(nn.Module):
    def __init__(self, in_dim=20, hidden=64, out_dim=3):
        super().__init__()
        self.fc1 = nn.Linear(in_dim, hidden)
        self.relu = nn.ReLU()
        self.fc2 = nn.Linear(hidden, out_dim)

    def forward(self, x):
        return self.fc2(self.relu(self.fc1(x)))

model = MLP()
optimizer = torch.optim.SGD(model.parameters(), lr=0.05)
criterion = nn.CrossEntropyLoss()

for epoch in range(20):
    for xb, yb in train_loader:        # mini-batches of 64
        logits = model(xb)
        loss = criterion(logits, yb)
        loss.backward()
        optimizer.step()
    print(f"epoch {epoch}: loss={loss.item():.4f}")
# epoch 0: loss drops nicely... epoch 3: loss=6.2  epoch 19: loss=18.1

Symptom

Loss decreases for the first epoch or two, then climbs and oscillates wildly, ending far above where it started. (With Adam instead of SGD the failure is subtler: training doesn't blow up, it just stalls at a much worse loss than it should.)

Diagnostic reasoning (say this out loud)

Fix

for epoch in range(20):
    for xb, yb in train_loader:
        optimizer.zero_grad()          # <-- the fix
        loss = criterion(model(xb), yb)
        loss.backward()
        optimizer.step()

Deeper lesson


Problem 2 — 99.5% validation accuracy, 62% in production (data leakage)

Category: Data preprocessing / leakage Difficulty: Medium — the single most common real-world ML bug Frequency tier: Tier 1 — core screener (the most common real-world ML bug; any applied role)

Buggy code

import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression

X, y = load_patient_data()          # features include lab results
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)  # BUG 1: fit on ALL data

# BUG 2: duplicate rows from repeated patient visits
X_train, X_val, y_train, y_val = train_test_split(
    X_scaled, y, test_size=0.2, random_state=42
)

clf = LogisticRegression(max_iter=1000).fit(X_train, y_train)
print("val acc:", clf.score(X_val, y_val))   # 0.995 (!)

Symptom

Validation accuracy is suspiciously high; the deployed model performs far worse on new patients.

Diagnostic reasoning

Fix

from sklearn.model_selection import GroupShuffleSplit
from sklearn.pipeline import make_pipeline

# Split FIRST, and split by patient so no patient straddles the boundary
gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
train_idx, val_idx = next(gss.split(X, y, groups=patient_ids))

X_train, X_val = X[train_idx], X[val_idx]
y_train, y_val = y[train_idx], y[val_idx]

# Pipeline guarantees the scaler is fit only on training folds,
# including inside cross-validation
clf = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
clf.fit(X_train, y_train)
print("val acc:", clf.score(X_val, y_val))

Deeper lesson


Problem 3 — The double softmax (loss/activation mismatch)

Category: Loss function semantics Difficulty: Medium — extremely common in PyTorch Frequency tier: Tier 1 — core screener (top-3 most frequent PyTorch questions)

Buggy code

class Classifier(nn.Module):
    def __init__(self):
        super().__init__()
        self.backbone = nn.Sequential(
            nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 10)
        )

    def forward(self, x):
        logits = self.backbone(x)
        return torch.softmax(logits, dim=1)   # BUG

criterion = nn.CrossEntropyLoss()
# ... training loop uses criterion(model(x), y)

Symptom

The model trains, but slowly, plateaus early, and confidence calibration is bizarre. Nothing crashes — this is a silent bug.

Diagnostic reasoning

Fix

def forward(self, x):
    return self.backbone(x)        # return raw logits

# At inference time only:
probs = torch.softmax(model(x), dim=1)

Deeper lesson


Problem 4 — NaN loss at step 137 (numerical instability)

Category: Numerical stability Difficulty: Medium-hard Frequency tier: Tier 1 — core screener (every deep-learning loop has a NaN question)

Buggy code

def custom_loss(pred, target, eps=0.0):
    # focal-style loss written by hand
    p = torch.sigmoid(pred)
    loss = -(target * torch.log(p) + (1 - target) * torch.log(1 - p))
    return loss.mean()

optimizer = torch.optim.SGD(model.parameters(), lr=1.0)  # aggressive LR

for step, (x, y) in enumerate(loader):
    optimizer.zero_grad()
    loss = custom_loss(model(x), y.float())
    loss.backward()
    optimizer.step()
    # step 0..136: loss decreasing... step 137: loss=nan, forever after: nan

Symptom

Training is healthy, then the loss becomes NaN and never recovers. Classic "it worked for a while" failure.

Diagnostic reasoning — the NaN debugging playbook

  1. Localize when: NaN mid-training (not step 0) usually means values drifted into an unstable region — exploding logits, extreme learning rate, or an unstable formula — rather than bad input data (which would NaN immediately).
  2. Localize where: enable torch.autograd.set_detect_anomaly(True) to get the exact op in the backward graph that produced the NaN, or register forward hooks checking torch.isfinite(out).all().
  3. Read the math: torch.log(p) where p = sigmoid(pred). When pred is very negative, sigmoid underflows to exactly 0.0 in float32, and log(0) = -inf; 0 * -inf = nan. Symmetrically log(1-p) blows up for large positive pred. The aggressive LR pushed logits into the saturation zone by step 137.
  4. Check the amplifiers: lr=1.0 with SGD on raw logits accelerates the drift; no gradient clipping means one bad batch can launch the weights.

Fix

# Best fix: use the numerically stable fused op — it applies log-sum-exp internally
criterion = nn.BCEWithLogitsLoss()
loss = criterion(model(x), y.float())

# If you must hand-roll, clamp the probabilities:
def custom_loss(pred, target, eps=1e-7):
    p = torch.sigmoid(pred).clamp(eps, 1 - eps)
    return -(target * torch.log(p) + (1 - target) * torch.log(1 - p)).mean()

# And defensive training hygiene:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

Deeper lesson


Problem 5 — Great training accuracy, terrible eval — and eval changes every run (mode mismatch)

Category: train/eval semantics — Dropout & BatchNorm Difficulty: Medium Frequency tier: Tier 1 — core screener (train/eval semantics are near-universal)

Buggy code

model = nn.Sequential(
    nn.Linear(100, 256), nn.BatchNorm1d(256), nn.ReLU(), nn.Dropout(0.5),
    nn.Linear(256, 10),
)

def evaluate(model, loader):
    correct = 0
    total = 0
    for x, y in loader:                      # BUG 2: no torch.no_grad()
        preds = model(x).argmax(dim=1)       # BUG 1: model still in train mode
        correct += (preds == y).sum().item()
        total += y.numel()
    return correct / total

Symptom

Evaluation accuracy is (a) worse than expected, (b) non-deterministic across identical calls, and (c) memory usage grows during evaluation, occasionally OOMing on large eval sets.

Diagnostic reasoning

Fix

@torch.no_grad()                 # no autograd graph -> fast, low-memory
def evaluate(model, loader):
    model.eval()                 # dropout off; BN uses running stats
    correct, total = 0, 0
    for x, y in loader:
        preds = model(x).argmax(dim=1)
        correct += (preds == y).sum().item()
        total += y.numel()
    model.train()                # restore state for the caller
    return correct / total

Deeper lesson


Problem 6 — The shuffled features, unshuffled labels (silent data misalignment)

Category: NumPy/pandas data handling Difficulty: Medium — brutal because everything "runs" Frequency tier: Tier 2 — role-standard (common in data-heavy / applied-ML loops)

Buggy code

import numpy as np

X = np.load("features.npy")       # (N, D)
y = np.load("labels.npy")         # (N,)

# shuffle before splitting
np.random.shuffle(X)              # BUG: X and y shuffled independently...
np.random.shuffle(y)              # ...breaking row correspondence

split = int(0.8 * len(X))
X_train, y_train = X[:split], y[:split]
X_val,   y_val   = X[split:], y[split:]

A pandas variant of the same disease:

df = df.sort_values("timestamp")
features = df[FEATURE_COLS].reset_index(drop=True)
labels   = raw_labels_series          # still carries the OLD index
aligned  = pd.concat([features, labels], axis=1)   # aligns on index -> scrambled/NaN

Symptom

Training loss decreases only slightly then plateaus at roughly the entropy of the label distribution; accuracy ≈ majority-class rate. The model behaves as if labels were random — because they now are.

Diagnostic reasoning

Fix

# One permutation applied to both arrays
rng = np.random.default_rng(42)
perm = rng.permutation(len(X))
X, y = X[perm], y[perm]

# or shuffle at split time and keep pairs together:
from sklearn.model_selection import train_test_split
X_train, X_val, y_train, y_val = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

# pandas: align positionally on purpose
labels = raw_labels_series.reset_index(drop=True)
aligned = pd.concat([features, labels], axis=1)

Deeper lesson


Problem 7 — The broadcasting bug that halves your metric (silent shape mismatch)

Category: NumPy/PyTorch broadcasting Difficulty: Medium — a favorite short screener question Frequency tier: Tier 1 — core screener (favorite 5-minute filter question)

Buggy code

import torch

preds  = model(x)                # shape (B, 1)  — regression head keeps last dim
target = batch["label"]          # shape (B,)

loss = torch.nn.functional.mse_loss(preds, target)
# UserWarning: Using a target size (torch.Size([64])) that is different
# to the input size (torch.Size([64, 1])) ... (warning often scrolls by unread)

What actually happens:

diff = preds - target        # (B,1) - (B,) broadcasts to (B, B)!!
loss = (diff ** 2).mean()    # mean over B*B pairwise differences

Symptom

The loss value looks plausible and even decreases, but the model converges to predicting roughly the mean of the targets and R² is terrible. GPU memory is also mysteriously high for large batch sizes (the hidden (B, B) tensor).

Diagnostic reasoning

Fix

loss = F.mse_loss(preds.squeeze(-1), target)      # shapes (B,) vs (B,)
# or: F.mse_loss(preds, target.unsqueeze(-1))     # shapes (B,1) vs (B,1)

# Defensive: assert shapes at the loss boundary
assert preds.squeeze(-1).shape == target.shape, (preds.shape, target.shape)

Deeper lesson


Problem 8 — In-place ops and a detached graph (autograd surgery)

Category: Autograd mechanics Difficulty: Hard Frequency tier: Tier 2 — role-standard (expected at mid/senior level for PyTorch-heavy roles)

Buggy code

class GatedBlock(nn.Module):
    def __init__(self, d):
        super().__init__()
        self.lin = nn.Linear(d, d)
        self.gate = nn.Linear(d, d)

    def forward(self, x):
        g = torch.sigmoid(self.gate(x))
        h = self.lin(x)
        g *= h                    # BUG A: in-place on a tensor needed in backward
        return g

# elsewhere, a "warm-up trick" someone added:
def forward_features(model, x):
    with torch.no_grad():         # BUG B: entire feature extractor detached
        feats = model.backbone(x)
    return model.head(feats)      # only the head ever trains

# and a metric helper:
def accuracy(logits, y):
    preds = logits.detach().argmax(1)     # fine
    return (preds == y).float().mean()

running_loss += loss              # BUG C: accumulating the TENSOR, not loss.item()

Symptoms

Diagnostic reasoning

Fix

# A: allocate a new tensor
out = g * h

# B: remove no_grad from the training path (keep it for frozen-backbone
# inference only, and if freezing is intended, do it explicitly):
for p in model.backbone.parameters():
    p.requires_grad = False       # explicit, documented freezing

# C: accumulate a python float
running_loss += loss.item()       # .item() detaches AND moves to CPU

Deeper lesson


Problem 9 — The DataLoader that feeds identical "random" augmentations (randomness & workers)

Category: Reproducibility, multiprocessing, NumPy seeding Difficulty: Hard — a well-known real PyTorch footgun Frequency tier: Tier 3 — differentiator (senior/staff depth; famous real-world footgun)

Buggy code

import numpy as np
from torch.utils.data import Dataset, DataLoader

class AugmentedDataset(Dataset):
    def __init__(self, images):
        self.images = images

    def __getitem__(self, idx):
        img = self.images[idx]
        # numpy-based augmentation
        noise_scale = np.random.uniform(0.0, 0.1)     # BUG (with workers > 0)
        crop_x = np.random.randint(0, 8)
        return augment(img, noise_scale, crop_x)

    def __len__(self):
        return len(self.images)

loader = DataLoader(AugmentedDataset(images), batch_size=32,
                    num_workers=4, shuffle=True)

Symptom

Augmentation "works" but the model overfits more than expected, and inspecting augmented samples reveals that groups of samples share identical augmentation parameters — every epoch, the same pattern.

Diagnostic reasoning

Fix

def worker_init_fn(worker_id):
    # derive a distinct, epoch-varying seed for numpy & random in each worker
    seed = torch.initial_seed() % 2**32
    np.random.seed(seed)
    import random; random.seed(seed)

g = torch.Generator()
g.manual_seed(42)                          # reproducible shuffling

loader = DataLoader(dataset, batch_size=32, num_workers=4, shuffle=True,
                    worker_init_fn=worker_init_fn, generator=g)

# Best practice: avoid global RNGs entirely — use torch ops for augmentation
# randomness, or a per-sample np.random.default_rng(seed + idx + epoch * N).

Deeper lesson


Problem 10 — The mutable default and the config that mutates itself (Python semantics in ML code)

Category: Core Python — the ML-flavored classics Difficulty: Easy-medium individually; often combined into one review problem Frequency tier: Tier 1 — core screener (general Python rounds love these in ML dress)

Buggy code

# (a) mutable default argument
def make_optimizer(params, lr=1e-3, betas=[0.9, 0.999]):
    betas[0] *= 0.5 if warmup else 1.0     # mutates the SHARED default list
    return torch.optim.Adam(params, lr=lr, betas=tuple(betas))

# (b) late-binding closures in a sweep
schedulers = []
for factor in [0.1, 0.5, 0.9]:
    schedulers.append(lambda epoch: base_lr * factor)   # all use factor=0.9

# (c) shallow config copy
base_cfg = {"model": {"hidden": 256}, "lr": 1e-3}
cfg = dict(base_cfg)                # shallow!
cfg["model"]["hidden"] = 512        # ALSO changes base_cfg
run_a = train(base_cfg)             # silently runs with hidden=512

# (d) class-level mutable attribute
class MetricTracker:
    history = []                    # shared across ALL instances
    def log(self, v):
        self.history.append(v)

Symptoms

Sweeps where every run mysteriously uses the last hyperparameter; experiment B contaminating experiment A's config; metrics from different models interleaved in one list; a "default" that drifts over the process lifetime.

Diagnostic reasoning & fixes

Deeper lesson

The unifying concept is Python's object/reference model: names bind to objects; defaults, class bodies, and closures each evaluate at surprising times. Interviewers use these because ML config/sweep code is exactly where they bite hardest.


Problem 11 — The learning-rate scheduler stepped in the wrong place (training dynamics)

Category: Training loop orchestration Difficulty: Medium Frequency tier: Tier 2 — role-standard (training-orchestration roles; resume-from-checkpoint variants are common)

Buggy code

model = build_model()
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS)

for epoch in range(EPOCHS):
    for x, y in train_loader:            # ~1000 batches/epoch
        optimizer.zero_grad()
        loss = criterion(model(x), y)
        loss.backward()
        scheduler.step()                 # BUG 1: stepped per-BATCH with T_max in EPOCHS
        optimizer.step()                 # BUG 2: also, scheduler before optimizer

    if val_acc > best:
        torch.save(model, "best.pt")     # BUG 3: saving the whole module w/ pickle

Symptoms

Diagnostic reasoning

Fix

steps_per_epoch = len(train_loader)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
    optimizer, T_max=EPOCHS * steps_per_epoch
)

for epoch in range(EPOCHS):
    for x, y in train_loader:
        optimizer.zero_grad()
        loss = criterion(model(x), y)
        loss.backward()
        optimizer.step()
        scheduler.step()                 # after optimizer.step()

    if val_acc > best:
        torch.save({
            "model": model.state_dict(),
            "optimizer": optimizer.state_dict(),
            "scheduler": scheduler.state_dict(),
            "epoch": epoch,
        }, "best.pt")

Deeper lesson


Problem 12 — The 0.91 AUC model that predicts one class (metrics & imbalance)

Category: Evaluation methodology Difficulty: Medium-hard — tests judgment, not just code Frequency tier: Tier 2 — role-standard (Tier 1 in classic-ML / data-science loops)

Buggy code

from sklearn.metrics import accuracy_score
from sklearn.ensemble import RandomForestClassifier

# fraud data: 1.2% positive
clf = RandomForestClassifier().fit(X_train, y_train)

y_pred = clf.predict(X_val)
print("accuracy:", accuracy_score(y_val, y_pred))          # 0.988 — "great!"

# team later switches to AUC:
from sklearn.metrics import roc_auc_score
print("auc:", roc_auc_score(y_val, y_pred))                # BUG: hard labels, not scores

# threshold chosen on the VALIDATION set used for reporting:
best_t = max(np.arange(0, 1, 0.01),
             key=lambda t: f1_score(y_val, proba[:, 1] > t))   # BUG: threshold leakage
print("f1 at best threshold:", ...)                            # reported as final

Symptoms

98.8% accuracy on a 98.8%-negative dataset (the majority-class baseline); an "AUC" computed on hard 0/1 predictions that understates and distorts the true AUC; an F1 that won't reproduce on the test set because the threshold was tuned on the same data used to report it.

Diagnostic reasoning

Fix

proba = clf.predict_proba(X_val)[:, 1]

from sklearn.metrics import (average_precision_score, roc_auc_score,
                             precision_recall_curve, confusion_matrix)
print(confusion_matrix(y_val, clf.predict(X_val)))
print("roc-auc:", roc_auc_score(y_val, proba))             # scores, not labels
print("pr-auc :", average_precision_score(y_val, proba))   # better for 1.2% prevalence

# tune threshold on a dedicated tuning split, evaluate once on test
prec, rec, ts = precision_recall_curve(y_tune, proba_tune)
best_t = ts[np.argmax(2 * prec[:-1] * rec[:-1] / (prec[:-1] + rec[:-1] + 1e-12))]
final_f1 = f1_score(y_test, proba_test > best_t)

# and fix the training itself:
clf = RandomForestClassifier(class_weight="balanced")
# or resampling / focal loss / threshold-moving — discuss trade-offs

Deeper lesson


Part II — Modern generative stacks: Transformers, Pre-training, Diffusion, Flow Matching, Post-training (Problems 13–22)


Problem 13 — The transformer that outputs NaN on the first token (attention masking)

Category: Transformers / attention masks Difficulty: Medium Frequency tier: Tier 2 overall; Tier 1 for any LLM/transformer role — mask bugs are the #1 transformer interview question

Buggy code

import torch, torch.nn as nn, torch.nn.functional as F

class SelfAttention(nn.Module):
    def __init__(self, d, n_heads):
        super().__init__()
        self.qkv = nn.Linear(d, 3 * d)
        self.proj = nn.Linear(d, d)
        self.n_heads, self.d_head = n_heads, d // n_heads

    def forward(self, x, pad_mask):            # pad_mask: (B, L), 1 = real token
        B, L, D = x.shape
        q, k, v = self.qkv(x).chunk(3, dim=-1)
        q = q.view(B, L, self.n_heads, self.d_head).transpose(1, 2)
        k = k.view(B, L, self.n_heads, self.d_head).transpose(1, 2)
        v = v.view(B, L, self.n_heads, self.d_head).transpose(1, 2)

        scores = q @ k.transpose(-2, -1)                       # BUG 1: no 1/sqrt(d_head)

        causal = torch.triu(torch.ones(L, L, dtype=torch.bool,
                                       device=x.device), diagonal=0)   # BUG 2: diagonal=0
        scores = scores.masked_fill(causal, float("-inf"))

        scores = scores.masked_fill(pad_mask[:, None, None, :], float("-inf"))
        # BUG 3: mask polarity flipped — this masks the REAL tokens (1s), keeps padding

        attn = scores.softmax(dim=-1)
        out = (attn @ v).transpose(1, 2).reshape(B, L, D)
        return self.proj(out)

Symptoms

Diagnostic reasoning

Fix

scale = self.d_head ** -0.5
scores = (q @ k.transpose(-2, -1)) * scale

causal = torch.triu(torch.ones(L, L, dtype=torch.bool, device=x.device),
                    diagonal=1)                         # strictly above diagonal
scores = scores.masked_fill(causal, float("-inf"))
scores = scores.masked_fill(~pad_mask[:, None, None, :].bool(), float("-inf"))
attn = scores.softmax(dim=-1)

# In practice, prefer the fused kernel and let it build the mask:
out = F.scaled_dot_product_attention(q, k, v, is_causal=True)  # handles scale + causal

Deeper lesson


Problem 14 — The language model with near-zero training loss and gibberish generations (causal LM labels)

Category: LLM training / label pipeline Difficulty: Medium Frequency tier: Tier 2 overall; Tier 1 for LLM roles — the label-shift question appears in some form in nearly every LLM loop

Buggy code

def compute_lm_loss(model, input_ids, pad_id):
    logits = model(input_ids)                        # (B, L, V)
    loss = F.cross_entropy(
        logits.view(-1, logits.size(-1)),
        input_ids.view(-1),                          # BUG 1: labels not shifted
    )                                                # BUG 2: pad tokens included in loss
    return loss

# elsewhere, a teammate "fixed" it for a HuggingFace model:
outputs = model(input_ids=input_ids[:, :-1],
                labels=input_ids[:, 1:])             # BUG 3: double shift (HF shifts internally)

Symptoms

Diagnostic reasoning

Fix

def compute_lm_loss(model, input_ids, pad_id):
    logits = model(input_ids)                        # (B, L, V)
    labels = input_ids.clone()
    labels[labels == pad_id] = -100                  # exclude pads from loss
    shift_logits = logits[:, :-1, :]                 # predict positions 1..L-1
    shift_labels = labels[:, 1:]
    return F.cross_entropy(shift_logits.reshape(-1, logits.size(-1)),
                           shift_labels.reshape(-1),
                           ignore_index=-100)

# HuggingFace: pass UNshifted labels; the model shifts for you
outputs = model(input_ids=input_ids, labels=labels)   # labels = input_ids with -100 on pads

Deeper lesson


Problem 15 — Great first answer, garbage second answer (KV-cache & decoding)

Category: LLM inference / KV cache Difficulty: Medium-hard Frequency tier: Tier 2 — standard for inference/serving-flavored LLM roles

Buggy code

class ChatServer:
    def __init__(self, model):
        self.model = model
        self.past_kv = None                         # BUG 1: cache is server state

    @torch.inference_mode()
    def generate(self, prompt_ids, max_new=100, temperature=0.0):
        ids = prompt_ids
        for _ in range(max_new):
            input_step = ids[:, -1:] if self.past_kv is not None else ids
            position_ids = torch.arange(input_step.shape[1])[None]   # BUG 2: always [0] for steps
            out = self.model(input_step, past_key_values=self.past_kv,
                             position_ids=position_ids)
            self.past_kv = out.past_key_values
            probs = torch.softmax(out.logits[:, -1] / temperature, -1)  # BUG 3: /0 when temp=0
            next_id = torch.multinomial(probs, 1)
            ids = torch.cat([ids, next_id], dim=1)
        return ids

Symptoms

Diagnostic reasoning

Fix

@torch.inference_mode()
def generate(self, prompt_ids, max_new=100, temperature=0.0):
    past_kv = None                                   # cache is per-request, not server state
    ids = prompt_ids
    for _ in range(max_new):
        input_step = ids[:, -1:] if past_kv is not None else ids
        past_len = 0 if past_kv is None else past_kv[0][0].shape[2]
        position_ids = (past_len + torch.arange(input_step.shape[1]))[None]
        out = self.model(input_step, past_key_values=past_kv, position_ids=position_ids)
        past_kv = out.past_key_values
        logits = out.logits[:, -1]
        if temperature == 0.0:
            next_id = logits.argmax(-1, keepdim=True)          # greedy
        else:
            next_id = torch.multinomial(torch.softmax(logits / temperature, -1), 1)
        ids = torch.cat([ids, next_id], dim=1)
    return ids

Deeper lesson


Problem 16 — The pre-training run with loss spikes and a useless grad-clip (mixed precision & optimizer hygiene)

Category: Pre-training at scale / AMP / optimizer grouping Difficulty: Hard Frequency tier: Tier 2 — expected for anyone claiming pre-training experience; the AMP-clip ordering is a favorite senior question

Buggy code

scaler = torch.cuda.amp.GradScaler()
optimizer = torch.optim.AdamW(model.parameters(),
                              lr=3e-4, weight_decay=0.1)   # BUG 3: decay on EVERYTHING

for step, batch in enumerate(loader):
    with torch.autocast("cuda", dtype=torch.float16):
        loss = model(**batch).loss
    scaler.scale(loss / ACCUM).backward()

    if (step + 1) % ACCUM == 0:
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)  # BUG 1: clipping SCALED grads
        scaler.step(optimizer)
        scaler.update()
        optimizer.zero_grad()

    if step % 100 == 0:
        log(loss=loss.item(), grad_norm=grad_norm)   # BUG 2: grad_norm is in scaled units

Symptoms

Diagnostic reasoning

Fix

decay, no_decay = [], []
for n, p in model.named_parameters():
    if p.ndim < 2 or "norm" in n.lower() or "embed" in n.lower():
        no_decay.append(p)
    else:
        decay.append(p)
optimizer = torch.optim.AdamW(
    [{"params": decay, "weight_decay": 0.1},
     {"params": no_decay, "weight_decay": 0.0}], lr=3e-4)

for step, batch in enumerate(loader):
    with torch.autocast("cuda", dtype=torch.bfloat16):   # bf16: no scaler needed
        loss = model(**batch).loss
    (loss / ACCUM).backward()

    if (step + 1) % ACCUM == 0:
        grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        optimizer.step()
        optimizer.zero_grad(set_to_none=True)
        log(loss=loss.item(), grad_norm=grad_norm.item(), lr=...)
# (If fp16 is required: keep GradScaler and call scaler.unscale_(optimizer)
#  immediately before clip_grad_norm_.)

Deeper lesson


Problem 17 — The 8-GPU run that's worse than 1 GPU (distributed training)

Category: DDP / distributed data loading Difficulty: Hard Frequency tier: Tier 2 — standard for ML-infra and scale-adjacent roles

Buggy code

sampler = DistributedSampler(dataset)
loader = DataLoader(dataset, batch_size=64, sampler=sampler)
model = DistributedDataParallel(model.cuda(rank), device_ids=[rank])

for epoch in range(EPOCHS):
    # BUG 1: sampler.set_epoch(epoch) never called
    for batch in loader:
        train_step(batch)

    acc = evaluate(model, my_val_shard)          # BUG 2: each rank evaluates its shard...
    if rank == 0:
        print("val acc:", acc)                   # ...but only rank 0's shard is reported
        torch.save(model.module.state_dict(), "ckpt.pt")
    # BUG 3: no barrier — other ranks race ahead into the next epoch / load a half-written file

Symptoms

Diagnostic reasoning

Fix

for epoch in range(EPOCHS):
    sampler.set_epoch(epoch)                     # different shuffle each epoch
    for batch in loader:
        train_step(batch)

    correct, total = evaluate_counts(model, my_val_shard)
    stats = torch.tensor([correct, total], device=rank)
    dist.all_reduce(stats)                       # aggregate over all ranks
    if rank == 0:
        print("val acc:", (stats[0] / stats[1]).item())
        torch.save(model.module.state_dict(), "ckpt.tmp")
        os.replace("ckpt.tmp", "ckpt.pt")        # atomic
    dist.barrier()                               # everyone waits for the checkpoint

Deeper lesson


Problem 18 — The diffusion model whose loss converges but samples are pure noise (parameterization mismatch)

Category: Diffusion models / DDPM training Difficulty: Hard Frequency tier: Tier 2 overall; Tier 1 for GenAI-vision / diffusion roles

Buggy code

# Training (DDPM-style)
def train_step(model, x0, alphas_cumprod):
    t = torch.randint(0, T, (x0.shape[0],), device=x0.device)
    noise = torch.randn_like(x0)
    a = alphas_cumprod[t].view(-1, 1, 1, 1)
    x_t = a.sqrt() * x0 + (1 - a).sqrt() * noise

    pred = model(x_t, t)
    loss = F.mse_loss(pred, x0)          # BUG 1: trains model to predict x0 ...
    return loss

# Sampling (copied from a reference epsilon-prediction implementation)
@torch.no_grad()
def sample(model, shape):
    x = torch.randn(shape)
    for t in reversed(range(T)):
        eps = model(x, torch.full((shape[0],), t))     # ... but sampler treats output as EPSILON
        x = ddpm_step_epsilon_param(x, eps, t)          # BUG 1 (cont.)
    return x

# Latent diffusion wrapper
z = vae.encode(images).latent_dist.sample()             # BUG 2: missing 0.18215 scaling
# ... train diffusion on z ...
images_out = vae.decode(z_sampled)                      # decode also unscaled

Symptoms

Diagnostic reasoning

Fix

# Make trainer and sampler agree — epsilon-parameterization end to end:
def train_step(model, x0, alphas_cumprod):
    t = torch.randint(0, T, (x0.shape[0],), device=x0.device)
    noise = torch.randn_like(x0)
    a = alphas_cumprod[t].view(-1, 1, 1, 1)
    x_t = a.sqrt() * x0 + (1 - a).sqrt() * noise
    return F.mse_loss(model(x_t, t), noise)       # predict the NOISE

# Latent diffusion: apply and invert the scale factor symmetrically
z = vae.encode(images).latent_dist.sample() * 0.18215
images_out = vae.decode(z_sampled / 0.18215)

Deeper lesson


Problem 19 — The diffusion samples that got worse after "cleanup" (EMA & schedule off-by-one)

Category: Diffusion models / sampling & maintenance Difficulty: Hard Frequency tier: Tier 3 — differentiator; probes whether you've shipped a diffusion model, not just read the paper

Buggy code

# A refactor "simplified" the trainer:
class Trainer:
    def __init__(self, model):
        self.model = model
        self.ema_model = copy.deepcopy(model)

    def step(self, batch):
        loss = diffusion_loss(self.model, batch)
        loss.backward(); self.opt.step(); self.opt.zero_grad()
        # BUG 1: EMA update was deleted in the refactor ("it wasn't referenced anywhere")

    def sample(self):
        return ddim_sample(self.model, ...)        # BUG 2: samples from RAW weights anyway

# DDIM sampler, ported by hand from the paper's 1-indexed math:
for i, t in enumerate(timesteps):                  # e.g. [999, 979, ..., 19]
    a_t    = alphas_cumprod[t]
    a_prev = alphas_cumprod[t - 20]                # BUG 3: assumes uniform spacing; breaks at
    ...                                            # the last step (t-20 = -1 wraps to index 999!)

Symptoms

Diagnostic reasoning

Fix

def step(self, batch):
    loss = diffusion_loss(self.model, batch)
    loss.backward(); self.opt.step(); self.opt.zero_grad()
    with torch.no_grad():                          # EMA update, every step
        for p_ema, p in zip(self.ema_model.parameters(), self.model.parameters()):
            p_ema.lerp_(p, 1 - 0.9999)

def sample(self):
    return ddim_sample(self.ema_model, ...)        # ALWAYS sample from EMA

# Sampler: explicit prev-timestep pairs, safe boundary
timesteps = list(reversed(range(0, 1000, 20)))
for t, t_prev in zip(timesteps, timesteps[1:] + [-1]):
    a_t    = alphas_cumprod[t]
    a_prev = alphas_cumprod[t_prev] if t_prev >= 0 else torch.tensor(1.0)
    ...

Deeper lesson


Problem 20 — The flow-matching model that trains beautifully and samples nothing (sign & time conventions)

Category: Flow matching / continuous-time generative models Difficulty: Hard Frequency tier: Tier 3 — differentiator for research-engineer and frontier-lab loops (increasingly common since 2024–25)

Buggy code

# Training — conditional flow matching with linear interpolation path
def fm_loss(model, x1):                    # x1: data
    x0 = torch.randn_like(x1)              # noise
    t = torch.rand(x1.shape[0], 1)
    x_t = (1 - t) * x0 + t * x1            # t=0 noise -> t=1 data
    v_target = x0 - x1                     # BUG 1: sign flipped (should be x1 - x0)
    return F.mse_loss(model(x_t, t), v_target)

# Sampling — Euler integration
@torch.no_grad()
def sample(model, n, steps=100):
    x = torch.randn(n, D)
    dt = 1.0 / steps
    for i in range(steps):
        t = torch.full((n, 1), 1.0 - i * dt)   # BUG 2: time runs 1 -> 0, but the
        x = x + model(x, t) * dt               # path was trained with t=0 at NOISE
    return x

Symptoms

Diagnostic reasoning

Fix

def fm_loss(model, x1):
    x0 = torch.randn_like(x1)
    t = torch.rand(x1.shape[0], 1)
    x_t = (1 - t) * x0 + t * x1
    v_target = x1 - x0                      # velocity points from noise toward data
    return F.mse_loss(model(x_t, t), v_target)

@torch.no_grad()
def sample(model, n, steps=100):
    x = torch.randn(n, D)                   # start at t=0 (noise) ...
    dt = 1.0 / steps
    for i in range(steps):
        t = torch.full((n, 1), i * dt)      # ... integrate FORWARD to t=1 (data)
        x = x + model(x, t) * dt
    return x

Deeper lesson


Problem 21 — The fine-tuned assistant that never stops talking (SFT data pipeline)

Category: Post-training / supervised fine-tuning Difficulty: Medium-hard Frequency tier: Tier 2 overall; Tier 1 for post-training / applied-LLM roles

Buggy code

def build_example(tokenizer, prompt, response):
    text = prompt + response                    # BUG 3: no chat template, no separator
    ids = tokenizer(text, truncation=True, max_length=1024)["input_ids"]
    # BUG 2: no EOS appended
    labels = list(ids)                          # BUG 1: loss computed on PROMPT tokens too
    return {"input_ids": ids, "labels": labels}

# inference (deployed):
out = model.generate(tokenizer("User: " + q, return_tensors="pt").input_ids,
                     max_new_tokens=512)        # BUG 3 (cont.): different format than training

Symptoms

Diagnostic reasoning

Fix

def build_example(tokenizer, messages):         # messages: [{role, content}, ...]
    prompt_ids = tokenizer.apply_chat_template(
        messages[:-1], add_generation_prompt=True)
    full_ids = tokenizer.apply_chat_template(messages)   # includes response + eos per template
    labels = [-100] * len(prompt_ids) + full_ids[len(prompt_ids):]
    assert full_ids[:len(prompt_ids)] == prompt_ids      # guard against boundary re-tokenization
    return {"input_ids": full_ids, "labels": labels}

# inference uses the SAME template:
ids = tokenizer.apply_chat_template(
    [{"role": "user", "content": q}], add_generation_prompt=True, return_tensors="pt")
out = model.generate(ids, max_new_tokens=512,
                     eos_token_id=tokenizer.eos_token_id)

Deeper lesson


Problem 22 — The DPO run where reward margins soar and the model gets worse (post-training / preference optimization)

Category: Post-training / DPO & RLHF Difficulty: Hard Frequency tier: Tier 3 — differentiator for alignment / post-training specialist roles

Buggy code

ref_model = model                               # BUG 1: "reference" IS the policy (no copy/freeze)

def dpo_loss(model, ref_model, batch, beta=0.1):
    # log-probs summed over ALL positions, including prompt & padding
    pi_w  = model(batch.chosen).logits.log_softmax(-1)
    pi_l  = model(batch.rejected).logits.log_softmax(-1)
    logp_w  = gather_token_logps(pi_w,  batch.chosen).sum(-1)     # BUG 2: prompt+pad included
    logp_l  = gather_token_logps(pi_l,  batch.rejected).sum(-1)

    with torch.no_grad():
        ref_w = sum_logps(ref_model, batch.chosen)
        ref_l = sum_logps(ref_model, batch.rejected)

    margin = beta * ((logp_w - ref_w) - (logp_l - ref_l))
    return -F.logsigmoid(margin).mean()
# Logged: reward margins climb beautifully; accuracy of margin > 0 hits 95%+.
# Actual generations: shorter-lived coherence, repetition, then degeneration.

Symptoms

Diagnostic reasoning

Fix

ref_model = copy.deepcopy(model).eval().requires_grad_(False)   # true frozen snapshot

def masked_response_logps(model, ids, response_mask):
    logits = model(ids).logits[:, :-1]
    logps = logits.log_softmax(-1).gather(-1, ids[:, 1:, None]).squeeze(-1)
    return (logps * response_mask[:, 1:]).sum(-1)   # response tokens only, pads/prompt excluded

# Monitor, alongside margins:
#   - absolute chosen logp on held-out data (should not collapse)
#   - KL(policy || ref) on sampled generations
#   - periodic generation evals vs a fixed judge — the only metric that counts

Deeper lesson


Part III — More classic ML & PyTorch (Problems 23–34)


Problem 23 — The cleaning step that never happened (pandas chained assignment)

Category: pandas semantics Difficulty: Easy-medium Frequency tier: Tier 1 — core screener (any role touching pandas; a favorite code-review question)

Buggy code

df = pd.read_csv("train.csv")
adults = df[df.age >= 18]
adults["income"] = adults["income"].fillna(adults["income"].median())  # SettingWithCopyWarning

df[df.label == "spam"]["weight"] = 2.0        # chained assignment: silently does NOTHING

train_df = df                                  # not a copy!
train_df.drop(columns=["id"], inplace=True)    # also mutates df for every other consumer

Symptom

NaNs survive into training despite the "fillna"; the spam weights are all still 1.0; a completely different module crashes because id vanished from df. Verified: after sub['b'] = 0 on a filtered frame, the original is untouched.

Diagnostic reasoning

Fix

adults = df[df.age >= 18].copy()               # explicit copy when you want a copy
adults["income"] = adults["income"].fillna(adults["income"].median())

df.loc[df.label == "spam", "weight"] = 2.0     # single .loc — one operation, guaranteed write

train_df = df.copy()                            # explicit copy when consumers must not interact
train_df = train_df.drop(columns=["id"])        # prefer non-inplace, reassign

Deeper lesson


Problem 24 — The pretrained model that's blind (vision preprocessing skew)

Category: Computer vision / preprocessing Difficulty: Medium Frequency tier: Tier 2 — role-standard for vision roles; the train/serve skew variant appears in ML-systems interviews

Buggy code

import cv2, numpy as np, torch

def load_image(path):
    img = cv2.imread(path)                     # BUG 1: OpenCV loads BGR, model expects RGB
    img = img + 30                              # BUG 2: brightness "augmentation" on uint8 wraps
    img = torch.from_numpy(img).permute(2, 0, 1)
    return img.float()                          # BUG 3: [0,255] floats fed to a model trained
                                                #        on [0,1] then ImageNet-normalized

Symptom

A pretrained ImageNet backbone that should get ~76% top-1 scores ~30%; fine-tuning "fixes" it (the network relearns around the skew), which hides the bug until someone evaluates the frozen backbone. The brightness op produces bizarre speckled artifacts on bright regions.

Diagnostic reasoning

Fix

def load_image(path):
    img = cv2.imread(path)
    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)                 # fix color order
    img = img.astype(np.float32)                                # lift to float BEFORE math
    img = np.clip(img + 30, 0, 255)                             # saturating brightness
    img = img / 255.0
    img = (img - [0.485, 0.456, 0.406]) / [0.229, 0.224, 0.225] # ImageNet stats
    return torch.from_numpy(img).permute(2, 0, 1).float()

Deeper lesson


Problem 25 — The churn model that flags your best customers (predict_proba column order)

Category: sklearn API semantics Difficulty: Easy-medium Frequency tier: Tier 2 — role-standard; quick screener in applied-ML and DS loops

Buggy code

clf = LogisticRegression().fit(X_train, y_train)   # y is strings: "churn" / "stay"

churn_prob = clf.predict_proba(X_val)[:, 0]        # BUG: assumes column 0 = "churn"
top_risk = X_val[np.argsort(-churn_prob)[:1000]]   # send retention offers to these users
print("AUC:", roc_auc_score(y_val == "churn", churn_prob))   # 0.18 (!!)

Symptom

AUC of 0.18 — far below 0.5. The campaign targets exactly the wrong users.

Diagnostic reasoning

Fix

churn_col = list(clf.classes_).index("churn")
churn_prob = clf.predict_proba(X_val)[:, churn_col]

# CI guard:
assert set(clf.classes_) == {"churn", "stay"}

Deeper lesson


Problem 26 — The oversampled validation set (resampling leakage)

Category: Imbalanced learning / evaluation methodology Difficulty: Medium Frequency tier: Tier 2 — role-standard; standard follow-up to any imbalance discussion

Buggy code

from imblearn.over_sampling import SMOTE

X_res, y_res = SMOTE().fit_resample(X, y)          # BUG: resample BEFORE split
X_train, X_val, y_train, y_val = train_test_split(X_res, y_res, test_size=0.2)

clf = RandomForestClassifier().fit(X_train, y_train)
print("val F1:", f1_score(y_val, clf.predict(X_val)))   # 0.93 — "solved it!"
# production F1: 0.31

Symptom

Validation F1 of 0.93 on a problem where production delivers 0.31.

Diagnostic reasoning

Fix

X_train, X_val, y_train, y_val = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)

X_train_res, y_train_res = SMOTE().fit_resample(X_train, y_train)  # train only
clf = RandomForestClassifier().fit(X_train_res, y_train_res)
print("val F1:", f1_score(y_val, clf.predict(X_val)))   # honest number, true prevalence

# with cross-validation, use imblearn's Pipeline so resampling happens inside each fold:
from imblearn.pipeline import Pipeline
pipe = Pipeline([("smote", SMOTE()), ("clf", RandomForestClassifier())])

Deeper lesson


Problem 27 — The trading signal that knew tomorrow's price (time-series lookahead)

Category: Time series / feature engineering Difficulty: Medium Frequency tier: Tier 2 — role-standard; Tier 1 in quant/forecasting loops

Buggy code

df["ma_5"] = df["price"].rolling(5, center=True).mean()   # BUG 1: window includes FUTURE rows
df["ret"]  = df["price"].pct_change()
df["target"] = df["ret"].shift(1)          # BUG 2: shift(+1) puts YESTERDAY's return as target
                                            # (author intended tomorrow's: shift(-1))
df = df.dropna()
df["vol_norm"] = df["volume"] / df["volume"].mean()       # BUG 3: full-series mean (future info)

X_train, X_test = train_test_split(df, test_size=0.2, shuffle=True)   # BUG 4: random split

Symptom

Backtest Sharpe ratio is spectacular; live trading loses money immediately. Alternatively (Bug 2 as written): the model achieves near-perfect "prediction" because predicting yesterday's return from today's features is trivial.

Diagnostic reasoning

Fix

df["ma_5"] = df["price"].rolling(5).mean()                # trailing window only
df["target"] = df["ret"].shift(-1)                        # tomorrow's return
train = df.iloc[: int(0.8 * len(df))]                     # temporal split
test  = df.iloc[int(0.8 * len(df)) :]
train_vol_mean = train["volume"].mean()                   # stats from train era only
train["vol_norm"] = train["volume"] / train_vol_mean
test["vol_norm"]  = test["volume"] / train_vol_mean       # frozen at train value

# for CV: sklearn TimeSeriesSplit (expanding-window), optionally with a purge gap

Deeper lesson


Problem 28 — Early stopping that stopped nothing (checkpoint & selection bugs)

Category: Training orchestration / model selection Difficulty: Medium Frequency tier: Tier 2 — role-standard; nearly every "review this training script" exercise hides one of these

Buggy code

best_loss, patience, bad_epochs = float("inf"), 5, 0
for epoch in range(100):
    train_loss = train_one_epoch(model, train_loader)
    val_loss   = validate(model, val_loader)

    if train_loss < best_loss:                 # BUG 1: monitoring TRAIN loss
        best_loss = train_loss
        torch.save(model.state_dict(), "best.pt")
        bad_epochs = 0
    else:
        bad_epochs += 1
    if bad_epochs >= patience:
        break

test_acc = evaluate(model, test_loader)        # BUG 2: evaluates LAST model, never reloads best
# BUG 3 (silent): best.pt is chosen on val_loss in a fixed-seed run —
# the same val set later reused to pick lr, patience, architecture, ...

Symptom

Early stopping essentially never triggers (train loss almost always improves), so the model overfits for 100 epochs; then the final overfit weights — not the best checkpoint — get evaluated and shipped. Reported test accuracy is noticeably below the best val epoch, and nobody can reproduce "the good checkpoint."

Diagnostic reasoning

Fix

best_val, bad_epochs = float("inf"), 0
for epoch in range(100):
    train_one_epoch(model, train_loader)
    val_loss = validate(model, val_loader)
    if val_loss < best_val - MIN_DELTA:        # monitor VAL, with a minimum improvement
        best_val, bad_epochs = val_loss, 0
        torch.save(model.state_dict(), "best.pt")
    else:
        bad_epochs += 1
        if bad_epochs >= PATIENCE:
            break

model.load_state_dict(torch.load("best.pt"))   # RELOAD the winner
test_acc = evaluate(model, test_loader)        # touch test once

Deeper lesson


Problem 29 — The loss that changed meaning with the batch size (reduction semantics)

Category: Loss reduction / optimization coupling Difficulty: Medium Frequency tier: Tier 2 — role-standard; probes whether you understand what the gradient actually is

Buggy code

criterion = nn.CrossEntropyLoss(reduction="sum")     # BUG 1: sum, tuned lr assumes mean

# later, someone adds variable-length sequence support:
loss = 0
for i in range(B):
    loss += F.cross_entropy(logits[i, :lens[i]], targets[i, :lens[i]], reduction="mean")
loss = loss / B          # BUG 2: mean-of-means — short sequences weighted same as long ones

# and a "weighted loss" for imbalance:
w = torch.tensor([1.0, 50.0])
loss = F.cross_entropy(logits, y, weight=w)           # OK — but the team ALSO oversampled 50x
                                                      # BUG 3: imbalance corrected twice

Symptoms

Diagnostic reasoning

Fix

# 1: pick mean (decoupled), tune lr once
criterion = nn.CrossEntropyLoss(reduction="mean")

# 2: token-level mean over the whole batch
losses = [F.cross_entropy(logits[i, :lens[i]], targets[i, :lens[i]], reduction="sum")
          for i in range(B)]
loss = torch.stack(losses).sum() / lens.sum()

# 3: choose ONE imbalance lever; verify with predicted-prevalence check

Deeper lesson


Problem 30 — Softmax over the batch (wrong-dimension reductions)

Category: Tensor semantics Difficulty: Easy — but under time pressure, misses happen Frequency tier: Tier 1 — core screener; the fastest bug-spotting question in the deck

Buggy code

logits = model(x)                       # (B, C)
probs = torch.softmax(logits, dim=0)    # BUG 1: normalizes over the BATCH
preds = logits.argmax(dim=0)            # BUG 2: argmax over batch -> shape (C,), not (B,)
conf  = probs.max(dim=1).values         # "confidences" from batch-normalized junk

# seq2seq variant:
attn = scores.softmax(dim=1)            # (B, L_q, L_k): normalized over QUERIES not keys

Symptom

Verified: with dim=0, per-row probabilities sum to ~2.16 (anything but 1). Confidences depend on which other samples are in the batch; accuracy computations broadcast oddly (shape (C,) vs labels (B,)) or crash downstream; batch size 1 makes every probability exactly 1.0 — a great smoke test.

Diagnostic reasoning

Fix

probs = torch.softmax(logits, dim=-1)
preds = logits.argmax(dim=-1)
attn  = scores.softmax(dim=-1)
assert torch.allclose(probs.sum(-1), torch.ones(probs.shape[0]))

Deeper lesson


Problem 31 — The reshape that scrambled every image (view vs reshape vs permute)

Category: Tensor memory layout Difficulty: Medium Frequency tier: Tier 2 — role-standard PyTorch depth check

Buggy code

x = torch.randn(B, 3, 32, 32)              # NCHW
x_hwc = x.view(B, 32, 32, 3)               # BUG 1: view REINTERPRETS memory, does not permute

feat = feat.transpose(1, 2)                # (B, L, D) -> (B, D, L)
flat = feat.view(B, -1)                    # BUG 2: RuntimeError — non-contiguous

# "fixed" by a teammate:
flat = feat.reshape(B, -1)                 # BUG 3: silences the error; is the ORDER right?

Symptoms

Diagnostic reasoning

Fix

x_hwc = x.permute(0, 2, 3, 1)              # actually move the channel axis

flat = feat.transpose(1, 2).contiguous().view(B, -1)   # explicit + intentional
# or document the intended order and test it:
t = torch.arange(6).reshape(1, 2, 3)
assert t.transpose(1, 2).reshape(1, -1).tolist() == [[0, 3, 1, 4, 2, 5]]

Deeper lesson


Problem 32 — The training loop that spends 80% of its time waiting (device placement & hidden syncs)

Category: GPU performance / device semantics Difficulty: Medium Frequency tier: Tier 2 — role-standard; perf-debugging questions are increasingly common

Buggy code

model = Net().cuda()
metric_history = []

for x, y in loader:                        # loader: num_workers=0, pin_memory=False
    x, y = x.cuda(), y.cuda()
    loss = criterion(model(x), y)
    optimizer.zero_grad(); loss.backward(); optimizer.step()

    metric_history.append(loss.item())     # BUG 1: .item() SYNCS every step
    acc = (model(x).argmax(1) == y).float().mean().item()   # BUG 2: extra fwd pass + sync
    print(f"loss={loss.item():.4f} acc={acc:.4f}")          # BUG 3: sync + IO every step

new_data = torch.tensor(np_batch)          # BUG 4 (elsewhere): CPU tensor into CUDA model
out = model(new_data)                      # RuntimeError: expected ... cuda ... got cpu

Symptoms

Diagnostic reasoning

Fix

loader = DataLoader(ds, batch_size=256, num_workers=8,
                    pin_memory=True, persistent_workers=True)

running = torch.zeros(2, device="cuda")            # [loss_sum, correct]
for step, (x, y) in enumerate(loader):
    x = x.cuda(non_blocking=True); y = y.cuda(non_blocking=True)
    logits = model(x)
    loss = criterion(logits, y)
    optimizer.zero_grad(); loss.backward(); optimizer.step()

    running += torch.stack([loss.detach() * y.numel(),
                            (logits.argmax(1) == y).sum()])   # stays on GPU, no sync
    if step % 50 == 0:
        print(f"loss={running[0].item()/N:.4f}")   # sync every 50 steps, not 3x per step

Deeper lesson


Problem 33 — The RNN whose backward pass reaches back to batch #1 (hidden-state graph growth)

Category: Recurrent models / truncated BPTT Difficulty: Medium-hard Frequency tier: Tier 3 — differentiator; less common now, but it tests graph-lifetime understanding that transfers to any stateful model

Buggy code

model = nn.LSTM(input_size=64, hidden_size=256, batch_first=True)
h = None
for batch in loader:                       # a long stream, chunked into batches
    out, h = model(batch, h)               # BUG: h carries the graph across batches
    loss = criterion(out, targets)
    loss.backward()                        # backprops through ALL previous batches
    optimizer.step(); optimizer.zero_grad()

Symptoms

Diagnostic reasoning

Fix

h = None
for batch in loader:
    if h is not None:
        h = tuple(s.detach() for s in h)   # LSTM state is (h, c)
    out, h = model(batch, h)
    loss = criterion(out, targets)
    loss.backward()
    optimizer.step(); optimizer.zero_grad()

Deeper lesson


Problem 34 — The category the model had never seen (train/serve encoding drift)

Category: Feature engineering / production ML Difficulty: Medium Frequency tier: Tier 2 — role-standard for production-ML and MLOps-flavored loops

Buggy code

# training job
le = LabelEncoder().fit(train_df["city"])            # learns 400 cities, alphabetical codes
train_df["city_code"] = le.transform(train_df["city"])
model.fit(train_df[FEATURES], y)
# encoder is NOT saved

# serving service (separate codebase)
le2 = LabelEncoder().fit(request_batch["city"])      # BUG 1: refit on the request batch!
codes = le2.transform(request_batch["city"])         # "Austin"->0 here, but ->27 in training

# months later, a new city appears; someone "fixes" serving:
code = le.transform([city])[0] if city in le.classes_ else -1   # BUG 2: -1 was never trained

Symptoms

Diagnostic reasoning

Fix

# training
enc = OneHotEncoder(handle_unknown="ignore", min_frequency=50)   # rare -> infrequent bucket
enc.fit(train_df[["city"]])
joblib.dump({"model": model, "encoder": enc}, "artifact.joblib")  # ONE artifact

# serving
art = joblib.load("artifact.joblib")
X = art["encoder"].transform(req[["city"]])
pred = art["model"].predict(X)

Deeper lesson

Part IV — More transformers & pre-training (Problems 35–42, 51–54)


Problem 35 — The tied weights that quietly came untied (weight tying)

Category: Transformers / parameter sharing Difficulty: Medium-hard Frequency tier: Tier 3 — differentiator; tests whether you know what's actually in your parameter list

Buggy code

class LM(nn.Module):
    def __init__(self, vocab, d):
        super().__init__()
        self.embed = nn.Embedding(vocab, d)
        self.blocks = TransformerBlocks(d)
        self.lm_head = nn.Linear(d, vocab, bias=False)
        self.lm_head.weight = self.embed.weight        # tie

        self.apply(self._init_weights)                 # BUG 1: init runs AFTER tying and
                                                       # re-initializes lm_head.weight = embed.weight
                                                       # (still tied, but double-initialized — subtle;
                                                       # the REAL bug is the reverse order in refactors:)

def load_pretrained(model, ckpt):
    model.load_state_dict(torch.load(ckpt))
    model.lm_head.weight = nn.Parameter(               # BUG 2: someone "fixes" a size mismatch
        model.lm_head.weight.clone())                  # by cloning -> silently UNties

Symptoms

Diagnostic reasoning

Fix

# single source of truth, applied LAST in __init__ and re-applied after any surgery:
def tie_weights(self):
    self.lm_head.weight = self.embed.weight

model.load_state_dict(torch.load(ckpt))
model.tie_weights()
assert model.lm_head.weight.data_ptr() == model.embed.weight.data_ptr()

Deeper lesson


Problem 36 — The new special token that corrupted generation (vocab surgery)

Category: Tokenizers / embedding resize Difficulty: Medium Frequency tier: Tier 2 — role-standard for anyone fine-tuning open models

Buggy code

tokenizer = AutoTokenizer.from_pretrained("gpt2")
tokenizer.add_special_tokens({"pad_token": "<pad>",
                              "additional_special_tokens": ["<tool_call>"]})

model = AutoModelForCausalLM.from_pretrained("gpt2")
# BUG 1: model.resize_token_embeddings(len(tokenizer)) never called
train(model, tokenizer, data)              # CUDA error: device-side assert (index OOB)
                                           # ...or on CPU: IndexError in embedding lookup

# after adding the resize, a second bug remains:
model.resize_token_embeddings(len(tokenizer))
# BUG 2: new rows are randomly initialized; "<tool_call>" embedding is noise,
# and with tied weights the OUTPUT logits for those ids are noise too

Symptoms

Diagnostic reasoning

Fix

tokenizer.add_special_tokens({"pad_token": "<pad>",
                              "additional_special_tokens": ["<tool_call>"]})
model.resize_token_embeddings(len(tokenizer))

# in-distribution init for the new rows:
with torch.no_grad():
    emb = model.get_input_embeddings().weight
    n_new = len(tokenizer) - old_vocab_size
    emb[-n_new:] = emb[:-n_new].mean(0, keepdim=True)
model.tie_weights()                                    # restore tying if applicable

assert max(tokenizer.get_vocab().values()) < model.get_input_embeddings().num_embeddings

Deeper lesson


Problem 37 — Gradient checkpointing that broke generation and determinism (recompute pitfalls)

Category: Memory optimization / activation checkpointing Difficulty: Hard Frequency tier: Tier 3 — differentiator; standard territory for large-model fine-tuning roles

Buggy code

model.gradient_checkpointing_enable()
model.config.use_cache = True            # BUG 1: KV-cache + checkpointing are incompatible;
                                          # HF warns and silently disables cache — people miss it,
                                          # then wonder why `generate()` inside the training loop
                                          # (for logging samples) got 10x slower

out = checkpoint(block, x)               # BUG 2 (hand-rolled): default use_reentrant=True +
loss = out.sum(); loss.backward()         # inputs that don't require grad -> silent no-grad,
                                          # or "element 0 of tensors does not require grad" error

class Block(nn.Module):
    def forward(self, x):
        x = self.attn(x) + x
        if self.training and random.random() < 0.1:   # BUG 3: python-level randomness
            x = self.aux(x)                            # differs between the two forward passes
        return self.mlp(x) + x

Symptoms

Diagnostic reasoning

Fix

model.gradient_checkpointing_enable(
    gradient_checkpointing_kwargs={"use_reentrant": False})
model.config.use_cache = False                     # explicit during training

@torch.no_grad()
def sample_callback(model, prompt):
    model.gradient_checkpointing_disable()          # re-enable fast path for generation
    model.config.use_cache = True
    out = model.generate(prompt, max_new_tokens=64)
    model.config.use_cache = False
    model.gradient_checkpointing_enable(
        gradient_checkpointing_kwargs={"use_reentrant": False})
    return out

# Block.forward: replace python RNG with torch RNG (or precompute the branch outside)
keep = torch.rand((), device=x.device) < 0.1       # participates in preserved RNG state

Deeper lesson


Problem 38 — The frozen layers that kept moving (freezing done wrong)

Category: Fine-tuning / optimizer state Difficulty: Medium-hard Frequency tier: Tier 3 — differentiator; the AdamW-decay interaction is a genuinely sneaky senior question

Buggy code

model = load_pretrained()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=0.01)

for p in model.backbone.parameters():      # BUG 1: frozen AFTER optimizer creation —
    p.requires_grad = False                #        optimizer still holds these params

for epoch in range(E):
    for batch in loader:
        loss = head_loss(model, batch)
        loss.backward()
        optimizer.step()
        optimizer.zero_grad(set_to_none=False)   # BUG 2: zeros grads instead of None-ing them

Symptom

The "frozen" backbone changes anyway — slowly shrinking toward zero. Verified: a parameter with grad = zeros under AdamW(wd=0.5, lr=0.1) decays from 1.0 to 0.599 in 10 steps with zero gradient. Fine-tuning quality degrades over long runs; the backbone's pretrained features quietly wash out. A second variant: momentum buffers from steps before freezing keep applying updates for a few steps after.

Diagnostic reasoning

Fix

for p in model.backbone.parameters():
    p.requires_grad = False
model.backbone.eval()                                # freeze BN/dropout behavior too

optimizer = torch.optim.AdamW(                       # build optimizer from TRAINABLE params only
    [p for p in model.parameters() if p.requires_grad],
    lr=1e-4, weight_decay=0.01)

optimizer.zero_grad(set_to_none=True)                # and prefer None-ing grads

# guard:
frozen_sum = sum(p.detach().sum() for p in model.backbone.parameters())
... # after an epoch:
assert sum(p.detach().sum() for p in model.backbone.parameters()) == frozen_sum

Deeper lesson


Problem 39 — The model that fell off the end of its context (length extrapolation)

Category: Positional encodings / context length Difficulty: Medium-hard Frequency tier: Tier 2 — role-standard for LLM serving and long-context work

Buggy code

class LM(nn.Module):
    def __init__(self, vocab, d, max_len=2048):
        super().__init__()
        self.tok = nn.Embedding(vocab, d)
        self.pos = nn.Embedding(max_len, d)          # learned absolute positions

    def forward(self, ids):
        L = ids.shape[1]
        return self.blocks(self.tok(ids) +
                           self.pos(torch.arange(L, device=ids.device)))
        # BUG 1: L > 2048 -> index out of bounds (device-side assert)

# serving "fix":
ids = ids[:, -2048:]                                  # BUG 2: silent truncation drops the
                                                      # SYSTEM PROMPT (it's at the start)

# RoPE model deployed with a bigger window:
config.max_position_embeddings = 8192                 # BUG 3: config edited, but the model was
                                                      # TRAINED at 4096 — positions beyond that
                                                      # are extrapolation, quality collapses

Symptoms

Diagnostic reasoning

Fix

# 1: validate input length against the REAL trained window; fail loudly or route to policy
if ids.shape[1] > self.trained_max_len:
    raise ValueError(f"sequence {ids.shape[1]} > trained window {self.trained_max_len}")

# 2: context policy that preserves what matters
msgs = [system_msg] + summarize_if_needed(old_turns) + recent_turns
assert tokens(msgs) <= window

# 3: extend properly (e.g., YaRN/interpolation + brief long-context fine-tune),
#    then EVALUATE at 1k/4k/8k... before shipping the new window

Deeper lesson


Problem 40 — The log-prob that was minus infinity (precision in log-space)

Category: Numerical precision / RLHF & distillation plumbing Difficulty: Hard Frequency tier: Tier 3 — differentiator; the fp16-vs-bf16 reasoning is a strong senior signal

Buggy code

# RLHF-style logprob extraction, run under fp16 autocast
with torch.autocast("cuda", dtype=torch.float16):
    logits = model(ids).logits                       # (B, L, 50257), fp16
probs = torch.softmax(logits, dim=-1)
logp  = torch.log(probs)                             # BUG 1: log(softmax) in fp16 -> -inf
tok_logp = logp.gather(-1, ids[:, 1:, None])

ratio = torch.exp(tok_logp.sum(-1) - ref_logp.sum(-1))   # BUG 2: sum of 1000s of logps,
                                                          # then exp -> overflow/underflow
kl = (probs * (logp - ref_logp_full)).sum(-1)             # inherits the -infs -> NaN

Symptoms

Diagnostic reasoning

Fix

with torch.autocast("cuda", dtype=torch.bfloat16):
    logits = model(ids).logits
logits = logits.float()                                   # upcast before the sensitive part
tok_logp = (logits.log_softmax(-1)
                  .gather(-1, ids[:, 1:, None]).squeeze(-1))

log_ratio = (tok_logp * resp_mask).sum(-1) - (ref_tok_logp * resp_mask).sum(-1)
ratio = log_ratio.clamp(-20, 20).exp()

kl = F.kl_div(ref_logp, tok_logp, log_target=True, reduction="none")  # stays in log space

Deeper lesson


Problem 41 — The packed sequences that read each other's documents (attention across boundaries)

Category: Pre-training data pipeline / sequence packing Difficulty: Hard Frequency tier: Tier 3 — differentiator; a real bug in several public training stacks

Buggy code

# efficient pre-training: concatenate documents, chop into fixed 2048-token rows
tokens = []
for doc in corpus:
    tokens.extend(tokenizer(doc)["input_ids"] + [EOS])
rows = [tokens[i:i+2048] for i in range(0, len(tokens), 2048)]

# training uses a plain causal mask
loss = model(input_ids=row, labels=row).loss
# BUG 1: tokens attend across document boundaries (doc B sees all of doc A)
# BUG 2: loss is computed ON the EOS boundary AND on predicting doc B's first
#        token from doc A's context — an unlearnable target that adds noise
# BUG 3 (evaluation twin): perplexity eval packs documents the same way,
#        so reported ppl is contaminated by cross-doc "context"

Symptoms

Diagnostic reasoning

Fix

# build rows carrying per-doc boundaries
row, doc_ids = pack_documents(corpus, seq_len=2048)   # doc_ids: (L,) which doc each token is in

# block-diagonal causal mask from doc ids
mask = (doc_ids[None, :] == doc_ids[:, None]) & causal_lower_triangle
# position ids restart per document
pos = position_within_document(doc_ids)
# labels: standard shift, but -100 wherever the NEXT token belongs to a different doc
labels = shift_and_mask_cross_doc(row, doc_ids)

# or, with FlashAttention varlen: pass cu_seqlens and skip building the (L,L) mask

Deeper lesson


Problem 42 — The GPU that was only pretending to train (input-pipeline starvation)

Category: Performance debugging / data loading Difficulty: Medium Frequency tier: Tier 2 — role-standard; "why is training slow?" is now a standard interview scenario

Buggy code

class TextDataset(Dataset):
    def __init__(self, paths):
        self.paths = paths                              # jsonl shards on network storage

    def __getitem__(self, idx):
        with open(self.paths[idx // 10000]) as f:       # BUG 1: opens & scans a shard file
            for i, line in enumerate(f):                #        PER SAMPLE
                if i == idx % 10000:
                    text = json.loads(line)["text"]
        ids = tokenizer(text, truncation=True,          # BUG 2: full tokenization per sample
                        max_length=2048)                #        at training time, every epoch
        return torch.tensor(ids["input_ids"])

loader = DataLoader(TextDataset(paths), batch_size=8,
                    num_workers=0)                       # BUG 3: main-process loading

# also in the loop:
for batch in loader:
    batch = batch.cuda()                                 # BUG 4: no pinning, blocking copy

Symptoms

Diagnostic reasoning

Fix

# offline, once:
#   tokenize corpus -> fixed-length uint16 binary shards + index
data = np.memmap("train.bin", dtype=np.uint16, mode="r")

class PackedDataset(Dataset):
    def __len__(self): return len(data) // 2048
    def __getitem__(self, i):
        return torch.from_numpy(data[i*2048:(i+1)*2048].astype(np.int64))

loader = DataLoader(PackedDataset(), batch_size=8, num_workers=8,
                    pin_memory=True, persistent_workers=True, prefetch_factor=4)

for batch in loader:
    batch = batch.cuda(non_blocking=True)

Deeper lesson


Problem 51 — The LoRA that changed after merging (adapter math & targeting)

Category: Parameter-efficient fine-tuning / LoRA Difficulty: Medium-hard Frequency tier: Tier 2 — role-standard; LoRA plumbing questions are now routine in applied-LLM loops

Buggy code

config = LoraConfig(r=8, lora_alpha=16,
                    target_modules=["q_proj", "k_proj"])   # BUG 1: only Q,K — no V, no O,
model = get_peft_model(base, config)                       #        no MLP; capacity crippled
train(model)

# export for serving:
merged = model.merge_and_unload()
merged = model.merge_and_unload()          # BUG 2: called twice in a retry path —
torch.save(merged.state_dict(), "m.pt")    #        adapter applied TWICE

# a hand-rolled merge elsewhere:
W_new = W + B @ A                          # BUG 3: missing the alpha/r scaling

Symptoms

Diagnostic reasoning

Fix

config = LoraConfig(r=8, lora_alpha=16,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                    "gate_proj", "up_proj", "down_proj"])

# merge-parity test before shipping:
x = fixture_batch()
with torch.no_grad():
    y_adapter = model(x).logits
merged = model.merge_and_unload()
with torch.no_grad():
    y_merged = merged(x).logits
assert torch.allclose(y_adapter, y_merged, atol=1e-4), "merge changed the model!"

Deeper lesson


Problem 52 — The compiled model that recompiles forever (torch.compile pitfalls)

Category: Compilers / torch.compile Difficulty: Hard Frequency tier: Tier 3 — differentiator; increasingly asked as compile becomes default in training stacks

Buggy code

model = torch.compile(model)

for step, batch in enumerate(loader):
    ids = batch["input_ids"]                  # BUG 1: raw variable lengths — every new
    loss = model(ids).loss                    #        (batch, seq) shape triggers recompile

    if loss.item() > 10.0:                    # BUG 2: graph break via .item() in the hot path
        logger.warn("spike!")

class Head(nn.Module):
    def forward(self, x, lens):
        out = []
        for i in range(x.shape[0]):           # BUG 3: data-dependent python loop —
            out.append(self.mlp(x[i, :lens[i]].mean(0)))   # unrollable, shape-dependent
        return torch.stack(out)

Symptoms

Diagnostic reasoning

Fix

model = torch.compile(model, dynamic=True)            # or: pad to fixed length buckets

ids = pad_to_bucket(batch["input_ids"], buckets=(512, 1024, 2048))

if step % 100 == 0:                                    # rare, amortized sync
    if loss.item() > 10.0: logger.warn("spike!")

def forward(self, x, mask):                            # vectorized masked mean
    s = (x * mask[..., None]).sum(1) / mask.sum(1, keepdim=True)
    return self.mlp(s)

Deeper lesson


Problem 53 — The prompt that started with two BOS tokens (special-token plumbing)

Category: Tokenizers / special-token handling Difficulty: Medium Frequency tier: Tier 2 — role-standard; among the most common real-world fine-tuning bugs of the current era

Buggy code

# fine-tuning data prep
text = tokenizer.apply_chat_template(messages, tokenize=False)   # template includes <s>
ids = tokenizer(text)["input_ids"]           # BUG 1: tokenizer ALSO prepends BOS
                                              # -> every example starts <s><s>

# inference elsewhere:
ids = tokenizer.encode(prompt, add_special_tokens=False)          # BUG 2: NO Bos at all here
                                              # -> train/serve mismatch on position 0

# and a concatenation pipeline:
full = tokenizer.encode(system) + tokenizer.encode(user)          # BUG 3: BOS injected in the
                                              # middle of the sequence by the second encode

Symptoms

Diagnostic reasoning

Fix

# one canonical path: template does the tokenization itself
ids = tokenizer.apply_chat_template(messages, tokenize=True)      # template owns specials

# CI invariant:
toks = tokenizer.convert_ids_to_tokens(ids)
assert toks.count(tokenizer.bos_token) == 1 and toks[0] == tokenizer.bos_token
assert tokenizer.bos_token not in toks[1:]

# id-space concatenation, specials explicit:
full = ([tokenizer.bos_token_id]
        + tokenizer.encode(system, add_special_tokens=False)
        + tokenizer.encode(user, add_special_tokens=False))

Deeper lesson


Problem 54 — The OOM at step 4,217 (dynamic batching & memory fragmentation)

Category: Training reliability / memory management Difficulty: Medium-hard Frequency tier: Tier 2 — role-standard; "why did it OOM six hours in?" is a beloved war-story prompt

Buggy code

# variable-length fine-tuning data, sorted "for efficiency"
data = sorted(data, key=lambda ex: len(ex["input_ids"]))   # BUG 1: sorted; longest LAST
loader = DataLoader(data, batch_size=32,
                    collate_fn=pad_to_longest_in_batch)     # dynamic padding

# BUG 2: eval builds a fresh CUDA context worth of buffers
def evaluate(model):
    outs = [model.generate(p, max_new_tokens=512) for p in prompts]   # no no_grad needed
    return score(outs)                                      # (inference_mode)… but batch=1,
                                                            # 512-token generations, cache big

# BUG 3: OOM "handler" that guarantees the next OOM
except torch.cuda.OutOfMemoryError:
    torch.cuda.empty_cache()
    continue                                                # skips batch; loop state now
                                                            # misaligned with scheduler/accum

Symptoms

Diagnostic reasoning

Fix

loader = DataLoader(data, batch_sampler=TokenBudgetSampler(
    lengths, max_tokens=16384, bucket_by_length=True, shuffle=True))

# warm-up with worst-case shape at step 0 — fail fast, pre-fragment the pool:
_ = model(torch.zeros(1, MAX_LEN, dtype=torch.long, device="cuda")); torch.cuda.synchronize()

@torch.inference_mode()
def evaluate(model):
    return score(model.generate(batch_prompts, max_new_tokens=512))   # batched, bounded

# and log the memory trio every N steps:
log(alloc=torch.cuda.memory_allocated(), reserved=torch.cuda.memory_reserved(),
    max_alloc=torch.cuda.max_memory_allocated())

Deeper lesson


Part V — Generative models: deeper cuts (Problems 43–46, 55–58)


Problem 43 — The guidance that made everything worse (classifier-free guidance bugs)

Category: Diffusion / conditional generation Difficulty: Hard Frequency tier: Tier 2 for GenAI roles — CFG is asked about in nearly every diffusion interview

Buggy code

# Training: conditional model, but...
def train_step(model, x0, cond):
    t = sample_timesteps(); noise = torch.randn_like(x0)
    x_t = q_sample(x0, t, noise)
    pred = model(x_t, t, cond)               # BUG 1: condition NEVER dropped during training
    return F.mse_loss(pred, noise)

# Sampling with CFG:
def cfg_step(model, x, t, cond, scale=7.5):
    both = torch.cat([x, x])
    conds = torch.cat([null_cond, cond])
    eps_uncond, eps_cond = model(both, t, conds).chunk(2)
    # BUG 2: the model output was ordered [uncond, cond] but a refactor of `conds`
    #        to torch.cat([cond, null_cond]) swapped them — chunk names now LIE
    eps = eps_uncond + scale * (eps_cond - eps_uncond)
    # BUG 3 (subtle): scale applied as eps_c + s*(eps_c - eps_u) in another branch —
    #        double-counting the conditional term (off-by-one in the formula)
    return ddim_update(x, eps, t)

Symptoms

Diagnostic reasoning

Fix

# Training: drop the condition ~10% of the time
def train_step(model, x0, cond):
    t = sample_timesteps(); noise = torch.randn_like(x0)
    x_t = q_sample(x0, t, noise)
    drop = torch.rand(cond.shape[0], device=cond.device) < 0.1
    cond = torch.where(drop[:, None], null_cond, cond)
    return F.mse_loss(model(x_t, t, cond), noise)

# Sampling: one explicit order, tested
conds = torch.cat([null_cond.expand_as(cond), cond])       # [uncond | cond] — documented
eps_u, eps_c = model(torch.cat([x, x]), t, conds).chunk(2)
eps = eps_u + scale * (eps_c - eps_u)

# identity tests in CI: scale=0 ≈ uncond-only; scale=1 ≈ cond-only

Deeper lesson


Problem 44 — The VAE that ignored its latent (reparameterization & KL bugs)

Category: VAEs / stochastic gradients Difficulty: Hard Frequency tier: Tier 3 — differentiator; the reparameterization question is a classic theory-meets-code probe

Buggy code

class VAE(nn.Module):
    def forward(self, x):
        mu, logvar = self.encoder(x).chunk(2, -1)
        std = torch.exp(0.5 * logvar)
        dist = torch.distributions.Normal(mu, std)
        z = dist.sample()                        # BUG 1: .sample() blocks gradients to encoder
        recon = self.decoder(z)

        kl = -0.5 * (1 + logvar - mu**2 - logvar.exp()).sum(-1).mean()
        loss = F.mse_loss(recon, x) + kl         # BUG 2: recon uses MEAN mse (per-pixel avg)
        return loss                              #        while KL SUMS over latent dims —
                                                 #        the balance is off by a factor of D_pixels

Symptoms

Diagnostic reasoning

Fix

z = dist.rsample()                                     # gradient-preserving sampling
# or explicitly: z = mu + std * torch.randn_like(std)

recon_ll = F.mse_loss(recon, x, reduction="none").sum(dim=(1, 2, 3)).mean()  # sum over pixels
kl = -0.5 * (1 + logvar - mu**2 - logvar.exp()).sum(-1).mean()               # sum over latents
loss = recon_ll + beta * kl                            # beta explicit (β-VAE if ≠ 1)

# monitor: per-dim KL (active units), and recon-with-prior-z vs recon-with-posterior-z

Deeper lesson


Problem 45 — The GAN where the generator trained the discriminator (detach discipline)

Category: GANs / adversarial training Difficulty: Hard Frequency tier: Tier 3 — differentiator; GANs are rarer now, but the detach-discipline lesson is evergreen

Buggy code

for real in loader:
    # --- discriminator step ---
    fake = G(torch.randn(B, zdim))
    d_loss = bce(D(real), ones) + bce(D(fake), zeros)   # BUG 1: fake NOT detached —
    d_opt.zero_grad(); d_loss.backward(); d_opt.step()  # backward populates G's grads too

    # --- generator step ---
    g_loss = bce(D(fake), ones)                          # BUG 2: reuses `fake` -> graph freed
    g_opt.zero_grad(); g_loss.backward()                 # RuntimeError: backward a second time
    g_opt.step()                                         # BUG 3: G steps with grads polluted
                                                         # by the leftover d_loss backward

Symptoms

Diagnostic reasoning

Fix

for real in loader:
    # D step: block gradients into G at the boundary
    fake = G(torch.randn(B, zdim))
    d_loss = bce(D(real), ones) + bce(D(fake.detach()), zeros)
    d_opt.zero_grad(); d_loss.backward(); d_opt.step()

    # G step: fresh forward through D (D's graph from its own step is gone anyway)
    g_loss = bce(D(fake), ones)          # fake's graph to G is intact & used exactly once
    g_opt.zero_grad(); g_loss.backward(); g_opt.step()

Deeper lesson


Problem 46 — The timestep the network never actually saw (conditioning plumbing)

Category: Diffusion / timestep & conditioning embeddings Difficulty: Medium-hard Frequency tier: Tier 3 — differentiator; a quiet bug that survives code review because everything "runs"

Buggy code

class SinusoidalEmb(nn.Module):
    def forward(self, t):                        # expects t as float
        half = self.dim // 2
        freqs = torch.exp(-math.log(10000) * torch.arange(half) / (half - 1))
        args = t[:, None] * freqs[None]          # BUG 1: t arrives as raw int steps 0..999
        return torch.cat([args.sin(), args.cos()], -1)

class UNet(nn.Module):
    def forward(self, x, t):
        temb = self.time_mlp(self.sin_emb(t))
        h1 = self.down1(x)                        # BUG 2: temb computed but never ADDED to
        h2 = self.down2(h1)                       #        the residual blocks (a refactor
        ...                                       #        dropped the `+ temb` injections)

# and in the sampler:
t_batch = torch.full((B,), t, dtype=torch.float32)
eps = model(x, t_batch / T)                       # BUG 3: sampler normalizes t/T, but training
                                                  #        passed raw integer t — a scale mismatch

Symptoms

Diagnostic reasoning

Fix

# one canonical embedding path, shared by train & sample:
def timestep_embedding(t_int, dim):              # t_int: integer steps, documented
    t = t_int.float()                            # embed raw steps consistently
    ...

class ResBlock(nn.Module):
    def forward(self, h, temb):
        h = h + self.temb_proj(F.silu(temb))[:, :, None, None]   # actually injected
        ...

# sensitivity test in CI:
x = torch.randn(1, C, H, W)
outs = [model(x, torch.tensor([t])) for t in (0, 250, 500, 999)]
assert all((outs[0] - o).abs().mean() > 1e-4 for o in outs[1:])

Deeper lesson

Problem 55 — The img2img that either copied or ignored the input (strength & mask plumbing)

Category: Diffusion / image-to-image & inpainting Difficulty: Hard Frequency tier: Tier 3 — differentiator; probes whether you understand the noising schedule as an interface

Buggy code

def img2img(model, image, prompt, strength=0.7, steps=50):
    z0 = vae_encode(image)
    t_start = int(strength * T)
    z_t = q_sample(z0, T - 1, torch.randn_like(z0))   # BUG 1: noised to the MAX timestep
                                                       # regardless of strength
    timesteps = schedule[:int(steps * strength)]       # BUG 2: takes the LOW-noise END of the
    for t in timesteps:                                # schedule instead of starting at t_start
        z_t = denoise_step(model, z_t, t, prompt)
    return vae_decode(z_t)

def inpaint(model, image, mask, prompt):               # mask: 1 = region to REPLACE
    z0, m = vae_encode(image), resize(mask)
    z_t = q_sample(z0, T - 1, torch.randn_like(z0))
    for t in reversed(range(T)):
        z_t = denoise_step(model, z_t, t, prompt)
        z_t = m * z_t + (1 - m) * z0                   # BUG 3: composites against CLEAN z0 —
    return vae_decode(z_t)                             # known region is noise-free at high t,
                                                       # creating a statistics mismatch at the seam

Symptoms

Diagnostic reasoning

Fix

def img2img(model, image, prompt, strength=0.7, steps=50):
    z0 = vae_encode(image)
    timesteps = make_schedule(steps)                    # full schedule, high -> low
    start = int(len(timesteps) * strength)              # how much to actually run
    t_start = timesteps[-start]                         # first timestep we'll denoise from
    z_t = q_sample(z0, t_start, torch.randn_like(z0))   # noise EXACTLY to t_start
    for t in timesteps[-start:]:                        # denoise t_start -> 0
        z_t = denoise_step(model, z_t, t, prompt)
    return vae_decode(z_t)

def inpaint(model, image, mask, prompt):
    z0, m = vae_encode(image), resize_nearest(mask)
    z_t = torch.randn_like(z0)
    for t in reversed(range(T)):
        z_t = denoise_step(model, z_t, t, prompt)
        z_known = q_sample(z0, t) if t > 0 else z0      # renoise known region to CURRENT t
        z_t = m * z_t + (1 - m) * z_known
    out = vae_decode(z_t)
    return mask_px * out + (1 - mask_px) * image        # pixel-space restore of kept region

Deeper lesson


Problem 56 — The VQ-VAE that used four codes (codebook collapse & straight-through)

Category: Discrete latents / VQ-VAE & neural tokenizers Difficulty: Hard Frequency tier: Tier 3 — differentiator; increasingly relevant as image/audio tokenizers underpin multimodal LLMs

Buggy code

class VQ(nn.Module):
    def __init__(self, K=1024, d=256):
        super().__init__()
        self.codebook = nn.Parameter(torch.randn(K, d))

    def forward(self, z_e):                                # z_e: encoder output (B, d)
        idx = torch.cdist(z_e, self.codebook).argmin(-1)
        z_q = self.codebook[idx]                           # BUG 1: hard lookup — no gradient
        return z_q, idx                                    #        path back to the ENCODER

loss = F.mse_loss(decoder(z_q), x)                         # BUG 2: no codebook/commitment
                                                           #        terms at all
# after someone adds the STE:
z_q = z_e + (z_q - z_e).detach()
loss = (F.mse_loss(decoder(z_q), x)
        + F.mse_loss(z_q_raw, z_e))                        # BUG 3: codebook loss WITHOUT
                                                           # stop-gradients — encoder and codebook
                                                           # chase each other; and no commitment β

Symptoms

Diagnostic reasoning

Fix

def forward(self, z_e):
    idx = torch.cdist(z_e, self.codebook).argmin(-1)
    z_q_raw = self.codebook[idx]
    z_q = z_e + (z_q_raw - z_e).detach()                   # STE: decoder grads reach encoder

    codebook_loss  = F.mse_loss(z_q_raw, z_e.detach())     # moves CODES (sg on encoder)
    commit_loss    = F.mse_loss(z_e, z_q_raw.detach())     # moves ENCODER (sg on codes)
    aux = codebook_loss + 0.25 * commit_loss
    return z_q, idx, aux

# dashboard: code-usage perplexity, updated every N steps
usage = torch.bincount(idx.flatten(), minlength=K).float() / idx.numel()
perplexity = torch.exp(-(usage * (usage + 1e-10).log()).sum())

Deeper lesson


Problem 57 — The prompt that was silently cut at 77 tokens (text-conditioning limits)

Category: Text-to-image / conditioning pipeline Difficulty: Medium Frequency tier: Tier 2 — role-standard for T2I products; a real-world bug users report as "the model ignores my prompt"

Buggy code

def encode_prompt(prompt):
    tokens = clip_tokenizer(prompt, padding="max_length",
                            max_length=77, truncation=True)   # BUG 1: silent truncation at 77
    return clip_text_encoder(tokens.input_ids)[0]             # BUG 2: returns ALL 77 positions
                                                              # incl. padding embeddings, and
                                                              # cross-attn attends to them with
                                                              # no key mask

# negative-prompt support added later:
cond   = encode_prompt(long_detailed_prompt)      # 120 tokens -> last 43 silently dropped
uncond = encode_prompt("")                        # 77 positions of mostly padding

Symptoms

Diagnostic reasoning

Fix

def encode_prompt(prompt, max_len=77):
    ids = clip_tokenizer(prompt, truncation=False).input_ids
    if len(ids) > max_len:
        logger.warning(f"prompt {len(ids)} tokens > {max_len}; chunking")
        chunks = [ids[i:i + max_len] for i in range(0, len(ids), max_len - 2)]
        embs = [clip_text_encoder(pad_to(c, max_len))[0] for c in chunks]
        return torch.cat(embs, dim=1)                 # concat along sequence for cross-attn
    return clip_text_encoder(pad_to(ids, max_len))[0]

# regression test: identical seeds, prompts differing after the old boundary must differ
img_a = generate(seed=0, prompt=base + " watercolor style")
img_b = generate(seed=0, prompt=base + " oil painting style")
assert (img_a - img_b).abs().mean() > tol

Deeper lesson


Problem 58 — The FID that improved when the model got worse (generative evaluation plumbing)

Category: Generative model evaluation Difficulty: Hard Frequency tier: Tier 3 — differentiator; metric-plumbing skepticism at the level labs actually need

Buggy code

def compute_fid(gen_dir, ref_dir):
    gen = [resize(load(p), 299, mode="nearest") for p in list_images(gen_dir)]   # BUG 1:
    ref = [resize(load(p), 299, mode="bilinear") for p in list_images(ref_dir)]  # different
                                                                                 # resize kernels
    gen = np.stack(gen)                     # BUG 2: gen saved as JPEG q=75 earlier in the
                                            # pipeline; ref is PNG — codec artifacts differ
    return fid_from_activations(inception(gen[:2000]),      # BUG 3: 2k samples;
                                inception(ref[:2000]))      # FID is biased at small n

# model comparison in the report:
# run A: FID 12.3 (this script)      run B: FID 9.8 (a different repo's script)   # BUG 4

Symptoms

Diagnostic reasoning

Fix

# one pinned harness, used for every model in a comparison:
def compute_fid(gen_dir, ref_dir, n=50_000):
    tf = standard_fid_preprocess()             # pinned resize kernel + antialias + range
    gen = load_images(gen_dir, tf, format="png")[:n]      # lossless end-to-end
    ref = load_images(ref_dir, tf, format="png")[:n]
    assert len(gen) == len(ref) == n, "report n with the number"
    return fid_from_activations(inception_v3_pinned(gen), inception_v3_pinned(ref))

# calibration block, run once per harness change:
floor = compute_fid(ref_half_a, ref_half_b, n)            # noise floor
assert compute_fid(blurred(ref), ref, n) > floor * 3      # degradation sanity check

Deeper lesson


Part VI — Post-training, evaluation & serving (Problems 47–50, 59–62)


Problem 47 — The sampler that only ever said "the" (top-k/top-p implementation)

Category: Decoding / sampling implementation Difficulty: Medium-hard Frequency tier: Tier 2 — role-standard for LLM roles; hand-implementing top-p is a common live-coding ask

Buggy code

def sample_next(logits, top_p=0.9, top_k=50, temperature=0.8):
    probs = torch.softmax(logits, -1)
    probs = probs / temperature                    # BUG 1: temperature on PROBS, not logits

    topk = probs.topk(top_k)
    probs = torch.zeros_like(probs).scatter(-1, topk.indices, topk.values)
    # BUG 2: not renormalized after filtering

    sorted_p, idx = probs.sort(descending=True)
    cum = sorted_p.cumsum(-1)
    mask = cum > top_p                             # BUG 3: masks the token that CROSSES the
    sorted_p[mask] = 0                             # threshold too — can zero out a 0.95-prob
    ...                                            # token when top_p=0.9, leaving near-nothing

Symptoms

Diagnostic reasoning

Fix

def sample_next(logits, top_p=0.9, top_k=50, temperature=0.8):
    logits = logits / temperature                          # on logits
    if top_k:
        kth = logits.topk(top_k).values[..., -1, None]
        logits = logits.masked_fill(logits < kth, float("-inf"))
    if top_p < 1.0:
        sl, idx = logits.sort(descending=True)
        cum = sl.softmax(-1).cumsum(-1)
        mask = cum - sl.softmax(-1) > top_p                # exclude only tokens AFTER crossing
        sl = sl.masked_fill(mask, float("-inf"))
        logits = torch.full_like(logits, float("-inf")).scatter(-1, idx, sl)
    return torch.multinomial(logits.softmax(-1), 1)        # single softmax at the end

Deeper lesson


Problem 48 — The benchmark score that was a regex bug (LLM evaluation harness)

Category: Evaluation / benchmark plumbing Difficulty: Medium-hard Frequency tier: Tier 2 — role-standard; eval-harness skepticism is now a core LLM-engineer competency

Buggy code

def score_mmlu(model, question, choices):
    prompt = f"{question}\nA. {choices[0]}\nB. {choices[1]}\nC. {choices[2]}\nD. {choices[3]}\nAnswer:"
    out = model.generate(prompt, max_new_tokens=5)
    ans = re.search(r"[ABCD]", out).group()       # BUG 1: matches the first A-D ANYWHERE —
    return ans == gold                             # "As an AI..." scores as "A"

# comparing two models:
scores_a = eval_model(model_a, fewshot=5)          # BUG 2: model_b evaluated with a different
scores_b = eval_model(model_b, fewshot=0)          #        prompt format & shot count
# BUG 3: model_b is a CHAT model evaluated without its chat template (Problem 21's skew,
#        now on the eval side) — its scores are ~random, conclusion: "model_b is worse"

Symptoms

Diagnostic reasoning

Fix

# logit-based scoring: no free-text extraction at all
def score_mmlu(model, tok, question, choices, gold_idx):
    prompt = build_prompt(question, choices)            # one canonical builder
    letters = [" A", " B", " C", " D"]
    lps = [answer_logprob(model, tok, prompt, l) for l in letters]
    return int(np.argmax(lps) == gold_idx)

# harness contract pinned in code:
CONFIG = dict(fewshot=5, template="model_native_chat", max_new=None,
              scoring="letter_logprob", version="v3")
# ...and both models evaluated under the SAME config, transcripts sampled per run

Deeper lesson


Problem 49 — The RL fine-tune that optimized nothing (GRPO/PPO advantage plumbing)

Category: Post-training / RL from feedback Difficulty: Hard Frequency tier: Tier 3 — differentiator; current-generation post-training loops love this territory

Buggy code

# GRPO-style: G samples per prompt, advantage = group-normalized reward
def compute_advantages(rewards, group_size):           # rewards: (B,) with B = P * G
    groups = rewards.view(-1, group_size)
    mean = groups.mean(-1, keepdim=True)
    std = groups.std(-1, keepdim=True)
    adv = (groups - mean) / std                        # BUG 1: std==0 when all G rewards equal
    return adv.view(-1)                                #        (all-correct or all-wrong prompt)
                                                       #        -> NaN that poisons the batch

loss = -(adv.detach() * logp_sum).mean()               # BUG 2: logp SUM over response —
                                                       #        long responses get bigger grads;
                                                       #        with binary rewards, model drifts
                                                       #        toward verbosity (length hacking)

kl_pen = (logp - ref_logp).mean()                      # BUG 3: sign — ADDED to the loss as
loss = loss + beta * kl_pen                            #        written, this REWARDS divergence
                                                       #        when logp < ref_logp... check it!

Symptoms

Diagnostic reasoning

Fix

def compute_advantages(rewards, group_size, eps=1e-4):
    groups = rewards.view(-1, group_size)
    mean = groups.mean(-1, keepdim=True)
    std = groups.std(-1, keepdim=True)
    adv = torch.where(std > eps, (groups - mean) / (std + eps),
                      torch.zeros_like(groups))        # uniform groups contribute nothing
    return adv.view(-1)

logp_tok = per_token_logp * resp_mask
policy_term = -(adv.detach()[:, None] * logp_tok).sum() / resp_mask.sum()  # per-token normalized

kl = (logp_tok - ref_logp_tok).sum(-1) / resp_mask.sum(-1)   # KL(policy||ref) estimate ≥ 0 on avg
loss = policy_term + beta * kl.mean()                        # penalty: pulls back TOWARD ref
# direction unit test in CI: rewarded response's logp must increase after one step

Deeper lesson


Problem 50 — The quantized model that lost 20 points (compression & serving parity)

Category: Quantization / deployment Difficulty: Hard Frequency tier: Tier 3 — differentiator; increasingly common as serving-efficiency questions enter ML loops

Buggy code

# post-training quantization for serving
qmodel = quantize(model, bits=8,
                  calib_data=random_tokens(512))      # BUG 1: calibration on RANDOM tokens,
                                                      #        not real traffic — activation
                                                      #        ranges are wrong

qmodel.lm_head = quantize_layer(model.lm_head)        # BUG 2: quantizing the output head +
qmodel.norms   = quantize_layers(model.norms)         #        norms — the most range-sensitive
                                                      #        layers for tiny savings

# fp16 conversion for a bf16-trained model:
served = model.half()                                  # BUG 3: bf16 weights/activations can
                                                       #        exceed fp16's 65504 max -> inf,
                                                       #        but only on some inputs

print("ppl:", quick_perplexity(qmodel, wiki_sample))   # BUG 4: ships on ONE aggregate metric —
                                                       #        long-tail damage (code, math,
                                                       #        non-English) never measured

Symptoms

Diagnostic reasoning

Fix

calib = sample_real_traffic(n=512, stratify_by="domain")     # representative calibration
qmodel = quantize(model, bits=8, calib_data=calib,
                  keep_fp16=["lm_head", "*norm*", "embed"])   # sensitive layers stay high-precision

# dtype safety scan before fp16 serving:
maxes = {n: p.abs().max().item() for n, p in model.named_parameters()}
assert max(maxes.values()) < 3e4, "bf16-trained model unsafe for fp16 serving"

report = {
  "ppl_by_domain": {d: ppl(qmodel, data[d]) for d in DOMAINS},
  "max_token_kl_vs_fp": worst_case_kl(qmodel, model, golden_prompts),
  "golden_prompt_diffs": diff_generations(qmodel, model, golden_prompts),
}

Deeper lesson


Problem 59 — The reward model that loved long answers (preference-data training bugs)

Category: Post-training / reward modeling Difficulty: Hard Frequency tier: Tier 3 — differentiator; the upstream half of Problem 22, asked in alignment-adjacent loops

Buggy code

# pairwise reward model training
def rm_loss(rm, chosen_ids, rejected_ids):
    r_c = rm(chosen_ids).logits[:, -1]          # BUG 1: reward read at position -1 —
    r_r = rm(rejected_ids).logits[:, -1]        # with right-padding that's a PAD position,
    return -F.logsigmoid(r_c - r_r).mean()      # varying with batch padding length

# dataset assembly:
pairs = [(pick_longer(a, b), pick_shorter(a, b))    # BUG 2: annotators preferred longer
         for a, b in comparisons]                    # answers 68% of the time; nothing
                                                     # decorrelates length from label

# evaluation:
print("RM accuracy:", (r_c > r_r).float().mean())    # BUG 3: accuracy on held-out pairs from
                                                     # the SAME distribution — says nothing
                                                     # about OOD behavior under optimization

Symptoms

Diagnostic reasoning

Fix

def rm_score(rm, ids, attn_mask):
    h = rm(ids, attention_mask=attn_mask).logits        # (B, L)
    last = attn_mask.sum(-1) - 1                        # per-row last REAL token
    return h.gather(1, last[:, None]).squeeze(1)

# data audit before training (and in CI when data refreshes):
len_bias = np.mean([len(c) > len(r) for c, r in pairs])
assert 0.4 < len_bias < 0.6, f"length-label correlation {len_bias:.2f} — rebalance pairs"

# eval suite: held-out accuracy AND length-controlled accuracy AND best-of-n probe

Deeper lesson


Problem 60 — The JSON mode that produced invalid JSON (constrained decoding)

Category: Structured output / constrained generation Difficulty: Medium-hard Frequency tier: Tier 2 — role-standard; structured output is now core LLM-product plumbing

Buggy code

# hand-rolled JSON enforcement
def constrained_step(logits, state):
    allowed = state.allowed_next_chars()               # grammar over CHARACTERS
    mask = torch.full_like(logits, float("-inf"))
    for tok_id, tok_str in vocab.items():
        if tok_str[0] in allowed:                      # BUG 1: checks only the FIRST char of a
            mask[tok_id] = 0                           # multi-char token — 'e' allowed lets
    return logits + mask                               # through 'egg"' and breaks the grammar

stop = ["}"]                                           # BUG 2: stops at the FIRST '}' —
out = model.generate(prompt, stop=stop)                # truncates nested objects mid-way

schema_prompt = "Respond in JSON."                     # BUG 3: constraint switched on at
out = generate_with_grammar(model, schema_prompt)      # serving, but the model was never
json.loads(out)                                        # fine-tuned/prompted with examples —
                                                       # grammar forces tokens the model
                                                       # assigns ~0 probability, quality tanks

Symptoms

Diagnostic reasoning

Fix

# use a token-aware constraint engine (e.g., automaton compiled over the token vocab):
def constrained_step(logits, state):
    allowed_tokens = state.tokens_fully_consumable()    # precomputed per (state, vocab)
    mask = torch.full_like(logits, float("-inf"))
    mask[allowed_tokens] = 0
    if state.is_accepting():
        mask[eos_token_id] = 0                          # EOS legal only when JSON is complete
    return logits + mask

# termination by grammar state, not substring; and align the model with the schema:
prompt = few_shot_schema_prompt(schema, examples=3)
out = generate_with_grammar(model, prompt, grammar=compile_schema(schema))
log(intervention_rate=state.mask_override_count / state.steps)   # quality leading indicator

Deeper lesson


Problem 61 — The speculative decoding that changed the model's answers (acceptance-rule bugs)

Category: Inference optimization / speculative decoding Difficulty: Hard Frequency tier: Tier 3 — differentiator; serving-efficiency depth that current LLM-infra loops probe

Buggy code

def speculative_step(target, draft, ctx, K=5, temperature=0.8):
    # draft proposes K tokens greedily
    draft_toks = draft.generate(ctx, max_new_tokens=K, do_sample=False)   # BUG 1: draft
    p = target_probs(target, ctx, draft_toks, temperature)                # greedy, target
    q = draft_probs(draft, ctx, draft_toks, temperature=1.0)              # sampled — and q
                                                                          # computed at a
                                                                          # DIFFERENT temperature
    for i, tok in enumerate(draft_toks):
        if p[i][tok] > 0.5:                        # BUG 2: accept if target prob > 0.5 —
            accept(tok)                            # NOT the p/q ratio rule; output
        else:                                      # distribution is now neither model's
            resample_from(p[i]); break
    # BUG 3: on rejection, resamples from p[i] directly instead of the
    # residual distribution max(0, p - q) — biases toward tokens the draft also liked

Symptoms

Diagnostic reasoning

Fix

def speculative_step(target, draft, ctx, K=5, temperature=0.8):
    draft_toks, q = draft.sample_with_probs(ctx, K, temperature=temperature,
                                            processors=SAME_PROCESSORS)
    p = target_probs(target, ctx, draft_toks, temperature, processors=SAME_PROCESSORS)

    for i, tok in enumerate(draft_toks):
        if torch.rand(()) < torch.clamp(p[i][tok] / q[i][tok], max=1.0):   # ratio rule
            accept(tok)
        else:
            residual = torch.clamp(p[i] - q[i], min=0)
            sample_from(residual / residual.sum())                          # residual dist
            rollback_kv_cache(to=i)
            break

# CI: greedy parity + sampled-distribution parity vs target-only decoding

Deeper lesson


Problem 62 — The cache that answered someone else's question (prefix caching & serving state)

Category: LLM serving / caching Difficulty: Hard Frequency tier: Tier 3 — differentiator; production-serving depth, increasingly asked as inference stacks mature

Buggy code

# prefix KV-cache across requests
class PrefixCache:
    def get_or_build(self, prompt_text):
        key = hash(prompt_text[:200])              # BUG 1: keyed on a 200-char prefix of the
        if key in self.store:                      # TEXT — collisions between different
            return self.store[key]                 # prompts sharing the first 200 chars
        ...

# deploy day: system prompt updated
SYSTEM = open("system_prompt_v2.txt").read()
# BUG 2: cache entries built under v1 still live — requests mixing v1 KV states
# with v2 text run until the TTL expires

# separately, sampling params live in the cache entry:
entry = self.store[key]                            # BUG 3: temperature/top_p captured at
out = generate(entry.kv, entry.params)             # build time — a user's temperature=0
                                                   # request reuses another user's 0.9 entry

Symptoms

Diagnostic reasoning

Fix

class PrefixCache:
    def key_for(self, token_ids, deploy):
        return hash((deploy.model_hash, deploy.adapter_id, deploy.tokenizer_hash,
                     deploy.prompt_epoch, bytes(memoryview(np.asarray(token_ids)))))

    def get_or_build(self, token_ids, deploy):
        key = self.key_for(token_ids, deploy)
        entry = self.store.get(key)            # entry holds KV ONLY — no sampling params
        ...

# per-request params applied at generation time, never cached:
out = generate(entry.kv, params=request.sampling_params)

# observability: per-request cache key + hit/miss logged; canary that replays a fixed
# request set cache-on vs cache-off after every deploy and diffs outputs

Deeper lesson


The ML Debugging Playbook (memorize this)

Symptom → most likely causes table

Symptom Prime suspects
Loss stuck exactly at ln(K) / never moves detached graph, frozen params, labels decoupled from features (shuffle bug), LR = 0
Loss improves briefly, then climbs/oscillates missing zero_grad (accumulating gradients), LR too high, exploding gradients
Loss decreases then NaN numerical instability (log/exp/div), LR too high, no grad clipping, fp16 overflow
Train great, val terrible overfitting (real), leakage in reverse (train-only augmentation bug), val transform mismatch (e.g., different normalization)
Val great, production terrible data leakage (group/temporal/preprocessing), train-serve skew, distribution shift
Eval results change run-to-run on same data model.eval() missing (dropout live), nondeterministic ops, unseeded sampling
Memory grows every step accumulating loss tensors, storing outputs with graphs, missing no_grad in eval, broadcasting creating huge intermediates
Loss spikes on checkpoint resume optimizer/scheduler state not restored
Model predicts constant / target mean broadcasting bug in loss, degenerate LR, class imbalance + wrong loss, dead ReLUs (LR too high early)
"Works with num_workers=0, weird with 4" worker RNG duplication, non-picklable state, dataset mutation in workers
LM training loss suspiciously below data entropy unshifted labels, eval/train contamination, packing leaks across documents
Transformer NaN at position 0 only causal mask includes the diagonal (token masks itself)
Output depends on batch composition / padding padding attends (mask polarity), right-pad + last-token pooling, BN in eval-sensitive spots
Generation never stops (always hits token cap) EOS never in training targets, wrong eos_token_id, template train/serve skew
First request fine, later requests degrade KV cache not reset per request, position_ids not offset by past length
Distributed run worse than single GPU sampler.set_epoch missing, LR not scaled for effective batch, per-rank metrics unreduced
Grad clipping "has no effect" under AMP/fp16 clipping scaled grads — missing scaler.unscale_ before clip_grad_norm_
Diffusion/FM loss converges, samples are noise trainer/sampler parameterization mismatch (ε vs x0 vs v), FM sign/time-convention mismatch, missing latent scale factor
Samples subtly worse after harmless-looking refactor sampling from raw instead of EMA weights, load_state_dict(strict=False) skipping keys, schedule index wrap at boundary
DPO margins up, generations worse reference model not frozen, log-probs include prompt/pad (length confound), proxy-metric Goodharting
AUC far below 0.5 / "anti-performance" score or label inversion (predict_proba column order, flipped comparison)
Pretrained model way under published accuracy preprocessing skew: BGR/RGB, value range, normalization stats, resize
Hyperparameters stop transferring across batch sizes reduction="sum" coupling LR to batch size
GPU utilization low / multi-GPU barely faster input-pipeline starvation, per-step .item()/print syncs
"Frozen" layers drift over long fine-tunes AdamW decoupled decay on zero-grad params, BN stats still updating
CUDA device-side assert out-of-bounds embedding/class index: vocab resize missing, bad labels — rerun on CPU
Long-context quality cliff at one specific length positions beyond trained window (config edited, model not extended)
Intermittent NaN only on some batches fp16 log/softmax underflow over big vocab, zero-variance advantage groups
Temperature/guidance knob "does nothing" applied to probs not logits; condition never reaches the model (orphaned embedding)
Blurry samples at every noise level timestep embedding computed but never injected (t-blind denoiser)
Quantized model fine on average, broken on a slice unrepresentative calibration, quantized lm_head/norms, fp16 range overflow
Merged LoRA behaves differently from the adapter double merge, missing alpha/r scale, wrong base checkpoint
torch.compile slower than eager shape-churn recompilation, graph breaks from .item()/control flow, silent eager fallback
Fine-tune quietly a few points worse than a colleague's double/missing/mid-sequence BOS, template-tokenizer special-token mismatch
OOM at a reproducible step hours into training length-sorted data back-loading the token budget, fragmentation after eval
Prompt details near the end ignored (T2I) silent 77-token CLIP truncation
Discrete tokenizer uses a handful of codes VQ codebook collapse, missing straight-through/stop-gradients
Metric moves when the model didn't change eval-pipeline drift: resize kernel, codec, sample count (FID); harness change (benchmarks)
RM accuracy high, RL against it degrades quality length/format shortcut in preference data, last-token pooling on pads
"JSON mode" emits invalid or degraded JSON char-level grammar over multi-char tokens, stop-string instead of grammar state, constraint vs distribution mismatch
Speculative decoding changes outputs threshold acceptance instead of p/q ratio, q at wrong temperature, missing residual resample
Wrong answer only on cache hits; retry fixes it prefix-cache key missing inputs (system-prompt version, adapter, exact tokens)

The universal 8-step debugging procedure

  1. Overfit one batch (10–50 samples) to ~zero loss. Failure ⇒ plumbing bug (graph, loss, optimizer, shapes), not data/hyperparameters.
  2. Check the loss at init against theory (ln K for K-class CE; label variance for MSE). Wrong init loss ⇒ activation/loss mismatch or scaling bug.
  3. Print gradient norms per layer after one backward: None ⇒ detached; huge/tiny ⇒ instability or vanishing.
  4. Assert shapes and dtypes at every module boundary; test with non-square, non-power-of-2 sizes so transposes can't hide.
  5. Visualize actual model inputs (post-augmentation, post-normalization) with their labels — not the raw data.
  6. Diff train vs eval paths line by line: transforms, normalization stats, tokenizer version, eval()/no_grad.
  7. Fix all randomness, reproduce the bug deterministically, then bisect (git bisect for code, data bisect for corrupt samples).
  8. Instrument, don't guess: log LR, grad norm, weight norm, throughput per step; set_detect_anomaly for NaNs; warnings-as-errors in CI.

One-liners worth saying in an interview

Frequency-tier summary

Tier Problems What it means for prep
Tier 1 — core screeners 1, 2, 3, 4, 5, 7, 10, 23, 30 (+13, 14 for LLM roles; +18, 43 for diffusion roles; +21 for post-training roles) Drill to < 5 min each with clean narration; these are pass/fail
Tier 2 — role-standard 6, 8, 11, 12, 13, 14, 15, 16, 17, 18, 21, 24, 25, 26, 27, 28, 29, 31, 32, 34, 36, 39, 42, 43, 47, 48, 51, 53, 54, 57, 60 Pick the subset matching your target role; expect one deep-dive
Tier 3 — differentiators 9, 19, 20, 22, 33, 35, 37, 38, 40, 41, 44, 45, 46, 49, 50, 52, 55, 56, 58, 59, 61, 62 Practice the diagnostic procedure aloud; these earn "strong hire"

How to practice with this guide

  1. Cover-up drill: read only each "Buggy code" + "Symptom" section and reconstruct the diagnosis before reading the analysis.
  2. Speed round: problems 1, 3, 5, 7, 23, 30 (and 13, 14 for LLM roles) should take you under 3 minutes each — these are screeners.
  3. Deep dives: problems 4, 8, 9, 16–20, 22, 35, 37, 38, 40, 41, 44–46, 49, 50, 52, 55, 56, 58, 59, 61, 62 are the differentiators for senior roles; practice narrating the diagnostic procedure, not just the fix.
  4. Reverse drill: for each symptom row in the playbook table, write your own minimal buggy script that produces it, run it, and confirm the fix. Writing the bug is the fastest way to learn to see it. (Many of this guide's claims were verified exactly this way — the numbers quoted in Problems 1, 3, 8, 13, 14, 20, 23–25, 30, 31, 38, 40, 51, 54, and 56 come from running the buggy code.)
  5. Role targeting: use the frequency-tier summary to allocate time — Tier 1 to fluency, your role's Tier 2 to depth, Tier 3 to differentiate.