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:
- Tier 1 — Core screener. Shows up in virtually every ML coding/debugging interview regardless of role. You should solve these in under 5 minutes with a crisp narration. Miss one of these and the interview is likely over.
- Tier 2 — Role-standard. Very common in loops for the relevant specialty (LLM engineering, GenAI, ML infra, applied ML) and expected at mid/senior level. Interviewers use these to check you've actually trained real models.
- Tier 3 — Differentiator. Asked less often, but strongly separates senior/staff and research-engineer candidates. Nailing the diagnostic procedure on these — not just the fix — is what earns "strong hire."
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:
- 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). - 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.
- 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.
- 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)
- "Trained briefly, then diverged" points at the update rule, not the data: the effective step size is growing over time.
- First hypothesis: LR too high. But the LR is constant — something else is scaling the updates.
- Second hypothesis: gradients are wrong. Print
model.fc1.weight.grad.norm()each step — it grows monotonically even when the loss is flat. Gradients shouldn't grow if the weights aren't moving much. - Read the loop carefully:
optimizer.zero_grad()is missing. PyTorch accumulates gradients acrossbackward()calls, so each step applies the running sum of every gradient since the start of training. The effective learning rate grows without bound → divergence with SGD. - Verified empirically (mini-batch SGD, lr=0.05, 20 epochs): final loss 18.18 without
zero_gradvs 0.126 with it. With Adam the per-parameter normalization hides the blow-up, but the stale accumulated direction still degrades convergence (0.71 vs 0.084 in the same setup) — a nastier version of the bug because nothing obviously breaks.
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
- PyTorch accumulates gradients by design (to support gradient accumulation for large effective batch sizes).
zero_grad()is your responsibility. - Modern idiom:
optimizer.zero_grad(set_to_none=True)(default since PyTorch 2.0) — frees memory and is marginally faster. - Interview follow-up you should volunteer: gradient accumulation done intentionally looks like calling
zero_grad()every N steps and dividing the loss by N. - Notice how the optimizer changes the symptom of the same bug: SGD diverges loudly, Adam quietly underperforms. This is why "it trains, just not great" deserves the same scrutiny as a crash.
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
- Rule of thumb: when validation looks too good, suspect leakage before celebrating.
- Leak #1 — preprocessing leakage:
StandardScaler.fitwas called on the full dataset, so the validation rows' means/variances influenced the training features. For scaling this is a mild leak, but the same pattern with target encoding, feature selection, or imputation is catastrophic. The interview point is the pattern, not the magnitude. - Leak #2 — group leakage: the same patient appears in both train and validation (repeated visits, near-duplicate rows). The model memorizes patient-specific signatures instead of generalizable signal. This is usually the dominant leak and explains 99.5% → 62%.
- How to detect: check
df.duplicated().sum(), check whether any grouping key (patient_id, user_id, session_id) spans both splits, and compare val performance against a grouped split.
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
- The golden rule: anything learned from data must be learned from training data only — scalers, imputers, vocabularies, target encoders, feature selectors, PCA, even "which features looked promising in EDA."
- For time series, the analogue is temporal leakage: always split by time, never randomly.
sklearn.pipeline.Pipelineexists precisely to make this correctness composable withcross_val_score/GridSearchCV.
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
nn.CrossEntropyLoss=LogSoftmax+NLLLoss. It expects raw logits.- Feeding it softmax outputs applies softmax twice. The composition
softmax(softmax(z))still produces a valid distribution, so nothing errors — but the effective function is heavily flattened: probabilities are squashed toward uniform, gradients shrink dramatically, and the model can never express high confidence (max achievable "logit" into the second softmax is 1.0). - How to catch it: print the loss at initialization. With 10 classes it should be ≈
ln(10) = 2.303. With the double softmax it will start near 2.30 too, but after training it can never get close to 0 — the loss floor is elevated. Also inspect the model's outputs: if they already sum to 1 before the loss, that's the tell.
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
- Same family of bugs:
BCELosswith logits (should beBCEWithLogitsLoss— also more numerically stable via the log-sum-exp trick),NLLLossfed probabilities instead of log-probabilities. - Keras analogue:
from_logits=Truemismatch with a final softmax layer. - Rule: losses own the activation. Keep models returning logits; apply activations only at inference/serving boundaries.
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
- 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).
- 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 checkingtorch.isfinite(out).all(). - Read the math:
torch.log(p)wherep = sigmoid(pred). Whenpredis very negative,sigmoidunderflows to exactly0.0in float32, andlog(0) = -inf;0 * -inf = nan. Symmetricallylog(1-p)blows up for large positivepred. The aggressive LR pushed logits into the saturation zone by step 137. - Check the amplifiers:
lr=1.0with 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
- Unstable formulas to recognize on sight:
log(softmax(x))(uselog_softmax),log(sigmoid(x))(uselogsigmoid/BCEWithLogitsLoss), naivesoftmaxwithout max-subtraction,sqrt(x)near 0 in backward,x / (norm + 0),exp()of large values, division in cosine similarity without eps. - Mixed precision (fp16) shrinks the safe range further — this is why AMP uses a
GradScaler. Under bf16/fp16, mention loss-scaling and keeping reductions in fp32. - Once weights are NaN, every future step is NaN — checkpointing + NaN detection lets you restart from the last healthy state.
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
- Non-deterministic eval on fixed data is the fingerprint of active Dropout — it's still randomly zeroing units because the model was never switched to eval mode.
- BatchNorm in train mode uses per-batch statistics and keeps updating its running mean/var during "evaluation" — so evaluating actually mutates the model, and small/skewed eval batches (e.g., a last batch of size 1) produce garbage normalization.
BatchNorm1dwith batch size 1 in train mode raises an error — a helpful clue. - The growing memory: without
torch.no_grad(), every forward builds an autograd graph that is never freed by abackward().
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
model.eval()andtorch.no_grad()are orthogonal and you need both: one changes layer behavior, the other disables graph construction. A frequent interview follow-up.torch.inference_mode()is the stricter, faster modern alternative tono_grad()for pure inference.- Related production bug: exporting a model without calling
.eval()before tracing/ONNX export bakes train-mode behavior into the artifact.
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
- Accuracy pinned at the class prior (e.g., ~52% on a balanced-ish binary task) with a loss stuck near
ln(K)screams "labels are independent of features." - The definitive test: overfit a tiny subset. Take 16 (x, y) pairs and train to zero loss. With scrambled labels, the model can still memorize 16 random pairs — so also run the complement test: check label agreement directly, e.g., visualize samples with their labels, or verify a known-signal feature correlates with y.
- Root cause:
np.random.shuffleshuffles in place, independently per call. Two calls = two different permutations. - The pandas variant:
pd.concat/assignment aligns on the index, not position. Afterreset_indexon one side only, rows pair up wrong — or produce NaNs that a later.fillna(0)quietly buries.
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
- Add an alignment assertion to pipelines:
assert len(X) == len(y)is necessary but not sufficient — better is carrying an ID column end-to-end and asserting IDs match after every join/shuffle/filter. - Know pandas index-alignment semantics cold: it is the #1 source of silent data corruption in pandas code.
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
(B,1)vs(B,)broadcast to(B,B): every prediction is compared against every target. Minimizing the mean of all pairwise squared differences drives predictions toward the global target mean — which is exactly the degenerate behavior observed.- The framework did warn — treating warnings as errors in CI (
python -W error::UserWarning) turns this from a silent bug into a loud one. - Quadratic memory in batch size is the second clue: memory scaling ∝ B² instead of B.
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
- Same disease appears as: cosine similarity between
(B,D)and(D,), attention masks of(B,L)vs(B,1,L)applied wrong,argmax(dim=?)on the wrong axis,keepdim=True/Falseconfusion in reductions. - Habits that prevent it: shape-comment every tensor (
# (B, L, D)), useeinops.rearrange/reducewhich make shapes explicit and assert them, and unit-test losses with asymmetric shapes (B=2, D=3, never square) so transposition bugs can't hide.
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
- Bug A:
RuntimeError: one of the variables needed for gradient computation has been modified by an inplace operation— sometimes only after a refactor changes the graph. - Bug B: no error at all; training "works" but the backbone's weights never change and final accuracy is mediocre.
- Bug C: no error; GPU memory climbs every step until OOM near the end of the first epoch.
Diagnostic reasoning
- A:
g *= hmutatesgin place. Sigmoid's backward isσ'(z) = σ(z)(1 − σ(z))— it is computed from the saved outputg, so mutatingginvalidates the saved tensor and backward raises a version-counter mismatch error.set_detect_anomaly(True)points to the offending op. The subtle part (verified):h *= gon theLinearoutput would not error, because Linear's backward needs its input and weight, not its output. Whether an in-place op is legal depends on what each op's backward saved — which is why these bugs appear and disappear across refactors. In-place ops (*=,relu_(),tensor[mask] = 0) are only safe when the overwritten value is not needed for any backward computation. - B: The canonical check for "is my model actually training?": after
loss.backward(), inspect[(n, p.grad is None or p.grad.abs().sum().item()) for n, p in model.named_parameters()]. All backbone grads areNone→ the graph never reached them → search forno_grad/detach/.dataon the forward path. Also compare parameter checksums between epochs. - C:
running_loss += losskeeps a reference to the whole graph of every step (eachlosstensor retains its autograd history). Memory grows linearly with steps. The tell: memory grows within an epoch, resets at epoch boundaries ifrunning_lossis reinitialized.
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
.detach()vs.data: both give a graph-free view, but.datasilently skips version tracking and can corrupt gradients — never use.datain modern code.- Hidden detach sources interviewers love: converting to numpy and back (
torch.tensor(t.numpy())),.item()inside the loss, integer casts, andtorch.no_grad()decorators applied too broadly. - Retained-graph OOM also occurs with: storing losses/logits in a list for logging, and appending
lossto a metrics dict — always.item()or.detach()at the logging boundary.
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
- With
num_workers > 0, workers are created by forking the parent process (on Linux). Each fork inherits an identical copy of NumPy's global RNG state. In older PyTorch versions all workers then produce the same "random" sequence; in newer versions PyTorch reseeds torch's RNG per worker, but NumPy's and Python'srandomstate handling still trips people up, and withpersistent_workers=Falsethe workers are re-forked every epoch from the same parent state → identical augmentations every epoch. - How to catch: log the augmentation parameters with the worker id (
torch.utils.data.get_worker_info().id) for one epoch and diff across workers/epochs. - This generalizes: any library with global RNG state (NumPy,
random, OpenCV) + process forking = correlated randomness.
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
- Full reproducibility checklist for interviews:
torch.manual_seed,np.random.seed,random.seed,torch.use_deterministic_algorithms(True),torch.backends.cudnn.deterministic = True; benchmark = False, seed workers, seed the DataLoader generator, and setPYTHONHASHSEED. Also mention that some CUDA ops (e.g.,atomicAdd-based scatter) are fundamentally nondeterministic. - The mirror-image bug: seeding too aggressively inside
__getitem__(e.g.,np.random.seed(idx)) gives every epoch identical augmentations — randomness must vary across epochs but be reconstructible.
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
- (a) Default arguments are evaluated once at function definition. Every call shares the same list; mutation persists across calls. Fix:
betas=Nonethenbetas = (0.9, 0.999) if betas is None else betas. - (b) Closures capture variables, not values; after the loop,
factoris 0.9 for all three lambdas. Fix:lambda epoch, factor=factor: base_lr * factororfunctools.partial. - (c)
dict(base_cfg)copies one level; nested dicts are shared references. Fix:copy.deepcopy(base_cfg)— or better, immutable config objects (frozen dataclasses, pydantic models). - (d)
history = []in the class body is a class attribute shared by all instances. Fix: create it in__init__. (Note the trap:self.history.append(...)mutates the shared list, whileself.history = [...]would create an instance attribute — which is why the bug appears only with mutation.)
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
- LR collapses to ~0 within the first epoch (cosine schedule designed for
EPOCHSsteps consumed 1000× too fast); training freezes after a promising start. AUserWarningabout callingscheduler.step()beforeoptimizer.step()is emitted and ignored. - Later,
torch.load("best.pt")breaks after a refactor moves the model class (AttributeError: Can't get attribute 'MyModel' on module ...).
Diagnostic reasoning
- Plot or log
optimizer.param_groups[0]["lr"]every step — the single most underused debugging trace. Here it reveals LR hitting its minimum ~1 epoch in. - Unit mismatch:
T_maxis in scheduler steps. Stepping per batch means T_max should beEPOCHS * len(train_loader)— or step per epoch. Neither is wrong per se; they must simply agree. - Order matters since PyTorch 1.1:
optimizer.step()thenscheduler.step(). Reversed order skips the first LR value and (for some schedulers) shifts the whole schedule. torch.save(model)pickles the class by reference (module path + name), so loading depends on the code layout at load time. Savestate_dictinstead.
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
- Always checkpoint optimizer + scheduler + epoch + RNG state if you intend to resume; a model-only checkpoint silently restarts Adam's moments and the LR schedule, producing a loss spike on resume — itself a classic interview symptom ("why does my loss jump when I resume from a checkpoint?").
- Warm-up interactions: schedulers that expect
initial_lrbehave oddly when composed; knowSequentialLR/LambdaLRfor warmup-then-cosine.
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
- Always compare to the trivial baseline. Accuracy 0.988 with prevalence 0.012 means the model may be doing nothing. Check the confusion matrix first — here it shows ~0 predicted positives.
roc_auc_score(y_val, y_pred)with hard labels computes AUC of a single-threshold classifier (a two-point ROC). It must receive scores:clf.predict_proba(X_val)[:, 1].- Threshold selection is a form of model selection → doing it on the reporting set is leakage. Tune the threshold on a separate split (or nested CV) and report on untouched data.
- For 1.2% prevalence, discuss PR-AUC / average precision over ROC-AUC (ROC is insensitive to the false-positive rate denominator being huge), calibration, and cost-weighted thresholds.
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
- The metric hierarchy for imbalance: confusion matrix → precision/recall at business-relevant operating points → PR-AUC → calibrated probabilities. Accuracy is last.
predict_probafrom tree ensembles is poorly calibrated — mentionCalibratedClassifierCVif downstream consumers use the probabilities as probabilities.
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
- Forward pass produces NaN at position 0 for every sequence.
- After "fixing the NaN" by clamping, training is glacially slow and attention maps are near one-hot.
- Model outputs change when the same sentence is batched with different-length neighbors — a classic serving bug report: "the model gives different answers depending on batch composition."
Diagnostic reasoning
- NaN at exactly position 0 is a masking fingerprint.
torch.triu(..., diagonal=0)includes the diagonal, so every token masks itself; row 0 has no unmasked entries → softmax over all-inf→ NaN. Verified:diagonal=0produces NaN rows;diagonal=1doesn't. The convention to memorize: mask entries wherej > i(strictly upper triangle), so each token sees itself and its past. - Near one-hot attention + slow training → missing
1/sqrt(d_head)scaling. With unit-variance q,k the score variance grows ∝ d_head; verified with d=512: mean max attention weight 0.94 unscaled vs 0.11 scaled. Saturated softmax ⇒ vanishing gradients through attention. - Batch-composition-dependent outputs → padding leaking into attention. Here the polarity is inverted:
pad_mask==1marks real tokens, andmasked_fillfills where the mask isTrue— so real tokens are masked and padding attends. Even with correct polarity, remember downstream poolers: with right-padding, "take the last hidden state" grabs a pad position — use the last non-pad index, or left-pad for generation.
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
- Mask polarity conventions are inconsistent across libraries and interviewers test whether you check rather than assume: HF
attention_maskuses 1 = attend;nn.MultiheadAttention'skey_padding_maskuses True = ignore; SDPA's booleanattn_maskuses True = attend. State the convention out loud before writing the mask. - A padded position that attends to nothing still emits a (garbage) output vector — harmless if downstream losses/poolers ignore pads, catastrophic if they don't. Trace where pad positions flow after attention.
- Follow-up you should expect: "why do we scale by 1/√d?" — keep score variance ~1 so softmax stays in its high-gradient regime at init.
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
- Bug 1: training loss collapses to ~0 within a few hundred steps — suspiciously fast — yet generations are gibberish.
- Bug 2: the model learns to spam the padding token; generations degenerate into pad/EOS repetition.
- Bug 3: loss looks reasonable, but the model learns to predict the token two positions ahead; generations are subtly incoherent, always "skipping" a step.
Diagnostic reasoning
- Near-zero LM loss is never good news. Predicting token t from an input that contains token t is an identity map. Verified with a toy model: loss on unshifted labels reached 0.0014 after 300 steps (random tokens, so the true next-token loss should stay near
ln V ≈ 3.9; the same model measured with properly shifted labels scores 11.7 — pure memorization of the copy task). Rule: for causal LMs,labels[i] = input_ids[i+1]; logits at position i are scored against the next token. - The reflex check: compare training loss to the theoretical floor. Random 50-token vocab data cannot be predicted below
ln 50— a loss far below the data's entropy means the model has access to the answer. - Padding in the loss: every pad position contributes a trivially learnable target, deflating the loss and teaching the model that pad/EOS is the most likely token everywhere. Use
ignore_index=-100(the HF convention) and set label positions for pads (and any non-target text) to -100. - Double shift: HuggingFace
...ForCausalLMmodels shift internally when you passlabels. Pre-shifting yourself composes two shifts. The tell: inspect one example end-to-end — printtokenizer.decodeof the input position and the label it's scored against. A 5-line sanity print beats an hour of loss-curve staring.
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
- Loss-floor reasoning is a universal LLM debugging tool: loss below the data entropy ⇒ leakage into the input (unshifted labels, document leaks across the context window, eval data in train).
- The same "who shifts?" question recurs everywhere: HF shifts, most nanoGPT-style codebases shift in the dataloader, some kernels (fused CE) shift internally. Establish it once per codebase and write it down.
- Related Tier-2 follow-up: sequence packing without cross-document attention masking — tokens attend across document boundaries, quietly improving loss and hurting downstream quality.
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
- The first request generates fine; the second request returns tokens influenced by the first conversation, degenerating over time (Bug 1).
- With Bug 2, generations start coherent then become repetitive/degenerate — worse the longer the output.
- Setting
temperature=0.0(intended as "greedy") returns NaN-driven random tokens (Bug 3: division by zero → inf logits → NaN probs;multinomialthen errors or samples garbage).
Diagnostic reasoning
- Request-order-dependent outputs ⇒ hidden state persisting across requests. The KV cache lives on
self; request 2 attends over request 1's keys/values. Printpast_kv[0][0].shape[2](cached sequence length) at the start of each request — it should equal 0/None, not the previous conversation's length. - Position bug: during incremental decoding the new token's
position_idsmust bepast_length + arange(new_len). Computingarange(input_step.shape[1])gives[0]for every step, so RoPE/positional encoding places every generated token at position 0. The model still "works" (attention over cache is intact) which is what makes it insidious — quality just quietly degrades. Test: generate with and without cache and assert identical outputs (use_cache=Falseas the reference implementation). - temperature=0 must be special-cased to
argmax, not passed to a division. The clue is the stack trace location (softmax returning NaN) plus "only when temperature is 0."
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
- The gold-standard KV-cache test: cached and uncached decoding must produce identical tokens (greedy). Any divergence is a cache/position/mask bug. Same trick validates batched-vs-single and left-padded-vs-unpadded inference.
- Related bugs in this family: attention mask not extended along with the cache; left-padding required for batched generation but dataset right-padded; sampling with
repetition_penaltyapplied to logits after softmax; forgettingeoshandling so finished sequences keep generating inside a batch.
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
- Grad-clip "doesn't work": occasional loss spikes and divergences persist even with
max_norm=1.0. - Logged grad norms are enormous (∼10⁴–10⁵) and jump discontinuously when loss spikes occur — but the numbers look meaningless.
- Weights in LayerNorm drift toward zero over long runs; embedding quality degrades late in training.
Diagnostic reasoning
- Clipping scaled gradients:
GradScalermultiplies the loss (hence gradients) by a dynamic scale factor (often 2¹⁴–2¹⁶). Clipping tomax_norm=1.0in scaled units is effectively clipping to1/65536in true units — or, more commonly, effectively never clipping because the comparison is against the scaled norm. The correct order isscaler.unscale_(optimizer)beforeclip_grad_norm_, thenscaler.step. The logged grad norms being ~the scale factor is the giveaway. - fp16 vs bf16: fp16 has a tiny dynamic range (max ~65504) — hence the scaler; bf16 has fp32's range and normally needs no GradScaler. If you see
GradScaler+ bf16 in review, flag it as harmless-but-confused; if you see fp16 with no scaler, flag it as a NaN generator. Loss spikes at scale also implicate data (bad shards), so bisect: replay the exact global step's batch (this is why you checkpoint dataloader state). - Blanket weight decay: AdamW decay should exclude LayerNorm/RMSNorm weights, all biases, and (commonly) embeddings. Decaying norm gains pulls them toward zero and destabilizes activations late in training. This is a "did you actually read a real pre-training codebase" check.
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
- Grad-norm logging is only meaningful in unscaled units; also log it pre-clip — the spike pattern (isolated spikes vs sustained growth) distinguishes bad-data events from genuine instability.
- Other pre-training spike suspects worth naming: LR warmup too short, Adam eps too small for bf16, attention logit growth (fix: QK-norm or logit soft-capping), and a data shard with pathological content (fix: skip-batch + data bisect).
- Gradient accumulation subtlety: loss must be divided by ACCUM (done here) and anything computed per-micro-batch (like logged loss) interpreted accordingly; with DDP, use
no_sync()on non-boundary micro-steps to avoid ACCUM× communication.
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
- The distributed run converges to a worse loss than the single-GPU baseline given the same number of samples seen (Bug 1: every epoch replays the identical shuffle order, so the model sees batches in the same order every epoch — less effective SGD noise, and with augmentation-free data, effectively a repeated curriculum).
- Reported validation accuracy is noisy and disagrees with post-hoc full-dataset evaluation (Bug 2: it's the accuracy of 1/8th of the data, whichever shard rank 0 got).
- Intermittent crashes or silently stale checkpoints when other ranks
torch.loadthe checkpoint file that rank 0 is still writing (Bug 3).
Diagnostic reasoning
- "Distributed is worse" checklist, in order of likelihood: (1)
set_epochmissing → same permutation every epoch (the sampler seeds its RNG withseed + epoch; without it, epoch is always 0 — log the first batch's indices at epoch 0 and 1 and diff them); (2) learning rate not adjusted for the 8× effective batch size; (3) loss reduction semantics — DDP averages gradients across ranks, so per-rankmeanlosses compose correctly, but a per-ranksumloss silently scales gradients by world_size; (4) BatchNorm statistics computed per-rank (use SyncBatchNorm if BN matters). - Metric aggregation: any metric computed on a
DistributedSamplershard must be all-reduced (dist.all_reduceon correct/total counts) before reporting. Also beware:DistributedSamplerpads the dataset to divide evenly across ranks — duplicated samples inflate eval metrics; usedrop_lastthinking or a dedicated (unsharded, rank-0) eval pass. - Checkpoint discipline: rank-0-writes +
dist.barrier()after; or write to temp file + atomic rename.
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
- DDP invariant worth stating in interviews: after
backward(), gradients are identical on all ranks; parameters must therefore stay identical forever. Any rank-dependent randomness in the model (e.g., unseeded dropout noise applied to weights, rank-dependent data-dependent control flow) breaks this silently — symptoms only appear at checkpoint-load or whenfind_unused_parameterserrors surface. - Know the classic hang: a conditional branch where some ranks call
backward()on parameters others didn't use → NCCL collective waits forever. Debug withTORCH_DISTRIBUTED_DEBUG=DETAILandfind_unused_parameters=True(and know its perf cost).
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
- Training loss decreases smoothly and plateaus at a healthy-looking value — the loss gives no warning — yet samples are pure noise or vague color blobs (Bug 1).
- In the latent-diffusion variant, samples are washed-out / low-contrast / desaturated, and the diffusion loss behaves oddly because latents have ~5× the expected standard deviation (Bug 2).
Diagnostic reasoning
- Training loss cannot detect a train/sample contract mismatch. MSE-to-x0 is a perfectly learnable objective; the network happily learns it. The bug lives in the interface: the sampler algebra assumes
model(x_t,t) ≈ εand reconstructsx0 = (x_t − √(1−ᾱ)·ε)/√ᾱ. Feed it an x0-prediction and every step's mean is wrong. This is the diffusion analogue of Problem 3 (loss owns the activation): the sampler owns the parameterization. - Diagnostic that settles it in minutes: one-step reconstruction test. Take a real image, noise it to a middling t, run the model once, apply the sampler's x0-recovery formula, and look at the result. If the recovered x0 is garbage while
mse(pred, target_you_trained_on)is small, the parameterization contract is broken. - Know the three parameterizations and their conversions: ε-prediction, x0-prediction, and v-prediction (
v = √ᾱ·ε − √(1−ᾱ)·x0). Any can train; trainer, sampler, and loss weighting must simply agree. (v-prediction is the modern default for stability at high noise levels.) - Latent scaling: SD-family VAEs produce latents with std ≈ 1/0.18215; the
scale_factornormalizes them to ~unit variance so the diffusion noise schedule matches. Skipping it at encode (or decode) breaks the SNR the schedule assumes. The tell:z.std()far from 1 on a training batch — a one-line check worth doing on any latent pipeline. - Also verify in review:
tpassed to the model matches the timestep the noising used (off-by-one between 0-indexedalphas_cumprodand 1-indexed schedule tables is a classic — see Problem 19), and that the model actually receives t (a missing timestep embedding degrades to a blurry average denoiser).
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
- Generative-model debugging inverts the usual signal: the training loss is nearly useless; the samples are the test. Sample early, sample often, and keep a fixed noise seed so sample quality is comparable across checkpoints.
- Standard follow-ups: why ε-prediction loss ≈ uniform weighting in a particular SNR sense; min-SNR loss weighting; why classifier-free guidance requires training with condition dropout (10% null-condition) — forgetting that dropout is its own classic bug: CFG at inference then extrapolates between two conditionals the model never learned to separate.
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
- Samples are subtly but consistently worse than a colleague's older checkpoint — more high-frequency artifacts, less coherent global structure (Bugs 1–2). FID quietly regressed ~15% and nobody could point to a diff that "touched the model."
- With Bug 3, the final denoising step occasionally adds massive noise:
alphas_cumprod[-1]wraps to the most-noised entry, so the last step's target SNR is catastrophically wrong — images look almost right but carry a global haze/noise floor.
Diagnostic reasoning
- EMA is part of the model, not an optimization nicety. Diffusion sample quality depends heavily on exponentially-averaged weights (decay ~0.9999); raw online weights sit in a sharper, noisier region. When sample quality regresses with no model-code diff, ask "what weights are we sampling from?" first. Detection: checksum the sampled-from state_dict against both raw and EMA checkpoints.
- Negative indexing is a silent wrap in PyTorch/NumPy.
alphas_cumprod[-1]is a valid read of the wrong element — no exception, just wrong math on exactly one step. The general lesson: any hand-ported index arithmetic from 1-indexed paper math deserves an explicit boundary test (first step, last step). Fix by carrying explicit(t, t_prev)pairs, witha_prev = 1.0whent_prev < 0(the fully-denoised boundary). - Interview-grade habit: plot the schedule you actually execute — t values, ᾱ_t, SNR per sampling step — before debugging the network. Most sampler bugs are visible in that plot (wrapped index, skipped final step, reversed order, trained-vs-sampled schedule mismatch).
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
- "No diff touched the model" is never true — EMA updates, weight tying, buffer registration, and checkpoint-loading
strict=Falseare all model-affecting code that doesn't look like model code.strict=Falsedeserves special fear: it silently skips mismatched keys, so a renamed module loads random init without error. - Related sampler-family bugs to name-drop: guidance scale applied to the wrong quantity (ε vs x0 space),
etamisuse in DDIM, float32 schedule computed in float16 (ᾱ underflow at high t), and mismatch between training noise schedule (e.g., cosine) and the sampler's hardcoded linear betas.
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
- Training loss converges smoothly to a sensible plateau — the target
x0 − x1is a perfectly well-defined regression target, so the loss cannot reveal the bug. - Samples never reach the data distribution. Verified on a 1-D bimodal toy (modes at ±2): correct sign/time puts 98% of samples near the modes; the sign-flipped model leaves 3% near the modes with samples statistically indistinguishable from the starting noise (the learned field pushes away from data, and the two bugs partially "cancel" into a field that transports nowhere useful).
Diagnostic reasoning
- Flow matching has exactly three conventions that must agree, and each is a coin-flip across papers/codebases: (1) does t=0 mean noise or data? (2) is the velocity target
x1 − x0(pointing toward data under t=0-noise) or the reverse? (3) does the sampler integrate t from 0→1 or 1→0, withx += v·dtmatching that direction? Any consistent triple works; any mismatch fails silently. (Diffusion papers use t=0 = data, which is exactly why ported code mixes conventions.) - The decisive test costs 20 lines: run the pipeline on a 1-D or 2-D toy dataset (two Gaussians, checkerboard) where you can see the samples. This localizes convention bugs in minutes, versus days on images. Interviewers specifically reward "I'd shrink the problem to a visualizable toy."
- Second decisive test: integrate the ground-truth field. For linear paths the true velocity is known in closed form (
x1 − x0for paired endpoints); plugging the analytic field into your sampler validates the sampler independently of the network. Separate "trainer bug" from "sampler bug" — the two-component contract is the same lesson as Problem 18. - Sanity checks worth narrating: at t≈data-end, samples should already look like data; the norm of the learned velocity should ≈ E‖x1 − x0‖ (≈ √(2D/π)-ish for Gaussian pairs), not ~0 (a ~0 field means targets averaged out — e.g., unpaired/reshuffled (x0, x1) within the batch, another classic).
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
- Write the convention as a docstring contract at the top of the module (
t=0: N(0,I); t=1: data; v = dx/dt = x1 − x0; sampler: Euler 0→1) and add a unit test that trains 30 seconds on two Gaussians and asserts sample means. Convention bugs recur every time someone ports code. - Expect follow-ups: why the conditional FM objective (regressing per-pair velocities) has the same minimizer as the intractable marginal objective; independent coupling vs minibatch-OT coupling (straighter paths, fewer sampling steps); relationship to rectified flow and to v-prediction diffusion (linear-path FM is closely related — good candidates connect them).
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
- The model never emits EOS — every generation runs to
max_new_tokensand trails off mid-sentence (Bug 2). - The model often responds by asking another user-style question or continuing the prompt rather than answering — it has partially learned to imitate prompts (Bug 1).
- Offline eval loss looks great, but deployed quality is far worse than the eval suggested (Bug 1 deflates loss with easy prompt tokens; Bug 3 means the deployed input distribution doesn't match training at all).
Diagnostic reasoning
- Prompt-loss masking: SFT should optimize
P(response | prompt), so label positions covering the prompt must be −100. Training on prompt tokens (a) wastes capacity teaching the model to mimic users, and (b) corrupts loss-based evals (prompt tokens are easy → loss looks better than the model is). Detection: decode one example's(input_id, label)pairs and read them — the fastest, most underused post-training debug step. - EOS: a model that never saw
<eos>after responses assigns it near-zero probability forever. Symptom is unmistakable (always hits the token cap). Also check the opposite failure: padding witheoswithout masking pad-labels teaches the model to spam EOS immediately — the same underlying issue as Problem 14's Bug 2. - Template train/serve skew: exact string format is part of the model's input distribution — role markers, whitespace, newlines, BOS handling.
apply_chat_templateat train but raw concatenation at serve (or vice versa) is a distribution shift you inflicted on yourself. The classic subtle version: tokenizing prompt and response separately and concatenating token IDs produces different tokens than tokenizing the joined string (tokenizer merges across the boundary); mask offsets computed from the separate tokenization then misalign by a token or two. - Bonus check for interviews: truncation must never cut the response to fit max_length while keeping the whole prompt (truncate the prompt from the left instead) — otherwise you train on amputated targets.
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
- Post-training debugging rule: read your actual tokenized examples, decoded, with label masks visualized, before trusting any loss curve. Nearly every SFT bug is visible in one printed example.
- Know the adjacent family:
pad_token = eos_token(common default) silently masking real EOS from the loss when pads are set to −100 by matching on token id — mask by position, not by id. Multi-turn masking (train on all assistant turns, mask user turns) is the standard follow-up.
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
- All DPO dashboard metrics improve (margins ↑, implicit-reward accuracy ↑, loss ↓) while side-by-side human/LLM-judge evals show the model getting worse — the signature divergence of optimizing a proxy.
- With Bug 1, the "KL leash" is gone: since
ref == policy, the reference log-probs track the policy exactly (theno_gradonly stops gradients, not weight sharing), so implicit rewards are computed against a moving target and the loss degenerates toward pushinglogp_w − logp_lapart without any anchor — unbounded drift from the initial model. - With Bug 2, sequences win by being long: summed log-probs over prompt+padding make length and prompt-likelihood confounders of "preference." The model learns verbosity, not quality.
Diagnostic reasoning
- First question for any preference-optimization pathology: "what exactly anchors the policy to its initialization, and is it actually frozen?" Check
id(ref_model) != id(model),all(not p.requires_grad for p in ref_model.parameters()), and — the subtle one — that no optimizer param group or weight tying reaches the reference copy.deepcopy+.eval()+requires_grad_(False). - Second: what tokens are inside the log-prob sum? DPO's implicit reward should cover response tokens only (mask prompt positions and padding with the same −100 discipline as SFT). Diagnostic: correlate margin with response-length difference across the eval set — high correlation ⇒ length confound. (Length-normalized variants exist precisely because even correct masking leaves some length bias.)
- Third — the conceptual layer interviewers push on: margins measure relative log-prob movement, and DPO can achieve them by decreasing the chosen response's absolute log-prob (just decreasing the rejected one faster). Monitor absolute
logp_won held-out data: if it falls persistently, the model is unlearning fluent text while "winning" the margin game. Always pair proxy metrics with actual generation evals. - The RLHF-side sibling bugs to name: KL penalty sign/coefficient errors (policy pushed away from ref → fluent-looking reward-hacked gibberish), reward model queried on a different template than the policy generates, and advantage normalization across a batch containing multiple prompts of very different difficulty.
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
- Post-training is where Goodhart's law becomes a debugging category: the loss can be implemented perfectly and the run still "fails" because the proxy diverged from quality. Strong candidates volunteer the monitoring suite (absolute log-probs, KL, length distributions, judge win-rates), not just the code fix.
- This problem generalizes Problem 12's lesson (metric ≠ objective) to the frontier setting — connecting those two explicitly is a strong interview move.
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
df[mask]may return a copy; assigning into it modifies the copy, which is immediately discarded.df[mask]["col"] = xis two operations —__getitem__then__setitem__— and the warning tells you pandas can't guarantee which object you wrote to.- The reflex check when a transformation "doesn't take": immediately after the line, assert the effect (
assert df["income"].isna().sum() == 0). Cheap assertions after each cleaning step convert silent no-ops into loud failures. - The reverse hazard is aliasing:
train_df = dfbinds a second name to the same object, soinplace=Truemutations propagate to every holder of the reference.
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
- pandas ≥ 2.x with Copy-on-Write (
pd.options.mode.copy_on_write = True, default in pandas 3) makes chained assignment consistently a no-op rather than sometimes-working — know both behaviors, because production codebases straddle versions. - Style rule that prevents the whole class: every write goes through a single
.loc[rows, cols] = value; every subset that will be mutated gets an explicit.copy().
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
- Pretrained-model accuracy far below the published number with no training involved is always preprocessing skew. Diff your pipeline against the model card line by line: color order, value range, normalization constants, resize interpolation, center-crop.
- Bug 2 verified: uint8
200 + 100 = 44— modular wraparound, not saturation. Any arithmetic on uint8 images must be lifted to float (or usecv2.add, which saturates). The speckle pattern — bright pixels turning dark — is the fingerprint. - The decisive 2-minute test: run one known image (a dog photo) through the exact deployed pipeline and check the top-5 predictions. Interviewers reward concrete "golden input" tests over staring at code.
- Channel-order check: swap
img[..., ::-1]and see if accuracy jumps. BGR-vs-RGB costs a characteristic ~10–20 points on ImageNet models — degraded but not destroyed, which makes it easy to ship.
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
- Preprocessing is part of the model. Serialize it with the model (torchvision
transformsin the checkpoint, or a processing config), and add a unit test asserting the pipeline's output stats (mean ≈ 0, std ≈ 1 post-normalization) on a fixture image. - Same family: PIL vs OpenCV resize interpolation differences, JPEG decode differences across libraries, EXIF rotation ignored, and normalizing with the wrong dataset's mean/std after switching backbones (e.g., CLIP's stats differ from ImageNet's).
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
- AUC substantially below 0.5 is a label/score inversion, not a bad model. A truly uninformative model gives 0.5; 0.18 means the score is strongly informative but flipped — the very first thing to check is which column of
predict_probayou grabbed. predict_probacolumns followclf.classes_, which sklearn sorts (alphabetically for strings, numerically for ints). Verified: classes['neg','pos']→ column 1 is'pos'. Hereclasses_ = ["churn", "stay"]— alphabetical — so column 0 is churn… but had the labels been"active"/"churn", it would silently flip. Never assume; index by lookup.- The generalized lesson: any code containing
[:, 1]onpredict_probaoutput is only correct for a specific label encoding. It breaks when labels change from{0,1}to strings, or when a binary problem becomes multiclass.
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
- Sibling bugs:
roc_auc_score(y, clf.predict(X))(hard labels — Problem 12),pos_labeldefaults inprecision_scorewhen labels aren't {0,1}, andLabelEncoderfit on train mapping differently than on serving data (Problem 34). - AUC ≈ 1 − true AUC, accuracy ≈ 1 − expected, correlations of the wrong sign: "anti-performance" always means an inversion somewhere — labels, scores, or a comparison operator.
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
- Two distinct leaks compound. First, SMOTE synthesizes minority samples by interpolating between real minority neighbors — do it before splitting and synthetic points built from validation rows land in train (and vice versa): the model has effectively seen blurred copies of the validation set.
- Second, even without interpolation leakage (plain random oversampling), duplicating minority rows before splitting puts exact copies of validation rows in train.
- Third — independent of leakage — the validation set is now 50/50 balanced, so its F1 answers a question about a distribution that doesn't exist in production. Evaluation must happen on the true prevalence.
- Interview framing: "resampling is a training-time trick; evaluation data must be untouched in both membership and distribution."
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
- The general form of Problem 2's rule: split first, then everything — scaling, resampling, feature selection, augmentation — happens inside the training fold only.
imblearn.pipeline.Pipelineexists because sklearn's own Pipeline can't express "fit-time-only" resampling. - Also question whether resampling is needed at all: class weights or threshold-moving often match SMOTE without synthetic-sample pathologies (SMOTE in high dimensions interpolates through empty space).
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
- Any time a temporal model looks amazing, hunt for lookahead. Four channels here: (1)
center=Truewindows average future prices into "current" features; (2) shift direction —shift(1)moves data forward (past→present),shift(-1)pulls the future; writing the intended alignment as a comment and testing it on a 5-row toy frame settles it in a minute; (3) normalizing by full-series statistics injects the future's scale; (4) random splitting trains on the future to predict the past. - The decisive audit: pick one row, list every feature, and ask "was this value knowable at this row's timestamp?" — a knowability audit. Interviewers care that you have a procedure, not that you spot all four instantly.
- Detection signal in the wild: performance that degrades as you move the evaluation window further from training data, or a feature-importance ranking dominated by one suspiciously perfect feature.
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
- Add a purge/embargo gap between train and test when features use windows (a 5-day feature straddles the boundary otherwise) — this is standard in financial ML for exactly this reason.
- The subtlest variant: target leakage through labels computed with future revisions (restated economic data, delayed outcome labels). Ask when the label became known, not just when the event occurred.
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
- Early stopping's entire purpose is to detect generalization degradation; train loss monotonically improves under sufficient capacity, so monitoring it disables the mechanism. The tell:
bad_epochsnever increments in the logs. - Bug 2 is the more damaging and more common one: the loop saves the best model but evaluates whatever the last epoch left in memory. Diff test-time metrics against the best-epoch val metrics — a gap larger than noise means you're not evaluating what you think.
- Bug 3 is the methodological layer: every decision made by peeking at val (stopping epoch, LR, architecture) is a fit to val. With enough decisions, val becomes train. Report final numbers on a test set touched exactly once.
- Bonus review point:
val_lossvsval_metric— for imbalanced tasks, loss and task metric can disagree about the best epoch; monitor the metric you'll be judged on.
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
min_deltamatters: without it, noise-level improvements reset patience forever on plateaued runs.- Related orchestration bugs to name: LR scheduler continuing to step during patience epochs (so the reloaded best model meets a mismatched LR), and saving
state_dictwithout the epoch/optimizer needed to resume (Problem 11's 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
- Bug 1: training that was stable at batch size 32 diverges at 256 with the "same" hyperparameters (gradient magnitude scaled 8×); works again when LR is divided by 8 — nobody knows why.
- Bug 2: per-token perplexity on long documents is worse than expected; the model underweights exactly the sequences with the most signal.
- Bug 3: the model wildly over-predicts the minority class; predicted prevalence ≈ 50× the true rate.
Diagnostic reasoning
reduction="sum"makes the gradient proportional to batch size, so LR and batch size become coupled: every batch-size change silently retunes the effective LR.meandecouples them. Neither is wrong, but the choice must be deliberate and consistent with LR tuning. Fingerprint: hyperparameters that mysteriously stop transferring across batch sizes.- Bug 2 is the token-weighting question that recurs throughout LLM training: mean-of-per-sequence-means weights each sequence equally; a global token-level mean weights each token equally. For language modeling the standard is token-level: sum the per-token losses and divide by total non-pad tokens. (This same choice silently differs across gradient-accumulation implementations — micro-batch means averaged ≠ global token mean when micro-batches have different token counts.)
- Bug 3: class weights, oversampling, and threshold-moving are three levers for the same correction — stacking them overcorrects. Predicted prevalence vs true prevalence is the 1-line diagnostic.
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
- "What, exactly, is this gradient the gradient of?" is the interview question behind all three bugs. Being able to state the objective as a weighted sum over examples/tokens — and defend the weights — is the skill being probed.
- Linear-scaling rule connection: when you intentionally grow batch size, LR typically scales up proportionally (with warmup) — the opposite direction of Bug 1's accidental coupling, and a good place to show you know both.
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
- Softmax's dim must be the class/key dimension — the one that competition is over. For
(B, C)logits:dim=1(or-1, which is robust to adding sequence dims). For attention(B, L_q, L_k): normalize over keys,dim=-1. - Three instant checks: (1)
probs.sum(dim=-1)must be all-ones; (2) predictions for one sample must not change when you change its batchmates; (3) run batch size 1 — degenerate outputs expose batch-dimension reductions immediately. - Bug 2's shape error usually surfaces as a broadcast rather than a crash (Problem 7's disease):
(C,) == (B,)may broadcast if C == B — which is why test batches should never have size equal to the number of classes.
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
- Prefer
dim=-1for class/key reductions — it survives shape evolution (adding a sequence dimension turns(B,C)into(B,L,C)anddim=1silently becomes wrong). - Same family:
mean(dim=...)over the wrong axis in masked pooling,norm(dim=...)in cosine similarity,maxvsamaxreturn-type confusion. The habit that prevents all of them: comment expected shapes and reduce by named intent, or useeinops.reducewhich encodes the axis semantics in a string.
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
- Bug 1: no error; images render as three stacked grayscale bands with hue stripes — every pixel's channels reassigned. A model trained on this converges (CNNs are robust) but a few points low; nobody suspects the input.
- Bug 2 verified:
view size is not compatible with input tensor's size and stride— the loud, helpful version. - Bug 3: no error, but whether elements are ordered
(D,L)or(L,D)in the flattened vector depends on the transpose — if a downstreamLinearwas trained expecting the other order, quality silently degrades.
Diagnostic reasoning
- Mental model interviewers probe: a tensor is a flat buffer + shape/stride metadata.
viewchanges only metadata over the same element order;permute/transposechange stride so logical order ≠ memory order;reshape= "view if possible, else copy into a fresh contiguous buffer honoring the logical order." - Therefore: to reorder dimensions,
permuteis the only correct tool;viewafter a permute is either an error (good) or — after a.contiguous()cargo-culted in — a silent success with the order you hopefully wanted. The question to ask at everyreshape(B, -1): "which axis varies fastest, and does the consumer agree?" - Verification trick: build a tiny tensor with
torch.arange, run the op chain, and read the result — element identities make ordering bugs visible in one print.
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
- The same buffer-vs-logical distinction underlies:
torch.from_numpysharing memory with the source array (mutate one, corrupt the other),Tensor.expandcreating zero-stride views (writing to them is UB — userepeatto materialize), and channels-last memory format for conv performance. - Flattening convention bugs are the classic CNN→FC interface failure when porting between frameworks (PyTorch NCHW vs TF NHWC): weights permuted one way, activations the other.
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
nvidia-smishows 25–40% GPU utilization; training is far slower than the same model in a colleague's script; profiler shows largecudaStreamSynchronizeblocks.- Bug 4 is the loud version everyone learns first:
Expected all tensors to be on the same device.
Diagnostic reasoning
- CUDA ops are asynchronous: python queues kernels and runs ahead. Any op that needs the value on the CPU —
.item(),.cpu(),print(tensor),float(tensor),if tensor > 0:— forces a full pipeline sync. Once per step is tolerable; three per step (plus an extra forward pass for a training-batch "accuracy") serializes GPU and CPU. - Diagnosis procedure to narrate: (1)
nvidia-smiutilization — low means the GPU is starving or syncing; (2)torch.profilerone epoch — look at what dominates:DataLoader.__next__(input pipeline, see Problem 42) vs sync ops vs kernels; (3) fix the biggest block, re-measure. Perf debugging is measurement-driven, exactly like correctness debugging. - Cheap wins list: accumulate loss as a tensor and
.item()once per epoch (or every N steps), compute train metrics from the logits you already have (no second forward), log every 50 steps,num_workers>0,pin_memory=Truewithnon_blocking=Truetransfers.
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
- "The GPU is a remote server you send work to" is the mental model; every value read is a round-trip. The same intuition explains why
if loss.isnan():every step is expensive and why device-side accumulators are the pattern for metrics. - Loud device bugs have quiet cousins: a model with a buried CPU-resident buffer (tensor created in
forwardwithoutdevice=x.device),.to(device)returning a new tensor that someone forgot to assign (x.to(device)vsx = x.to(device)) — the latter is a top-5 beginner bug worth naming.
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
- Each step is slower than the previous one; memory grows linearly until either OOM or
RuntimeError: Trying to backward through the graph a second time(once a previous segment's buffers were freed by the first backward). - Verified: the carried hidden state has
grad_fnset — it is a live graph node, not a plain value.
Diagnostic reasoning
- The hidden state returned by the LSTM is part of batch N's autograd graph. Feeding it into batch N+1 splices the graphs together, so
backward()on batch N+1's loss traverses back through batch N, N−1, … — quadratically growing work and memory, and a double-backward error once earlier buffers are freed. - The intended semantics is truncated BPTT: carry the value of the hidden state (the model stays stateful) but cut the gradient at the batch boundary. That's precisely
h.detach(). - Fingerprints to check on any stateful loop: per-step wall time increasing, memory increasing, and
state.grad_fn is not Nonewhere you expected a plain value. The identical disease appears in non-RNN settings: EMA teacher outputs carried with graphs, cached embeddings reused across steps, replay buffers storing un-detached tensors (RL's version of this bug).
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
- The rule generalizes: any tensor that outlives its training step must be detached (and usually cloned or moved) at the boundary — logging accumulators (Problem 8C), replay buffers, teacher targets, cached keys/values.
- Follow-up you may get: what does truncation cost? Gradients can't credit dependencies longer than the chunk — motivate longer chunks, or architectures (transformers with long context, state-space models) that sidestep BPTT truncation.
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
- Serving predictions are near-random despite perfect offline metrics; worse, they're plausibly wrong (no crash, no NaN). Per-city error analysis shows the mapping city→prediction is scrambled versus offline.
- After the "fix," rows with new cities get code −1 — a value the model interprets via whatever it learned near the extreme of the feature range (tree splits behave arbitrarily; embeddings index-error or wrap).
Diagnostic reasoning
- An encoder is learned state, exactly like model weights: fit once on training data, serialize with the model, load at serving. Refitting at serving builds a different code assignment whenever the request batch's category set differs — which is always.
- Detection method worth narrating: feature parity testing — run the same raw rows through the offline pipeline and the serving pipeline and diff the feature vectors. Train/serve skew hides wherever the two paths are implemented twice.
- Unseen categories are a modeling decision, not an error-handling afterthought: reserve an explicit UNK code that exists during training (e.g., map rare training categories to UNK so the model has actually learned it), or use encodings robust to novelty (hashing, target encoding with a prior, learned embeddings with an OOV row).
- Also flag
LabelEncoderitself for features: it implies a spurious ordinality (Austin=0 < Boston=1). Fine for tree models, misleading for linear/NN —OneHotEncoder(handle_unknown="infrequent_if_exist")or embeddings are the deliberate choices.
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
- The bundle principle: model + every fitted transformer + feature list + version travel as one artifact (sklearn
Pipelineinside the pickle, or a model registry entry). Two implementations of one pipeline is the root cause of most train/serve skew. - The categorical-drift monitoring hook: log the rate of unseen/infrequent categories at serving — a spike is an upstream schema change announcing itself.
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
- Bug 2: parameter count jumps by
vocab × d(~38M for a 50k vocab, d=768) — nobody notices because nobody rechecks the count. Fine-tuning behaves slightly differently from the reference implementation; output logits drift from input embeddings over training; checkpoint size grows. - Order-of-operations variants (init after tying, or
resize_token_embeddingsbreaking the tie in older library versions) produce a model that trains but underperforms the tied baseline.
Diagnostic reasoning
- Weight tying is an aliasing invariant:
lm_head.weight is embed.weightmust beTrue— one tensor, two references. Any op that replaces either reference (nn.Parameter(w.clone()), re-init, naive resize, someload_state_dictpaths) silently breaks it, and nothing errors because an untied LM is a perfectly valid model. - The two-line detector belongs in a unit test:
assert model.lm_head.weight.data_ptr() == model.embed.weight.data_ptr()— run after init, after checkpoint load, after any resize. Also assert the expected total parameter count; tying bugs move it by exactlyvocab × d. - Interview depth: why tie at all — parameter savings, and the regularization view (input and output token representations share geometry); also when not to tie (models where embedding and unembedding scales want to differ — some modern LMs untie deliberately). Knowing it's a choice, not a law, reads as experience.
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
- Aliasing invariants exist elsewhere: shared encoders in siamese/two-tower models (accidentally deep-copied → towers drift), LoRA applied to one alias of a shared weight, and optimizers double-counting a tied weight if it's added to the param list through both names (most optimizers dedupe by identity — but only if the tie is intact).
- After any structural surgery (resize, prune, quantize, load), re-run the invariant checks. Structural invariants don't survive by default; they survive by assertion.
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
- Bug 1: the notorious
CUDA error: device-side assert triggered— an out-of-bounds embedding index, reported far from the offending op and with a uselessly generic message. - Bug 2: training limps: whenever
<tool_call>appears, loss spikes; early in fine-tuning the model occasionally emits the new token in bizarre places (its random output embedding happens to have high dot-product with common hidden states).
Diagnostic reasoning
- First move on any device-side assert: rerun on CPU (or with
CUDA_LAUNCH_BLOCKING=1) to convert it into a readable stack trace with the real op. Nine times out of ten in NLP it's an id ≥ embedding rows: checkmax(input_ids)vsmodel.get_input_embeddings().num_embeddings— a one-line diagnosis. - Root cause is a contract between tokenizer and model:
len(tokenizer)and the embedding matrix must agree. Any vocab surgery (added specials, merged tokenizers, chat-template tokens) requires the resize on the model side — and re-tying if weights are tied (see Problem 35). - Bug 2 is subtler: fresh rows are drawn from the init distribution, which may be far from the trained embedding geometry. Best practice is to initialize new token embeddings to the mean of existing embeddings (or the embedding of a semantically similar token) so they start "in distribution"; some libraries now do mean-init by default — check, don't assume.
- Also verify the data side of the contract: the pad token you added must actually be used by the collator, and label positions for it set to −100 (Problem 14/21's discipline).
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
- "Device-side assert" fluency is a mini-skill interviewers notice: OOB embedding ids, OOB class ids in CrossEntropyLoss (label ≥ num_classes, or a stray −1 label without
ignore_index), and mask/index dtype issues cover ~90% of cases. - Vocab contracts recur at serving: a quantized or exported model with the old vocab paired with the new tokenizer reproduces Bug 1 in production. Version the tokenizer with the model artifact (Problem 34's bundle principle, LLM edition).
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
- Bug 1: sample-generation callbacks during training crawl; or, in older stacks, incorrect gradients when the cache interacts with recompute.
- Bug 2: loss.backward() either errors immediately or — the dangerous variant — the checkpointed segment's parameters silently receive no gradients (the Problem-8B fingerprint:
p.grad is Nonefor exactly the wrapped blocks). - Bug 3: gradients are wrong, not absent: the recomputed forward takes a different branch than the original, so activations used in backward don't match the graph. Loss curves get subtly noisier; hard to attribute.
Diagnostic reasoning
- Checkpointing's contract: the recomputed forward must be bit-identical to the original. Anything violating that — python-level RNG,
random/np.randomcalls, time-dependent branching, in-place mutation of module state, non-reentrant-unsafe side effects — breaks gradient correctness silently. PyTorch preservestorch's RNG state across recompute (dropout is safe); it cannot know about python'srandom(Problem 9's global-RNG lesson in a new costume). - The audit for a checkpointed block: read its forward for (a) non-torch randomness, (b) data-dependent control flow that could flip between passes, (c) side effects (appending to lists, updating buffers).
use_reentrant=False(the modern recommended mode) fixes most of Bug 2's class: it supports inputs without grads, keyword args, and nested structures; the legacy reentrant mode requires at least one input withrequires_grad=Trueand fails in quiet ways otherwise. Always pass the flag explicitly.- Bug 1 is a compatibility matrix issue: recompute assumes re-running the block from its inputs is valid, but a cache makes the block stateful across calls. Frameworks resolve it by disabling the cache during training — fine — but you must know that's happened when you interleave
generate().
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
- Memory-optimization features compose badly by default: checkpointing × KV-cache, checkpointing ×
torch.compilegraph breaks, FSDP × checkpointing wrapping order. The senior-level skill is knowing that each feature has a contract and checking pairwise compatibility rather than stacking flags until OOM goes away. - Verification pattern: run one batch with and without checkpointing and assert
torch.allcloseon gradients (loose tolerance for recompute-order float noise). Ten lines, catches Bugs 2 and 3 categorically.
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
- Chain of causes: (1) the optimizer's param groups were built from all parameters and don't care about later
requires_gradchanges; (2)set_to_none=Falseleavesgrad = 0tensors on frozen params, so the optimizer treats them as participating with zero gradient; (3) AdamW applies weight decay decoupled from the gradient —p ← p·(1 − lr·λ)— so a participating param with zero grad still decays every step. Three individually reasonable lines compose into "frozen params drift." - Detection: checksum "frozen" parameters at epoch boundaries (
p.sum()per module) — the drift is unmistakable. This check belongs in any fine-tuning harness. - Related trap in the same family: freezing by wrapping forward in
torch.no_grad()(Problem 8B) freezes gradient flow through the module too, breaking training of anything upstream of it;requires_grad=Falsefreezes the parameters while letting gradients pass through to earlier trainable layers. Know which of the two effects you want. - BatchNorm footnote that costs real accuracy:
requires_grad=Falsedoes not freeze BN running statistics — the frozen backbone's BN stats keep updating on the new domain unless the BN modules are put ineval()mode. "Frozen" has three independent levers: params, buffers, mode.
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
- The interview-grade summary: "requires_grad controls gradient computation; the optimizer's param list controls gradient application; module mode controls buffer updates. Freezing correctly means aligning all three."
- This is also why LoRA-style adapters are operationally safer than partial freezing: the base weights are never in the optimizer at all, so no decay/momentum path can touch them.
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
- Bug 1: crash at exactly one prompt length — the classic off-the-cliff failure (
device-side assert, see Problem 36's debugging recipe). - Bug 2: no crash; the assistant "forgets its instructions" only on long conversations — because the system prompt was truncated away. Support tickets say "the model ignores the rules sometimes."
- Bug 3: no crash; perplexity is fine up to 4k then degrades sharply; generations beyond the trained window become repetitive or incoherent. Benchmarks at short context look perfect.
Diagnostic reasoning
- Three different failure shapes from one root concept — positions are part of the training distribution: hard capacity limit (learned absolute), silent content loss (naive truncation policy), and out-of-distribution extrapolation (RoPE beyond trained length). Identifying which shape you're seeing localizes the bug: crash at fixed length → indexing; instruction-following degrades with conversation length → truncation policy; quality cliff at a specific length with no crash → extrapolation.
- For Bug 2, the fix is a policy, not a slice: preserve the system prompt and recent turns, drop the middle (or summarize it). Any
[-N:]slice on a chat transcript is a red flag in code review. - For Bug 3, editing config numbers doesn't retrain the model. Legitimate extension paths exist — position interpolation, NTK-aware/YaRN scaling, continued pretraining at longer context — but all involve doing something, and each has a characteristic quality/perplexity signature you should evaluate at multiple lengths (e.g., needle-in-a-haystack retrieval across positions), not just at the maximum.
- Quick eval to narrate: perplexity (or retrieval accuracy) as a function of position — flat = healthy; cliff at trained length = extrapolation; degraded early positions after truncation = policy bug.
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
- "The config said 8192" is a special case of a broad senior lesson: configuration describes the artifact; it doesn't change it. The trained context window, like the tokenizer vocab (Problem 36), is a property of the weights.
- Adjacent bugs: sliding-window attention serving code that mismatches the training window;
position_idsrestarting at 0 after truncation while the KV cache holds old positions (Problem 15 crossover); and attention sinks — dropping the first tokens degrading streaming generation, the research-flavored follow-up.
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
- Verified: over a 50k vocab in fp16,
log(softmax(x))contains-inf(tail probabilities underflow fp16's ~6e-5 minimum normal), whilelog_softmaxof the same logits has none. One −inf in a KL or ratio turns the batch loss NaN — but only on some batches, so training NaNs intermittently, the most annoying failure schedule. - Bug 2: sequence-level importance ratios explode or hit exactly 0; PPO/GRPO clipping then silently makes gradients zero for those sequences.
Diagnostic reasoning
- Two composable rules. Rule 1: never leave log space.
log_softmaxcomputesx − logsumexp(x)— stable by construction;log(softmax(x))materializes tiny probabilities and destroys them. Same forlog(sigmoid)→logsigmoid(Problem 4's family, now at 50k-vocab scale where it always triggers). - Rule 2: precision-sensitive reductions run in fp32. Autocast handles matmuls; reductions over long sequences and softmaxes over huge vocabs deserve explicit
.float()upcasts. bf16 changes the failure mode, not the rule: bf16 has fp32's range (no −inf from underflow at these scales) but only ~3 decimal digits of precision — logprob differences between policy and reference (the whole signal in DPO/PPO) drown in rounding noise. fp16 fails loudly (inf/NaN); bf16 fails quietly (noisy, biased estimates). Knowing which failure belongs to which dtype is the interview differentiator. - The gather-before-log trick: you rarely need the full
(B, L, V)logp tensor —logp.gatherafterlog_softmaxis fine, but computing per-token logps vialogits.gather(-1, ids) − logsumexp(logits, -1)avoids materializing the full log-prob tensor at all (memory: L×V → L). - For ratios: keep everything in log space until the last moment, and clamp there:
torch.exp((logp − ref).clamp(-20, 20))— a bounded ratio is an explicit modeling decision (PPO clips anyway), unboundedness is just a bug.
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
- Log-space discipline is a system property: one function returning probabilities instead of log-probs reintroduces the bug at the interface. Type-annotate/naming-convention it (
logp_,logits_prefixes) — the cheapest correctness tooling there is. - Where this bites in practice: distillation KL over teacher logits, entropy bonuses in RL, contrastive InfoNCE denominators, mixture-model responsibilities. All are logsumexp in disguise; say the word "logsumexp" early in the interview.
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
- Training loss looks better than it honestly is (extra context helps prediction), and models occasionally generate abrupt topic continuations reminiscent of "another document's" style mid-output. Downstream evals underperform what the loss curve promised — the gap fingerprint.
- After fixing packing at train time but not eval time (Bug 3), the model "regresses" on the perplexity dashboard while actually improving — causing a rollback of a good change. Metrics bugs cause bad decisions, not just bad numbers.
Diagnostic reasoning
- Packing is a memory-layout optimization that must be semantically invisible: each document should train exactly as if alone in the row. That requires (a) block-diagonal causal masking within the row (each doc attends only to itself), and (b) loss masking so no token's target is the first token of the next document, and (c) position ids restarting per document (RoPE/absolute positions should reflect within-doc position, not within-row).
- Modern kernels support this efficiently (FlashAttention's varlen interface with cumulative sequence lengths) — the excuse that block-diagonal masks are slow is outdated; know the mechanism by name.
- How you'd detect it, which is the interview question: construct a probe — pack a row as [secret document][query document asking about the secret] and check whether the model's next-token distribution on the query leaks the secret. Direct behavioral evidence beats staring at masks. Cheaper unit test: assert the attention mask's block-diagonal structure for a synthetic 2-doc row.
- Judgment layer: some labs deliberately train with cross-doc attention (it's cheaper, and at scale the damage is debated) — the bug is not knowing which regime you're in, and above all evaluating in a different regime than you train/serve (Bug 3).
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
- The eval-twin principle: every train-time data transformation needs an eval-time decision — same transform, or explicitly not — made on purpose. Silent asymmetry between the two produced the rollback-of-a-good-change failure, which is among the most expensive bug outcomes in ML.
- Packing has siblings: example-level contamination in packed fine-tuning (two chat examples in one row seeing each other's answers), and "loss on the EOS token" choices that change whether models learn to stop (connects to Problem 21).
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
- Step time 1.9s of which the model accounts for 0.2s;
nvidia-smishows GPU utilization sawtoothing 0–90%; scaling from 1 to 8 GPUs yields nearly zero speedup (they all starve on the same pipeline). Nobody suspects the dataloader because "the code runs."
Diagnostic reasoning
- The diagnostic ladder to narrate: (1) is it input-bound? Time
next(iter(loader))alone vs a full step; or run the model on a single cached batch in a loop — if that's 10× faster, the pipeline is the bottleneck, full stop. (2)torch.profiler(or even py-spy) to see which part: file IO, decode, tokenize, collate, H2D copy. (3) Fix in order of measured cost. - Bug 1 is an algorithmic accident: O(shard_size) IO per sample — random access into line-oriented files requires an index (offsets), a random-access format (arrow/parquet/memory-mapped tokenized bins), or sequential IterableDataset consumption.
- Bug 2: tokenization is deterministic — do it once, offline, and store token ids (this is why pre-training stacks tokenize to binary shards). Rule: anything deterministic and per-epoch-identical belongs in preprocessing, not
__getitem__; only true randomness (augmentation) must stay online. - Bugs 3–4: workers parallelize and prefetch (
num_workers,prefetch_factor,persistent_workers); pinned memory +non_blocking=Trueoverlaps the H2D copy with compute. These are the standard knobs, but state the reason — overlap — not just the flag names. - Distributed corollary: an input-bound job scales embarrassingly badly — 8 GPUs at 12% utilization looks like a model problem but is one shared filesystem problem.
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
- Performance debugging is the same discipline as correctness debugging: hypothesis → cheapest decisive measurement → fix → re-measure. The single cached-batch experiment is the "overfit one batch" of performance work.
- Know the canonical stack for scale: offline tokenization to binary, memory-mapped reads, sequential sharded access per worker, and (beyond this problem) sample-level shuffle via shuffle buffers rather than global random access — each replaces a random-IO pattern with a sequential one.
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
- Bug 1: fine-tuning underperforms full fine-tuning far more than LoRA papers suggest; loss plateaus early. Everyone blames the rank.
- Bug 2: the served model behaves differently from the evaluated one — sometimes better on the target style (over-applied adapter), usually worse and weirder. Verified: merging twice shifts outputs by ~0.43 in a toy layer where a correct merge matches exactly.
- Bug 3: the merged model behaves like a much weaker fine-tune. Verified: with
alpha/r = 8, the properly scaled update has norm 0.97 vs 0.12 unscaled — an 8× under-application.
Diagnostic reasoning
- The merge invariant to state and test: forward(merged) must equal forward(base + adapter) on a fixture batch, to float tolerance. One assert catches double-merges, missing scaling, wrong target modules, and dtype-of-merge bugs all at once — it's the LoRA analogue of the cached/uncached decoding test (Problem 15).
- LoRA's effective update is
(alpha/r)·B@A. Two consequences interviewers probe: changingrwithout changingalphachanges the effective scale (which is why rank sweeps that hold alpha fixed confound rank with scale — rsLoRA-stylealpha/√rscaling exists for this reason), and any hand-rolled merge must include the factor. - Target-module choice is a capacity decision: Q/K-only adapts where to attend but barely what to compute; common practice adapts all attention projections + MLP layers. The diagnostic when LoRA underperforms: print trainable-parameter percentage and the module list — a 30-second check before touching hyperparameters.
- Merge idempotence:
merge_and_unloadmutates state. Any retry/reload path that can call it twice needs a guard (check whether adapters are still present before merging). State-mutating exports are Problem 35's aliasing lesson in adapter form.
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
- Also in this family: training LoRA in bf16 but merging in fp16 (precision loss concentrated in the update), loading an adapter onto a different base checkpoint than it was trained on (nothing errors; quality quietly degrades — pin the base model hash in the adapter card), and serving stacks that hot-swap adapters but share a contaminated KV cache across them (Problem 62 crossover).
- The learning-rate follow-up: LoRA typically wants ~10× the full-fine-tune LR (only the small adapters train); copying an SFT config's LR into a LoRA run and concluding "LoRA doesn't work" is a standard failure story.
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
- The first ~50 steps each take 30–90 s (compilation), then it happens again whenever a new sequence length appears; total wall-clock is worse than eager. Logs show
torch._dynamo hit config.cache_size_limitand the model silently falls back to eager — so after all that compiling, you're not even running compiled code. - Profiles show many small graphs instead of one big one (graph breaks at
.item(), python control flow, logging calls) — compile "works" but delivers a fraction of the expected speedup.
Diagnostic reasoning
- The mental model:
torch.compiletraces specialized graphs guarded on properties like shapes. Every unseen dynamic property → new compile; every untraceable construct → graph break (the function splits into compiled fragments joined by eager python). Neither errors — the failure mode is performance, so the diagnostics are compile logs, not stack traces. - The three-step diagnosis to narrate: (1)
TORCH_LOGS=recompiles(ortorch._dynamo.explain(model)(...)) — why is it recompiling? Almost always: varying shapes, varying python scalars captured as constants, orcache_size_limitexhaustion. (2)TORCH_LOGS=graph_breaks— where does tracing stop?.item(), prints/logging on tensor values, data-dependent branching, unsupported ops. (3) Fix the top offender, re-measure — same measurement-driven loop as Problem 42. - Shape churn has two standard fixes: bucketed/padded shapes (pad to multiples of 128, cap distinct lengths) or
dynamic=True/mark_dynamicto compile symbolic-shape graphs — each trades peak speed vs compile count; knowing the trade-off is the senior signal. - Bug 2's subtlety: the branch needs the loss value on CPU — inherently a sync and a graph break. Move spike detection out of the hot path (check every N steps, or accumulate on-device and inspect asynchronously). Bug 3: vectorize with masks (Problem 30's masked-mean pattern) — per-sample python loops defeat both the compiler and the GPU.
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
- Compiled training adds a new bug category: correct-but-slow silent fallbacks. Treat compile like a dependency with a health dashboard — compile count, graph-break count, and a compiled-vs-eager parity check on one batch (outputs must match to tolerance; occasionally they don't, and that's a real minimizable bug report).
- The same specialization mindset transfers: CUDA graphs (static shapes/addresses required), ONNX/TensorRT export (traced control flow frozen at export time — Problem 34's serving-skew cousin), and JAX's
jit(recompilation on shape/dtype change is the same phenomenon with different spelling).
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
- Quality is mysteriously a few points worse than the same recipe run by a colleague; nothing crashes. First-token behavior is erratic at inference (the model expects
<s><s>but gets one<s>, or vice versa). With Bug 3, the model sees<s>mid-context — a token that in pretraining always meant "document start" — and treats the user turn as a fresh document, sometimes dropping the system prompt's influence entirely (Problem 39 Bug 2's symptom via a different route).
Diagnostic reasoning
- The whole bug class has a single detection method, and it's the one to lead with: decode and read the first and last 10 tokens of real training examples and real serving requests, side by side.
tokenizer.convert_ids_to_tokens(ids[:10])makes double-BOS, missing-BOS, and mid-sequence-BOS instantly visible. This is Problem 21's read-your-examples discipline, focused on the token level. - The mechanism:
apply_chat_templaterenders special tokens as text, and then string-level tokenization appliesadd_special_tokens=Trueby default — two independent layers each "helpfully" adding BOS. Whether they compose correctly differs per model family (some templates include BOS, some don't; some tokenizers add it, some don't) — which is why the rule is verify per model, never assume from the last project. - The invariant to encode in CI: for each (template, tokenizer) pair, assert the rendered id sequence has exactly one BOS at position 0, EOS where the template intends, and zero special tokens elsewhere. Three asserts; catches all three bugs and their permutations.
- Concatenation (Bug 3) is the token-level version of Problem 21's boundary re-tokenization: build sequences either entirely in string space (one final encode) or entirely in id space (
add_special_tokens=Falseeverywhere, specials placed explicitly) — never mixed.
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
- Special tokens are load-bearing distribution markers: pretraining gave BOS/EOS strong meanings (document boundaries), so misplacing them is a semantic corruption, not a formatting nit. That's why a two-token bug moves benchmark scores.
- Same family:
add_special_tokensdefaults differing between__call__,encode, andencode_plus; fast-vs-slow tokenizer discrepancies for the same model; whitespace-sensitive tokens (" A"vs"A", Problem 48); and template version drift between the training repo and the serving image — pin the chat template file in the model artifact (bundle principle, third appearance).
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
- Training runs for hours at ~70% memory, then OOMs at a reproducible step — exactly where the length-sorted loader reaches the long tail. Verified with a simulated length distribution: random batching's worst batch pads to ~65k tokens; sorted data's final batches hit the same ~65k but only at the end — memory demand is back-loaded, so the failure is delayed and looks "random" unless you connect step number to data order.
- After adding eval-during-training, OOMs happen right after eval epochs (fragmentation + retained eval allocations shrink the largest contiguous block).
empty_cache()"fixes" it for a few steps, then it returns — with gradient accumulation now silently corrupted by the skipped batch.
Diagnostic reasoning
- First move: turn "random OOM" into a deterministic one. Log
(step, batch_max_len, batch_tokens, memory_allocated, memory_reserved)every step; the OOM step's batch stats almost always explain it. Activation memory for attention-era models scales withbatch × seq_len(andseq²for naive attention) — the token budget, not the example count, is the resource. Fixedbatch_sizewith dynamic padding means unbounded token budgets. - The structural fix is token-based batching: cap
batch_tokens = Σ padded_len ≤ B_tok(length-bucketed sampling gets the efficiency win of sorting without the back-loaded blowup). Also capmax_lengthexplicitly — one 100k-token outlier document should be truncated or dropped by the pipeline, not discovered by the allocator. - Fragmentation literacy:
memory_allocated(tensors) vsmemory_reserved(allocator pool) diverging, plus OOM messages citing "reserved but unallocated" memory, indicate fragmentation — mitigations:PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True, keeping eval shapes consistent, and pre-allocating at the largest shape early (warm-up with the worst-case batch — which also surfaces Bug 1 at step 0 instead of hour six). - Bug 3 is a semantics bug wearing a reliability costume: skipping a batch mid-accumulation desynchronizes the accumulation counter, scheduler, and (in DDP) the ranks — a skipped batch on one rank hangs the collective (Problem 17). Legitimate OOM recovery must be transactional:
zero_grad, restore counters, and skip the whole accumulation window on all ranks — or better, make OOM impossible by construction with token budgeting.
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
- The interview-grade framing: memory bugs are capacity-planning bugs — the fix is to make peak demand a controlled, load-tested quantity (token budgets, worst-case warm-up) rather than a random variable you hope stays under the limit.
torch.cuda.memory._record_memory_history()+ the memory-snapshot visualizer is the profiler-grade tool for the hard cases (leaks vs fragmentation vs genuine peak); naming it signals you've debugged this for real. And the leak checklist is Problem 8C/33's: retained graphs, growing python lists of tensors, hooks that capture activations.
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
- Bug 1: CFG "works" but weakly and artifacts grow rapidly with scale — the model never learned an unconditional distribution, so
model(x, t, null_cond)is an out-of-distribution query returning garbage; guidance extrapolates along a garbage direction. - Bug 2: prompts produce images that actively avoid the prompt content (guidance points away from the condition) — a distinctive "anti-prompt" behavior at high scale.
- Bug 3: images oversaturated/fried at scales that should be safe; the effective guidance is
1+sinstead ofs.
Diagnostic reasoning
- CFG's formula
ε̂ = ε_u + s·(ε_c − ε_u)requires both endpoints to be in-distribution: the model must have been trained with condition dropout (typically 10–20% null-condition) so the unconditional branch is meaningful. No dropout at train time → Bug 1. This is a training-time prerequisite for a sampling-time feature — a contract spanning the codebase, like Problems 18/20. - The swap bug is a batching-convention bug (Problem 13's "state the convention" lesson): batched cond/uncond passes have an implicit order contract between three code sites (the concat, the chunk, the formula). Detection: sample with
scale=0(should equal pure unconditional) andscale=1(should equal pure conditional). These two identity tests pin down ordering and formula bugs in minutes — narrate them; they're the interview answer. - Bug 3 is caught by the same
scale=1identity: the buggy branch returnsε_c + (ε_c − ε_u)≠ε_c. - Also check
null_conditself: it must be the same null embedding used during training dropout (e.g., the empty-string text embedding for SD-style models), not a zeros tensor — zeros may be out-of-distribution for the text encoder's output space.
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
- Guidance-scale sweeps are a diagnostic instrument: quality-vs-scale should rise then gently degrade. Anti-prompt behavior → ordering; frying at low scales → formula; weak response + rapid artifacts → missing train-time dropout; no response at all → condition not actually reaching the model (e.g., cross-attention weights frozen or cond tensor detached).
- Follow-ups to expect: why high scale hurts (extrapolation off the data manifold; mean shift — hence rescaling tricks like dynamic thresholding / CFG rescale), negative prompts (replace the null embedding), and guidance on x0- vs ε-space for different parameterizations (Problem 18 crossover).
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
- Bug 1: reconstruction improves only via the decoder learning to ignore z's noise; the encoder's
mu, logvarreceive gradients only through the KL term, which pushes them toward the prior — the posterior collapses to N(0, I), z carries no information, and samples from the prior look like a blurry dataset average. Training loss still decreases (the decoder gets better at unconditional reconstruction), hiding the bug. - Bug 2 (even with rsample): with per-pixel mean reconstruction vs summed KL, the KL term is effectively weighted thousands of times too heavily → the same posterior-collapse presentation, from a pure bookkeeping mismatch. (The mirror error — summed recon vs mean KL — yields the opposite: near-deterministic autoencoder, garbage prior samples.)
Diagnostic reasoning
- The reparameterization trick is not an implementation nicety; it is the gradient path:
z = μ + σ·εmakes z a differentiable function of (μ, σ) with the randomness externalized into ε..sample()cuts that path (it's implemented under no-grad);.rsample()— the "r" is reparameterized — preserves it. Detection is the Problem-8B check: after backward, encoder grads are nonzero but only from the KL term; zero out the KL and encoder grads vanish entirely → the recon path is severed. - Posterior-collapse triage, in order: (1) is the gradient path intact (rsample)? (2) are the two loss terms on the same scale (both summed, or both means with an explicit β)? (3) if both are correct, then it's the modeling phenomenon (too-powerful decoder) — mitigations: KL annealing/warmup, free bits, weaker decoder. Interviewers want the bug ruled out before the modeling fixes are reached — most "posterior collapse" in practice is one of bugs (1)/(2).
- The scale diagnostic: print both loss components. A healthy VAE has them within a couple orders of magnitude; KL pinned near zero from step 1 (collapse) or recon pinned (autoencoder) shows the imbalance immediately.
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
- "Where does the gradient flow through the randomness?" is the transferable question — the same issue appears as the score-function-vs-pathwise choice in RL (REINFORCE vs reparameterized), Gumbel-softmax for discrete latents, and straight-through estimators in VQ-VAE (whose codebook/commitment losses are the discrete cousin of this problem).
- Loss-term bookkeeping (sum vs mean per term) is Problem 29 wearing a generative-model costume; any multi-term loss deserves a components-logged-separately dashboard.
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
- Immediate:
Trying to backward through the graph a second timeon the generator step. - After the usual "fix" (
retain_graph=True— making it run instead of making it right): training limps; the generator receives a mixture of gradients — its own loss plus stale gradients from the discriminator's loss pass (which pushed G in the direction that makes fakes easier to detect). Mode collapse and D/G oscillation follow, and everyone blames "GANs being GANs."
Diagnostic reasoning
- The invariant to state: each optimizer step must apply gradients from exactly one intended loss.
d_loss.backward()traverses every graph reachingd_loss— including throughfakeinto G — populatingG.parameters().grad. Nothing errors; G'szero_gradhappens after in this loop ordering only if you're lucky. The composed failure is invisible in code review unless you trace where each backward reaches. retain_graph=Trueappearing in a diff is a review red flag ~90% of the time: it usually papers over an unintended graph-sharing bug rather than expressing a real need (the legitimate uses — multiple backward passes over a genuinely shared forward — are rare and deserve a comment).- The audit tool: after each
backward(), check which parameter groups have nonzero grads ([n for n,p in named_parameters() if p.grad is not None and p.grad.abs().sum() > 0]). In a clean GAN loop, the D step touches only D. Ten lines; categorical detection. - Interview extension — the same discipline appears wherever two objectives share a graph: actor-critic (critic loss leaking into actor), adversarial domain adaptation (the gradient reversal layer is this problem solved intentionally), and RLHF policy/value heads on a shared trunk (decide and document which losses update the trunk).
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
detach()is a statement about training semantics — "this tensor is a constant for this objective" — not a memory trick. Reading a training loop means reading where every backward reaches; writing one means placing detaches so each loss's reach matches intent.- Adjacent GAN-loop bugs worth naming: updating D's BatchNorm stats on fake batches in eval-ish phases, using one
zero_gradfor both optimizers, and TTUR/lr-ratio issues masquerading as instability. The meta-lesson: "GAN instability" is frequently a plumbing bug wearing a research costume.
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
- Bug 2 is the striking one: training loss decreases and plateaus at a mediocre value; samples are uniformly blurry at every noise level. The model, blind to t, learns the single best t-independent denoiser — an average over noise levels — which is exactly a blur.
- Bugs 1/3: training and sampling disagree about what "t=500" means; sample quality is poor in a way that resists hyperparameter tuning, because the network is being queried at embedding coordinates it never trained on.
Diagnostic reasoning
- The t-blindness test (decisive, 5 minutes): feed one fixed
x_twith several different t values and diff the outputs. Identical outputs ⇒ t never reaches the computation (Bug 2 — the embedding is computed and orphaned; autograd won't complain about an unused subgraph, andtime_mlpweights simply stay near init, which is itself a detectable fingerprint via the Problem-8B gradient audit). - Scale mismatches (raw 0–999 vs normalized 0–1) are the conditioning version of Problem 24's preprocessing skew: sinusoidal embeddings are periodic, so wrong input scale aliases different timesteps onto near-identical embeddings (t and t+2π/f collide). The contract — what units does the model expect t in — must be owned by one function used by both trainer and sampler.
- The general principle across all three: conditioning is only real if it changes the output. For any conditioning signal (t, class label, text embedding), the sensitivity test — vary the condition, hold everything else — belongs in the unit-test suite. It catches orphaned embeddings, detached conditions, zeroed cross-attention, and scale mismatches in one shot.
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
- "Computed but never used" bugs are refactor-borne and review-resistant — autograd's silence about unused subgraphs is a feature that hides them. The two systematic detectors: gradient audits (unused modules have
grad=None/near-init weights) and behavioral sensitivity tests. - Same disease, other hosts: class-conditional embeddings concatenated then sliced off by a shape fix; text cross-attention whose weights were accidentally frozen; FiLM/adaLN parameters computed from a condition that was zeroed by a collator default. The sensitivity test catches the whole family.
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
- Bug 1+2 in combination:
strengthbehaves like a broken dial — low values, which should return near-copies of the input, instead return unrelated images (the input was noised to pure noise, destroying it), while the truncated low-noise schedule can't rebuild structure, yielding smeary half-images. - Bug 3: inpainted regions look fine in isolation but the boundary shows halos/ghosting, and the fill doesn't harmonize with the surrounding image's lighting — the model was denoising a hybrid whose known region had wrong noise statistics for the current t.
Diagnostic reasoning
- img2img is one idea: skip the early part of the reverse process by starting from
x_{t_start} = q_sample(x0, t_start)and denoisingt_start → 0. Strength maps to how much of the schedule you skip. Two coupled invariants: the noising timestep must equal the first denoising timestep, and the remaining schedule must be the segment[t_start … 0]. Bugs 1 and 2 each break one; the test that catches both:strength=0.05must return a near-identical image (round-trip fidelity test),strength=1.0must match text-to-image from pure noise. Two endpoint tests pin the whole dial. - Bug 3's principle: at every step, everything the model sees must have the statistics of timestep t. The known region must be
q_sample(z0, t)— the clean latent renoised to the current level — not cleanz0. Composite-with-clean creates an input off the training distribution, and the model "repairs" the discrepancy by blending — hence halos. (This is Problem 18's contract — the sampler owns the statistics — applied spatially.) - Also on the checklist: the mask must be resized to latent resolution with the right interpolation (hard masks need nearest/max-pool — bilinear creates a soft seam of half-noised pixels), mask polarity documented (1 = inpaint vs 1 = keep flips per library; Problem 13's convention lesson), and for VAE-based models a final pixel-space composite of the untouched region (the VAE round-trip subtly alters "kept" pixels otherwise).
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
- The two endpoint tests (strength≈0 → identity, strength=1 → t2i parity) generalize into a habit: every continuous control knob deserves boundary-behavior tests, because knob bugs present as "it sort of works" at middle values where nobody can tell.
- Follow-ups this territory invites: why dedicated inpainting models (mask as an input channel) beat composite-based inpainting at seams; SDEdit as the theory behind img2img; and per-region prompt guidance — each builds on the same "statistics of timestep t" contract.
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
- Bug 1: verified — with a hard codebook lookup the encoder's gradient is
None(theargminindexing severs the path; only the codebook entries that were selected receive gradients). Training "works" — the decoder learns to decode whatever the frozen-at-init encoder maps to — reconstruction plateaus high, and the encoder never trains. The Problem-8B audit (which parameters have grads?) finds it in one pass. - Bugs 2–3: codebook usage collapses — a histogram of
idxshows 4 of 1024 codes carrying 95% of assignments ("codebook collapse"). Reconstructions are blurry-quantized; downstream (a transformer trained on these tokens) has almost no vocabulary to work with, capping the entire multimodal stack's quality.
Diagnostic reasoning
- Three separate gradient paths must each be wired deliberately, and the interview is about naming them: (1) decoder→encoder via the straight-through estimator
z_e + (z_q − z_e).detach()— forward passes quantized values, backward pretends identity (verified: encoder grads nonzero with STE, None without); (2) codebook via‖sg[z_e] − z_q‖²— codes move toward the encoder outputs assigned to them (stop-grad on the encoder side); (3) encoder commitment viaβ·‖z_e − sg[z_q]‖²— the encoder is pulled toward its assigned codes (stop-grad on the codebook side). Drop a stop-gradient and the two sides co-adapt in a feedback loop; drop the commitment and encoder outputs drift between codes, destabilizing assignments. - Codebook collapse's mechanism: codes only receive gradients when selected; unlucky initialization → a few codes near the encoder's output distribution win everything, the rest never update, and rich-get-richer locks in. The
idxhistogram (or codebook perplexityexp(H(usage))) is the one metric that makes all of this visible — it belongs on the dashboard from step 0, and "what would you monitor?" is the actual interview question. - Standard mitigations to name (mechanism, not incantation): EMA codebook updates (replace loss-based code learning with usage-weighted running means — more stable), dead-code reinitialization (respawn unused codes at random encoder outputs), and lower-dimensional/normalized code spaces (FSQ-style schemes remove the learned codebook entirely — knowing why they exist, i.e. this bug class, is the differentiator).
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
- The stop-gradient placements are the algorithm: sg on one side vs the other selects which component adapts — the same design tool as detach-discipline in GANs (Problem 45) and EMA targets in self-supervised learning (BYOL-style collapse when the stop-grad is removed is this bug in another costume).
- Downstream coupling: a collapsed tokenizer silently caps everything trained on its tokens — when a multimodal model underperforms, auditing the tokenizer's usage statistics before the model's hyperparameters is the experienced move (the Problem 48 lesson: interrogate the pipeline stage that produced your inputs).
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
- Users write long, detailed prompts and report that instructions near the end are ignored — style modifiers, "no text in image", aspect details. Support closes tickets as "prompt engineering"; the actual cause is that those tokens never reached the model.
- Subtler: two prompts differing only after token 77 produce identical images (same seed) — the definitive reproduction, and a great interview answer because it converts a vague quality complaint into a deterministic test.
- Padding attention (Bug 2) shows up as prompt-length-dependent style drift: the same content with different padding amounts yields different images, because cross-attention keys include padding-position embeddings (which are not "empty" — they're EOS-propagated states in CLIP-style encoders).
Diagnostic reasoning
- First principle: conditioning pipelines need loud limits. Truncation is a lossy transform applied to the user's intent; doing it silently converts an input-validation issue into a model-quality mystery. Minimum fix: detect and surface (log/warn/UI) when a prompt exceeds the window. This is Problem 39's truncation-policy lesson relocated to the condition path.
- Know the actual mechanics for CLIP-family encoders: the 77-token window is a hard property of the text encoder's learned positional embeddings (Problem 39 again — a property of weights, not config). Long-prompt support is therefore a technique, each with trade-offs you should be able to sketch: chunk the prompt into 77-token windows, encode each, and concatenate the embeddings for cross-attention (what popular UIs do); weighted re-emphasis of chunks; or moving to a long-context text encoder (T5-class) if you own training.
- Whether padding positions should be masked in cross-attention is model-specific — SD-family models were trained attending to the full 77 (padding states carry the EOS summary), so masking at inference is a train/serve mismatch in the other direction. The transferable rule: match the conditioning interface the model was trained with, exactly — and when you don't know, the prompt-length-sensitivity test (same prompt, varying pad amounts) measures whether it matters.
- The differential test discipline: fix the seed and diff outputs across the boundary (76 vs 78 tokens; padded vs unpadded). Deterministic seeds turn conditioning-pipeline bugs into unit-testable behavior — the T2I version of Problem 46's sensitivity test.
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
- "The model ignores part of my input" bugs have a standard triage across modalities: (1) did the input survive the pipeline (truncation, resizing, template)? (2) did it reach the model (Problem 46's orphaned conditioning)? (3) did the model weight it (attention analysis)? Work the list in order — most cases die at step 1.
- Same family: max-length truncation of system prompts in LLM serving, image conditioning center-cropped so the subject falls outside the crop, and audio conditioning windowed shorter than the clip — every conditioning path has a window, and every window needs a policy and a warning.
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
- FID moves when nothing about the model changed — a refactor of image saving (JPEG quality, resize call) shifts FID by several points, larger than the real differences being measured. Conversely, a genuinely better checkpoint scores worse because its eval ran with fewer samples (small-n FID bias is upward and variance is large — ranking flips are routine below ~10k samples).
- Bug 4: cross-repo FID comparisons in a report imply a ranking that neither script alone supports; a reviewer re-runs both models under one harness and the ordering flips (the Problem 48 rollback story, generative edition).
Diagnostic reasoning
- FID is a pipeline metric:
images → decode → resize → normalize → InceptionV3 → Gaussian fit → Fréchet distance. Every stage is a degree of freedom — resize kernel and antialiasing (the notorious one; different libraries' resize implementations measurably shift FID), codec (JPEG block artifacts are visible to Inception), value range/normalization, sample count, and even which Inception weights. Two numbers are comparable only if every stage matches — hence Bug 4's rule: never compare FIDs across harnesses; re-evaluate both models under one pinned pipeline (clean-fid-style standardization exists precisely because of this). - The calibration habit that catches most of it: evaluate real-vs-real first. Split the reference set in half and compute FID between the halves — that's your noise floor at the given n (should be near 0 for large n; if it's 4, differences under ~4 mean nothing). Then run known-degradation sanity checks (add noise/blur to real images — FID must increase monotonically). A metric you haven't calibrated is a random-number generator with units.
- Small-n statistics: FID's estimate is biased at finite n and the bias depends on the model — so comparing models at small equal n is still unsafe. Report n, use the standard 50k where feasible, and prefer paired comparisons under identical seeds. Complement with metrics probing different failure axes (precision/recall for fidelity-vs-coverage, CLIP score for prompt adherence) — FID alone can improve while diversity collapses (mode collapse reduces FID variance terms in misleading ways).
- The generalized lesson, third appearance (Problems 41, 48): eval pipelines are code, with bugs that cause wrong decisions — the most expensive class of ML bug because they compound into every decision made on the dashboard.
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
- The reporting discipline: every generative-metric number travels with its harness fingerprint (n, preprocessing hash, sampler settings, seed policy). A number without its fingerprint is unfalsifiable — and the interview signal is treating that as a correctness requirement, not bureaucracy.
- Human-eval and model-judge pipelines inherit every one of these issues plus their own (judge position bias, instruction leakage); the calibration habit — measure the noise floor, verify known orderings — transfers unchanged, and saying so connects this problem to Problems 48 and 22.
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
- Bug 1: temperature does nothing to the distribution shape (dividing probs by a scalar then renormalizing is the identity) — or, unnormalized as here, silently changes only the effective cutoff of later steps. Users report "temperature has no effect," which sounds like a config bug but is math.
- Bug 3: on peaked distributions (one token at p=0.95), the crossing token gets masked, leaving only tiny-probability tokens → the sampler picks from garbage precisely when the model was most confident: rare but catastrophic wrong tokens in otherwise clean generations — maddening to reproduce because it depends on the distribution's shape.
- Bug 2: after top-k zeroing without renormalization,
multinomialstill works (it normalizes internally) — which masks Bug 2 until someone computes entropy/logprobs from the filtered vector and gets nonsense. A bug hidden by an unrelated API's leniency.
Diagnostic reasoning
- Temperature must scale logits (
softmax(z/T)); it reshapes the distribution. On probabilities it's a no-op post-normalization — state the math, don't just "move the line." - Top-p's definition: keep the smallest set of tokens whose cumulative probability ≥ p — so the token that crosses the threshold is included. The standard implementation shifts the mask right by one (
mask[..., 1:] = mask[..., :-1]; mask[..., 0] = False). The peaked-distribution test (one token ≫ others) is the unit test that catches the off-by-one; the uniform test (all tokens equal) catches boundary handling. - Order matters and is a convention to state: typical stacks apply temperature → top-k → top-p on logits (filtering by setting
-inf, then one final softmax). Filtering in logit space with-infsidesteps renormalization bugs entirely — the structural fix for Bug 2. - Reproducibility note for debugging samplers: fix the seed and compare implementations on the same logits fixture; sampling bugs are distribution bugs, so assert on the filtered distribution, not on sampled tokens.
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
- Decoding is a place where tiny math errors ship because outputs remain plausible — the fingerprint of a sampler bug is statistical (wrong rare-token rate, temperature dead-zones), so the debugging tools are statistical too: entropy vs temperature curves, rank histograms of sampled tokens against the model distribution.
- Adjacent bugs: repetition penalty applied multiplicatively to negative logits (sign flip — it rewards repetition of tokens with negative logits), min-p vs top-p confusion, and applying filters before vs after the repetition penalty — ordering contracts again.
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
- Bug 1: scores biased toward "A"; a model that prefixes answers with "As an AI" or "Alright," gets systematic phantom credit or phantom errors. Accuracy shifts by several points when the answer-extraction regex changes — which should be impossible if the metric measured the model.
- Bugs 2–3: a genuinely stronger model loses the comparison. Decisions (model selection, ship/no-ship) get made on harness artifacts. Later, someone re-runs with a standard harness and the ranking flips — expensively.
Diagnostic reasoning
- Treat the eval harness as code under test: read the transcripts. Sample 20 (prompt, raw output, extracted answer, gold) tuples and eyeball them — extraction bugs, template mismatches, and truncated outputs are all visible in one screenful. This is the eval twin of "read your tokenized examples" (Problem 21), and it's the single highest-yield eval-debugging action.
- Robust extraction is a design choice, not a regex tweak: constrain the output format (e.g., logit-based scoring — compare logprobs of " A"/" B"/" C"/" D" continuations — sidesteps free-text extraction entirely), or use anchored patterns (
^Answer:\s*([ABCD])\b) with an explicit "unparseable" bucket that gets reported, not silently scored wrong. The unparseable rate is itself a metric; rising unparseable = format drift, not capability drift. - Comparisons require a fixed harness contract: same shots, same template policy (each model's own chat template, applied consistently), same max tokens, same stop sequences, same answer extraction. Any comparison across harness settings is a comparison of harnesses. Known sensitivity results (choice reordering, "A/B/C/D" vs "1/2/3/4" labels shifting scores) mean small deltas across settings are noise — report the harness version with the number.
- Bonus depth: logit-based multiple-choice scoring has its own bug class — length-biased continuations (normalize by token count or use answer-letter-only scoring) and tokenizer-dependent leading-space handling (
"A"vs" A"are different tokens).
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
- The meta-skill being probed: numbers are produced by pipelines, and pipelines have bugs — treat a surprising eval delta as a pipeline hypothesis first, capability hypothesis second (the mirror of Problem 2's "too good = leakage" rule; here "too bad = harness").
- Contamination completes the picture: a benchmark score means little without a train-set overlap check (n-gram / embedding dedup against the benchmark). Expect the follow-up "how would you detect contamination?" — answer: overlap scans plus the performance-vs-perturbation test (contaminated models degrade sharply on paraphrased/regenerated variants of benchmark items).
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
- Bug 1: intermittent NaN batches that correlate with easy or impossible prompts (uniform reward within a group) — the correlation with prompt difficulty is the identifying clue, and it worsens as the model improves (more all-correct groups!): a bug whose frequency grows with training success.
- Bug 2: mean response length climbs steadily while pass-rate plateaus; reward-per-token falls. Classic length hacking, here caused by the objective's plumbing rather than the reward model.
- Bug 3: depending on the sign convention of each term, the "KL penalty" either does nothing or actively pushes the policy away from the reference — outputs drift toward reward-hacked gibberish with fluent local structure (Problem 22's failure surface, reached through a one-character bug).
Diagnostic reasoning
- Zero-variance groups are not an edge case; they're the goal state (every group all-correct). Handle them by construction:
adv = (r − mean) / (std + eps), or skip/zero-advantage uniform groups explicitly — and log the uniform-group rate, which is itself a useful training-progress metric. The general rule (Problem 4): every division in a training objective needs a documented reason why the denominator can't be ~0. - Length coupling: with
logp.sum(), a response's gradient magnitude scales with its token count. Choices — mean-per-token logp, length-normalized advantages, or explicit length penalties — differ in subtle ways (and are actively debated); the interview skill is naming the coupling and the monitoring that detects it (length curves, reward-per-token) rather than reciting one fix as gospel. - Sign conventions in RL objectives are where one character destroys the run: write the objective on paper as "maximize E[adv·logp] − β·KL(policy‖ref)", then map each sign to code mechanically, then unit-test directions: construct a toy where the right update is known (single prompt, two responses, one rewarded) and assert the post-step logp moved the right way. Direction tests are cheap and catch sign bugs categorically.
- Also on the checklist for this loop family: advantages must be
detach()ed (no gradient through the reward path), logp masks must cover response tokens only (Problem 22), and the sampling policy must match the updated policy's era (stale off-policy batches without importance correction).
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
- RL objectives concentrate every previous lesson into one loss line: masking (21/22), log-space precision (40), division safety (4), detach discipline (45), and sign conventions (20). Interviewers pick RL debugging questions precisely because they test the whole stack at once.
- The monitoring suite is the real answer: pass-rate, length curves, KL-to-ref, entropy, uniform-group rate, and periodic fixed-prompt generations. In RL fine-tuning, undetected pathologies are the default — the dashboard is part of the algorithm.
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
- The quantized model's perplexity looks acceptable (+0.3) but users report broken code generation and garbled non-English output — damage concentrated where calibration data had no coverage (Bugs 1, 4).
- Bug 3: serving works for days, then specific prompts return degenerate repeated tokens — activation outliers (a known property of large transformer channels) overflow fp16 only on inputs that excite those channels. Intermittent, input-dependent, and invisible in aggregate metrics.
- Bug 2: disproportionate quality loss traced (eventually) to logit distortion — the head's output range is the model's entire decision surface; 8-bit resolution there costs far more than 8-bit resolution in an MLP.
Diagnostic reasoning
- Quantization debugging is layerwise error attribution: compare quantized vs full-precision activations layer by layer on real inputs (cosine similarity / SQNR per layer) and find where error concentrates. The standard findings: norms, the lm_head, and a handful of outlier-heavy channels — which is exactly why practice is to keep norms/head in higher precision and use outlier-aware schemes (the insight behind LLM.int8-style decomposition and activation-aware methods like AWQ/SmoothQuant: a few channels carry the range).
- Calibration data is training data for the quantizer: its activation ranges set the clipping thresholds. Random tokens produce unrepresentative ranges — Problem 24's preprocessing-skew principle, applied to compression. Calibrate on a sample of the actual serving distribution, stratified across the domains you care about.
- bf16→fp16 is a range conversion, not a no-op (Problem 40's table, applied to weights/activations): scan weight/activation maxima before converting; if anything approaches 6.5e4, fp16 is not a valid serving dtype for the model — keep bf16 or apply per-channel scaling.
- The evaluation principle that makes all of this shippable: parity testing. A compressed model is a change, and changes need diff-focused evals: per-domain metrics, worst-case (max logit KL per token) not just average, and golden-prompt regression suites. "+0.3 ppl average" is compatible with a ruined 2% slice that happens to be your most valuable traffic.
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
- Compression bugs are distribution-shift bugs: every one of Bug 1–4 is "the thing you measured/calibrated on ≠ the thing you serve." The transferable procedure — representative calibration, layerwise attribution, per-slice parity evals, worst-case metrics — applies unchanged to pruning, distillation, and even prompt/template changes at serving.
- Closing the loop with Problem 34: the quantized artifact must ship with its tokenizer, template, and preprocessing pinned — compression multiplies the ways a serving stack can skew from the training stack, and the bundle principle is the antidote.
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
- Bug 1: RM scores change when the same response is batched with different-length neighbors (the Problem 13 batch-composition fingerprint, reward edition); training is noisy and accuracy plateaus low.
- Bug 2: the RM reaches 72% held-out accuracy — most of it from the length shortcut. Everything looks fine until PPO/best-of-n optimizes against it: response length explodes, quality craters, "reward" climbs (Problem 22's Goodhart surface, caused upstream in the data).
- Bug 3: nobody sees it coming, because the eval measured interpolation, and optimization probes extrapolation.
Diagnostic reasoning
- Bug 1 is the last-non-pad-token bug: sequence-level scores must be read at each sequence's own final real token (
gatherwith per-row lengths), or the sequence must be left-padded. Detection: score the same response alone vs batched — any difference is a padding/pooling bug. (Same test, third use: Problems 13, 15.) - The shortcut audit is the core skill: before training, measure correlation between the label and cheap features — length, markdown density, list count, hedging phrases. A preference dataset where
len(chosen) > len(rejected)68% of the time will produce a length-reward model regardless of architecture. Mitigations are data-side (length-matched pair sampling, counterfactual pairs where the shorter answer wins) and model-side (length-penalized reward, or reporting length-controlled accuracy: accuracy on pairs where the shorter response is preferred — if that's 45% while overall is 72%, the shortcut is quantified). - Bug 3 is the deepest: an RM's job is to survive optimization pressure, which queries it far off-distribution. The eval that matters is adversarial: best-of-n sweeps (does quality keep rising with n, judged independently?), reward-vs-KL curves during a probe PPO run, and inspecting the top-scoring samples an optimizer finds (they reveal what the RM actually rewards — the fastest way to discover it loves bullet points and apologies).
- The systemic view to volunteer: reward hacking is usually diagnosed downstream in RL (Problem 22) but caused upstream here — data shortcuts, pooling bugs, and interpolation-only evals. Fixing the RL loop can't fix a reward model that measures the wrong thing.
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
- A reward model is a measurement instrument, and instruments are validated by their failure modes, not their fit: shortcut audits before training, invariance tests (padding, batching, paraphrase), and optimization-pressure probes after. This is Problem 12's "metric ≠ objective" and Problem 48's "interrogate the harness," promoted to the component that defines the objective.
- Adjacent bugs worth naming: ties in preference data forced into binary labels (label noise with structure), prompt leakage (RM scores prompt+response so verbose prompts shift rewards), and annotator disagreement discarded instead of modeled — each reappears at RL time as a mysterious pathology.
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
- Bug 1: outputs usually parse, then intermittently contain a stray fragment (
"count": 3egg") — rare, input-dependent, maddening. The failure rate scales with how often multi-character tokens straddle grammar states. - Bug 2: any response with nested objects gets truncated at the first inner
}—json.loadsfails on exactly the complex responses, so error rates correlate with task difficulty and everyone suspects the model. - Bug 3: outputs are syntactically valid but semantically degraded — wrong fields, empty strings, hallucinated enum values. Forcing a low-probability token path is sampling from the model's tail; validity went up, usefulness went down.
Diagnostic reasoning
- The core insight interviewers want stated: grammars are defined over characters; models emit tokens; the mapping is many-to-many. Correct constrained decoding must check whether the entire token string is consumable by the grammar from the current state (advancing the automaton per character), and handle tokens that end mid-state. First-char checks (Bug 1) are the canonical shortcut-that-almost-works. Related boundary cases to name: tokens containing
"or\mid-string, unicode escapes split across tokens, and EOS only being legal when the automaton is in an accepting state — otherwise you get forever-open JSON instead of truncated JSON. - Stop sequences are not parsers (Bug 2): terminating structured output requires bracket-depth tracking or the grammar's accepting state, not a substring match. The test: a fixture set of nested/escaped/long outputs replayed through the termination logic.
- Bug 3 is distribution mismatch, the recurring theme (Problems 21, 43, 57): constraints mask the distribution; they can't add probability mass the model doesn't have. Fixes are alignment-side: few-shot examples of the schema in the prompt, or fine-tuning on schema-conformant outputs — with the constraint kept as a safety net whose intervention rate is monitored. A high mask-intervention rate (grammar frequently overriding the argmax) is the leading indicator of Bug 3's quality damage.
- Debugging procedure for "invalid output" reports: log the raw token ids, the grammar state trace, and the first divergence point. Structured-output failures are automaton traces, not vibes — replayable, minimizable, unit-testable.
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
- The token/character mismatch is a load-bearing abstraction gap that also produces: stop-sequences split across tokens (never matched), streaming UIs cutting multi-byte unicode mid-token, and logit-bias lists that miss the leading-space token variant (Problem 48's
" A"vs"A", fourth appearance — it's that pervasive). - Production framing: validity, quality, and latency are three separate metrics for structured output. Constraint engines fix validity; prompts/fine-tuning fix quality; precomputed token masks fix latency. Conflating them — "we have JSON mode, structured output is done" — is how Bug 3 ships.
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
- Throughput improves as promised, but outputs differ from the target model running alone — with fixed seeds, generation diverges at specific positions. Quality evals drift a fraction of a point; style shifts subtly toward the draft model's habits. Everyone assumed speculative decoding was "lossless" because the paper says so — the implementation isn't.
- Acceptance rate is also oddly low on some prompts (Bug 1's temperature mismatch makes q a poor estimate of the draft's actual proposal distribution), so speedups evaporate exactly on the traffic where the draft disagrees with the target.
Diagnostic reasoning
- The correctness core: speculative sampling is lossless only under the specific accept/reject rule — accept draft token x with probability
min(1, p(x)/q(x)); on rejection, sample from the residualnorm(max(0, p − q)). Bugs 2 and 3 each break the algebra that makes the marginal distribution exactly p. Threshold-based acceptance (Bug 2) over-accepts tokens the draft likes and p tolerates — the output becomes a p/q hybrid. - The invariant test to lead with (fourth appearance of the pattern — Problems 15, 51, 55): with the same seed, speculative and non-speculative decoding must produce identical greedy outputs, and matching distributions under sampling (KS-test token frequencies on a fixture prompt set). Any drift is an implementation bug, categorically. This test is cheap and belongs in CI next to the kernel.
- Bug 1's layer: q must be the distribution the draft tokens were actually proposed from — same temperature, same top-k/top-p filtering, same logit processors as applied to the draft's sampling. Greedy proposals with full-softmax q is a mismatch; every logit processor applied to one side and not the other (repetition penalty is the classic) silently breaks the ratio rule.
- Also in the checklist: tokenizer identity between draft and target (byte-level differences make "the same token" a lie — verify vocab hash equality), KV-cache rollback on rejection (stale cached entries for rejected positions is a Problem 15 crossover), and position-id bookkeeping after partial acceptance.
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
- "Losslessness" is a property of an algorithm, not of a feature name — every serving optimization claiming exactness (speculative decoding, batching, quantized KV cache, prefix caching) deserves a parity test against the unoptimized path, and the parity test is the deliverable alongside the optimization.
- The acceptance rate is the observability handle: track it per domain — a drop flags draft/target drift (e.g., after fine-tuning the target but not the draft), which silently converts your speedup back into latency.
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
- Rare, unreproducible reports: "the assistant answered a different question," or replies that follow the old system prompt's rules for hours after a policy update — the bug only fires on cache hits, so the same request works on retry (cache miss), destroying reproducibility. A fraction of a percent of traffic is affected; dashboards look clean.
- Bug 1's collisions additionally leak context across users — a correctness bug that is also a privacy incident, which changes the severity conversation entirely.
Diagnostic reasoning
- Cache-correctness first principles: a cache key must capture every input that affects the cached value. For KV prefixes that means the exact token ids of the full cached span (not a truncated text hash — Problem 53 taught that text and tokens diverge), plus everything that changes the mapping tokens→KV: model version, adapter/LoRA id (Problem 51 crossover), tokenizer version, even attention-implementation config. The standard robust design: hash the token-id sequence block-wise (radix-tree/paged designs do this structurally).
- The debugging method for "rarely wrong answers" in a caching system: log the cache key, hit/miss, and the id of the entry used, per request — then for any bad-output report, replay with cache off. Output differs cache-on vs cache-off ⇒ cache bug, full stop (the parity discipline of Problems 15/61, applied at the systems level). Without that logging, these reports are unfalsifiable and get closed as "model being weird."
- Invalidation (Bug 2) is a deployment contract: any change to system prompt, model weights, adapters, or tokenizer must bump a generation/epoch id baked into the key — TTL is a mitigation, not a policy. Config-that-describes-the-artifact (Problem 39) meets cache keys: the key is a fingerprint of the artifact.
- Bug 3 is a scoping error: KV states depend only on the prefix (safe to share); sampling parameters are per-request (never cacheable). Drawing the line — what is a function of the prefix vs a function of the request — is the design question, and mixing the two scopes is the bug.
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
- Serving-layer state (caches, batching, routing) can change model behavior while every ML-level test passes — the last mile of correctness is systems correctness. The cache-off replay is the serving analogue of "overfit one batch": a cheap, decisive experiment that cleanly splits the hypothesis space (model vs infrastructure).
- The severity dimension is part of the answer: a stale cache is a quality bug; a colliding cache is a data-isolation incident with disclosure obligations. Recognizing when a debugging finding changes category — correctness → privacy/security — is a senior-engineer competency interviewers notice.
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
- Overfit one batch (10–50 samples) to ~zero loss. Failure ⇒ plumbing bug (graph, loss, optimizer, shapes), not data/hyperparameters.
- Check the loss at init against theory (
ln Kfor K-class CE; label variance for MSE). Wrong init loss ⇒ activation/loss mismatch or scaling bug. - Print gradient norms per layer after one backward:
None⇒ detached; huge/tiny ⇒ instability or vanishing. - Assert shapes and dtypes at every module boundary; test with non-square, non-power-of-2 sizes so transposes can't hide.
- Visualize actual model inputs (post-augmentation, post-normalization) with their labels — not the raw data.
- Diff train vs eval paths line by line: transforms, normalization stats, tokenizer version,
eval()/no_grad. - Fix all randomness, reproduce the bug deterministically, then bisect (git bisect for code, data bisect for corrupt samples).
- Instrument, don't guess: log LR, grad norm, weight norm, throughput per step;
set_detect_anomalyfor NaNs; warnings-as-errors in CI.
One-liners worth saying in an interview
- "CrossEntropyLoss owns the softmax — models should return logits."
- "If validation looks too good, I assume leakage until proven otherwise."
- "
model.eval()changes behavior;torch.no_grad()changes bookkeeping — you need both." - "A model that can't overfit 32 samples has a plumbing bug, not a modeling problem."
- "pandas aligns on index, not position — every silent scramble I've debugged came from that."
- "Accuracy on imbalanced data is a vanity metric; start from the confusion matrix."
- "For generative models the training loss is nearly useless — the samples are the test."
- "The sampler owns the parameterization, the same way the loss owns the activation."
- "Before debugging a flow/diffusion model on images, I'd reproduce it on a 2-D toy where I can see the samples."
- "State the mask convention out loud before writing the mask — every library flips it."
- "Cached and uncached decoding must produce identical greedy tokens; that's my KV-cache unit test."
- "In post-training, I trust generation evals over any proxy metric — margins can climb while the model degrades."
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
- Cover-up drill: read only each "Buggy code" + "Symptom" section and reconstruct the diagnosis before reading the analysis.
- 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.
- 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.
- 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.)
- 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.