The worst bugs don’t crash. They train beautifully.
A web app fails in milliseconds with a stack trace. A misconfigured training run burns eight hours of GPU time, exits with status 0, and reports a beautiful loss curve. This lesson is the instrument panel: targeted prints, conditional breakpoints, logs, timers, profilers, a memory ledger and TensorBoard.
AI debugging happens at three levels — standard Python (breakpoints, logging, profiling, memory), tensor operations (shapes, dtypes, devices, NaN), and training dynamics (loss curves, gradient norms, activations). Most people start at level 3, staring at TensorBoard. The source's claim is that 80% of AI bugs live at levels 1 and 2, where one print can show shape, dtype, device and value range together — before another hour of GPU time is spent.
L3 → curves · L2 → tensors · L1 → Python · start cheap, end cheap02 / INSTRUMENTS, CHEAPEST FIRST
Print the tensor. Then stop the run.
`debug_print(name, tensor)` prints shape, dtype, device, min, max, mean and has_nan in one line — the whole combination a tensor bug needs. When the symptom is already loud (loss over 100, or NaN), a conditional `breakpoint()` turns the debugger into a trap: it pauses exactly at the bad step, inside the frame, where `p outputs.shape` and `p optimizer.param_groups[0]['lr']` are live. Logging, with a file handler and a stream handler, is the version that survives a 3 a.m. failure.
debug_print · breakpoint() · logging.basicConfig · p/c/q03 / MEASURE THE TWO BUDGETS
60% of training time can be data loading.
The Timer shows where seconds go; `python -m cProfile -s cumtime` sorts functions by cumulative time; `line_profiler` goes line by line. The source's common finding: data loading eats 60% of training — and the fix is `num_workers > 0`, not a faster GPU. Memory has its own ledger: `tracemalloc` attributes allocations to lines (growth, not size, is the leak), and on the GPU `memory_allocated()` versus `memory_reserved()` plus the OOM order — batch size first, gradient checkpointing last.
data 60% → 6% · tracemalloc · allocated vs reserved
MENTAL MODEL IN ONE SENTENCE
A silent bug becomes visible the moment you ask the tensor four questions — what shape, what dtype, which device, any NaN — and ask the run two more: where did the time go, and where did the memory go. Cheap instruments first; TensorBoard last, not first.
By the end you will be able to read a tensor’s field report and name the bug it rules out; put a conditional breakpoint() behind a symptom and drive pdb (p, c, q); configure logging with a file and a stream handler so an overnight run leaves evidence; time data loading against forward and backward and explain why num_workers beats a faster GPU; profile with cProfile and line_profiler and tell cumulative from self time; trace CPU memory with tracemalloc and read the GPU ledger (allocated versus reserved); work the OOM checklist in its cost order; catch the four classic bugs — shape mismatch, NaN loss, data leakage, wrong device — with the source’s catchers; and read a TensorBoard run without confusing oscillation, overfitting and explosion.
01
THREE LEVELS
A web app crashes. A training run keeps going.
The source opens with the sentence that explains this whole lesson: a misconfigured training loop runs for eight hours, burns $200 of GPU (graphics processing unit) time, and produces a model that predicts the mean of every input. The code never errored.
That asymmetry is the whole problem. A web app fails in milliseconds and hands you a stack trace with a line number. A training run fails for eight hours and hands you a log that says everything completed. The process exits with status 0, the loss curve descends, and the model has learned nothing. The source’s arithmetic is worth stating plainly: 8 hours × $25/hour = $200, so the implied rental rate behind the example is about $25 per hour — the price of a small multi-GPU node rather than one consumer card, and the reason silent failures are measured in money.
a web app fails in milliseconds · a stack trace names the line
a training run fails in 8 hours · exit code 0 · "done" in the log
· $200 of GPU time ≈ $25/hour
"predicts the mean of every input" — the safest answer a regressor
can give; predict-the-average is what a model converges to when
it learns nothing at all. (A classifier's version is predicting
the class frequencies — which is why a fraud model can look
respectable at a 1%-fraud base rate while catching nothing.)
AI debugging happens at three levels, and the source’s diagram is the map of this lesson. Level 1, standard Python: breakpoints, logging, profiling, memory. Level 2, tensor operations: shapes, dtypes, devices, NaN (not a number) and Inf values. Level 3, training dynamics: loss curves, gradient norms, activations. The levels are not a difficulty ranking — they are a search order.
Here is the claim that reorganizes your instincts: most people jump straight to level 3 — staring at TensorBoard — but 80% of AI bugs live at levels 1 and 2. The evidence is in the instruments each level needs. A shape mismatch, a float64 tensor where float32 was intended, a batch left on the CPU (central processing unit), a NaN in a column with zero variance — each one is visible with a single print, before the run even finishes. A loss curve, by contrast, only tells you that something is wrong, and usually only after hours of compute have already been spent. The curves are the most expensive place to look and the least specific: they are a smoke alarm, not a floor plan.
So the workflow this lesson builds is a search that starts cheap. Ask the tensor four questions — shape, dtype, device, NaN — and the run two more: where did the time go, and where did the memory go. When the loss curve is the mystery itself — parked at the chance level, or refusing to move at any learning rate — the diagnosis becomes neural-network-specific, and that is a later lesson: Phase 3, Lesson 13 (Debugging Neural Networks) covers chance levels (ln C), the overfit-one-batch test, gradient checking and the learning-rate range test. This lesson is the general toolbox underneath all of them.
Quick check
Your training run finishes with no error, but the model is useless. The source says 80% of AI bugs live at levels 1 and 2. Where do you look first?
02
PRINT AND PAUSE
Print the tensor. Then stop the run.
Print debugging gets dismissed. It should not. For tensor code, one targeted print shows shape, dtype, device and value range at once — all the things a single step of a debugger cannot show you together.
The source’s first tool is a function that never changes: debug_print(name, tensor). It prints seven facts about a tensor — shape, dtype, device, min, max, mean and whether it contains a NaN (not a number) — and it is called after every suspicious operation. When the bug is found, you remove the prints. Simple. The reason a print wins here is bandwidth: stepping through a debugger gives you one value at a time, while a tensor bug is usually a combination of shape, dtype and range that only makes sense when you see them together.
The source's helper, verbatim. Seven fields, one line — the four-question drill (shape, dtype, device, NaN) plus the range that tells you whether the values are sane.
what the patrol prints (illustrative output)output · illustrative
Illustrative — four imagined prints from one imagined step. The lines are wrapped here for the page; the real helper prints one line per tensor. Note the last one: the scalar everyone watches is the last place to look, not the first.
Each field answers a specific question, and it is worth knowing which bug each one catches before you need it:
Field
What it catches
shape
The most frequent bug in deep learning: a tensor shaped [batch, features] where the layer expects [batch, channels, height, width], or a flattened input of the wrong width.
dtype
A float64 tensor where float32 was intended silently doubles the memory and changes the numerics; integer labels where float logits are expected fail only later, inside the loss.
device
A tensor that quietly stayed on the CPU while the model is on cuda:0 — training still runs, just far slower than the hardware allows.
min / max
Values exploding toward Inf (infinity) or collapsing to zero — the difference between a saturated activation and a dead one.
mean
Scale and offset: a mean of 12.4 on raw pixels says the normalization step never ran.
has_nan
The poison. One NaN makes every aggregate NaN, and it spreads through every matmul that reads it.
The debug_print inspector
One training step, five checkpoints. Step through them, read each tensor’s field report, and flag the first one where has_nan turns True. A simulation — the field values are illustrative, the bug is a real pattern.
checkpoint 1 / 5 · inputs
op DataLoader → x
shape torch.Size([32, 784])
dtype torch.float32
device cuda:0
min/max/mean -4.2126 / 4.1337 / 0.0121
has_nan false
attempts 0
scan or step, then flag the culprit
The lesson: one NaN column makes every aggregate NaN, and it spreads through every matmul. The sooner you look, the smaller the haystack — which is why the source asks for a targeted print after each suspicious operation.
The second tool is the debugger (pdb, the Python Debugger), and the interesting part is the condition. A 10,000-step run is too long to step through, so the source puts breakpoint() behind the symptom: pause only when the loss is absurd or already NaN. That turns the debugger from a microscope into a trap — it catches the run in the act, at the exact step where the evidence still exists.
The source's pattern. `loss.item()` is a full read of the scalar (it synchronizes the GPU); `torch.isnan(loss)` is the second trigger. When the run stops, you are inside the frame — with every variable live.
once the debugger has youpython debugger (pdb)
(Pdb) p outputs.shape # check shapes
(Pdb) p loss.item() # see the loss value
(Pdb) p torch.isnan(outputs).sum() # count NaNs
(Pdb) p model.fc1.weight.grad # check gradients (last step's — backward() has not run)
(Pdb) c # continue execution
(Pdb) q # quit
The source's command list. Two honest details: the breakpoint sits before `loss.backward()`, so a gradient reading is one step stale; and `c` continues the same run while `q` throws the rest of it away.
The conditional breakpoint simulator
A 10,000-step run compressed to 12 steps. The run pauses by itself when the loss crosses 100 — inspect with p …, continue with c, or quit with q. The debugger output is illustrative pdb output.
train.py — debugger sessionnot started
waiting — the run has not started yet
Commands appear when the debugger pauses.
run not paused yet — press ▶ run training.
This simulates the source's conditional breakpoint:
if loss.item() > 100 or torch.isnan(loss): breakpoint()
The breakpoint fires beforeloss.backward(), so a gradient you print at the pause is last step’s. That ordering is the source’s, and it is why the lr reading matters more than the gradient reading here.
Quick check
Why does the source prefer a targeted print over stepping through a debugger for tensor code?
03
A RECORD THAT SURVIVES
It failed at 3 a.m. So it belongs in a file.
Printing is for the ten minutes when you are watching. Logging is for the eight hours when you are not. The difference is timestamps, severity levels, and a destination that outlives the terminal.
The source’s argument for logging is one sentence of realism: when a training run fails at 3 AM, you want a log file, not terminal output that scrolled off screen. Overnight, nobody is looking. The SSH (Secure Shell) session that hosted the run may be gone by morning; the terminal window on your laptop may have been closed; the buffer that held 40,000 step lines is not evidence anywhere. Meanwhile the rented GPU billed you for every minute of the eight hours — which makes the log file the run’s black box.
The source's setup: one call, a file handler and a stream handler, and a format with a timestamp, a level and a message. `getLogger(__name__)` names the logger after the module, so records from different files stay tellable apart.
Three ideas are doing the work in those ten lines. Levels rank severity: DEBUG < INFO < WARNING < ERROR < CRITICAL. Setting level=logging.INFO means a record is emitted if its severity is at or above INFO — so DEBUG records vanish without you deleting anything, and you can raise the floor to WARNING for a quiet run or lower it to DEBUG for a three-minute investigation. Handlers are destinations: the file handler writes training.log; the stream handler writes to the terminal. Two handlers, one stream of records — which is why you can keep the full history on disk while the terminal shows only warnings. Timestamps turn the log into a timeline: the moment the loss spiked, the checkpoint that was never written, the exact minute the run stopped. And one habit to copy from the source: the arguments are passed separately ("...%.4f", value), not f-string-formatted, so the string is only built if the record is actually emitted — in a hot loop that is real work saved.
Logging also settles the timing question, because the thing that follows from “where did it fail” is “where did the time go”. The source’s Timer is a context manager — a Python object usable in a with block that does work on entry and exit — built on time.perf_counter(), the highest-resolution clock Python exposes.
part 4 · time the three phasespython
import time
class Timer:
def __init__(self, name=""):
self.name = name
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, *args):
elapsed = time.perf_counter() - self.start
print(f"[{self.name}] {elapsed:.4f}s")
with Timer("data loading"):
batch = next(dataloader_iter)
with Timer("forward pass"):
outputs = model(batch)
with Timer("backward pass"):
loss.backward()
The source's Timer. The lab below keeps the same three names, and the profiler in the next chapter turns them into a picture.
Now the finding that pays for this whole lesson: data loading often takes 60% of training time, and the fix is num_workers > 0 in your DataLoader — not a faster GPU. Here is that claim worked with arithmetic (a teaching model built on the source’s 60%, not a benchmark of your machine). One hundred steps of a small model, with num_workers=0, take 30.0 seconds:
data loading 18.0 s 60.0% ← the same process as the model
forward 6.2 s 20.7%
backward 5.5 s 18.3%
optimizer 0.2 s 0.7%
train (self) 0.1 s 0.3%
──────────────────────────────
wall clock 30.0 s → the GPU is busy 11.9 s of it (40%)
with num_workers=4 the loading happens in four parallel workers:
visible wait 0.8 s 6.3% (first-batch fill + occasional waits)
wall clock 12.8 s → 2.3× faster, same math, same GPU
10,000 steps 50.0 min → 21.3 min
96,000 steps 8.0 h → 3.4 h · at $25/h: $200 → ≈$85
Read the last line carefully: nothing about the model changed. The forward and backward times are identical in both columns — num_workers cannot make a matrix multiply faster. What changed is that the GPU stopped sitting idle waiting for the next batch, and idle hardware is the most expensive thing in the room.
The logging console
One logger, one level, two handlers. Change the level or turn a handler off and watch which records survive — the file keeps what the terminal drops, which is the whole point at 3 a.m.
terminal · StreamHandler()7 / 8 lines
2026-09-15 03:12:04.118 [INFO] Starting training: lr=0.1000, batch_size=322026-09-15 03:41:18.902 [INFO] step 40000: loss=0.4512, grad_norm=1.03122026-09-15 04:02:11.455 [WARNING] Loss spike detected: 148.6201 at step 401202026-09-15 04:02:19.001 [WARNING] large gradient in fc1.weight: 37.41832026-09-15 04:03:02.883 [ERROR] NaN loss at step 41877, stopping2026-09-15 04:03:02.884 [INFO] Training stopped after 41877 steps2026-09-15 04:03:02.885 [ERROR] no checkpoint saved since step 40000 — resume is not possible
2026-09-15 03:12:04.118 [INFO] Starting training: lr=0.1000, batch_size=322026-09-15 03:41:18.902 [INFO] step 40000: loss=0.4512, grad_norm=1.03122026-09-15 04:02:11.455 [WARNING] Loss spike detected: 148.6201 at step 401202026-09-15 04:02:19.001 [WARNING] large gradient in fc1.weight: 37.41832026-09-15 04:03:02.883 [ERROR] NaN loss at step 41877, stopping2026-09-15 04:03:02.884 [INFO] Training stopped after 41877 steps2026-09-15 04:03:02.885 [ERROR] no checkpoint saved since step 40000 — resume is not possible
level INFO
handlers stream + file
shown 7 / 8 records
INFO and up. The DEBUG shape check is dropped; warnings and errors are kept.
The level is the floor, not a filter per message: a record is emitted if its severity is at or above the logger’s level, and each handler then decides where it goes. Two handlers, one stream of records.
04
WHERE THE TIME GOES
Never guess. Measured beats obvious.
The Timer told you something is slow. A profiler tells you which function, how many times it was called, and how much of that time it spent in its own code versus in the code it called.
Python ships a profiler in the standard library. Run your script through it and sort the report by cumulative time — the time a function and everything it calls consumed:
part 5 · the standard-library profilershell
python -m cProfile -s cumtime train.py
`-m cProfile` runs the module under the profiler; `-s cumtime` sorts the report so the most expensive call trees come first — in this chapter's model, that is the DataLoader.
reading a profile (illustrative output)output · illustrative
Illustrative — the same 100-step model as the previous chapter. `tottime` is self time: the 17.6 of __next__'s 18.0 seconds is spent in that function itself. `cumtime` includes callees: train_step's 11.9 s is the sum of forward, backward and the optimizer, while its own code costs 0.1 s. That is why self time is where you look for the code that is actually slow.
Two habits make profiler output readable. First, sort and compare: run once with -s cumtime to find the expensive call trees, then look at the tottime column to see which frames are spending time in their own code rather than inheriting it from children. A function that calls everything accumulates everyone else’s seconds and will always sit near the top. Second, remember the overhead: cProfile instruments every call, so a profiled run is slower than the real one and the absolute numbers shift. The ratios are the finding; the seconds are a hint. When a ratio points at a frame, confirm it with a Timer on a clean run.
When the report points at a function but you need the line, the standard-library profiler stops being enough — it works at function granularity. That is the job of line_profiler:
line-by-line timingshell · python
pip install line_profiler
The source's install line. Then decorate the function you want measured with @profile and run the script through kernprof:
the decorated step, then the runnerpython + shell
@profile
def train_step(model, data, target):
output = model(data)
loss = F.cross_entropy(output, target)
loss.backward()
return loss
# Run with: kernprof -l -v train.py
Note what @profile is not: it is not imported from line_profiler, and the module will not run without kernprof — that is the contract of the tool. kernprof -l writes the timings, -v prints the report immediately.
what the line report looks like (illustrative output)output · illustrative
Timer unit: 1e-06 s
Total time: 11.01 s
Function: train_step at line 30
Line # Hits Time Per Hit % Time Line Contents
==============================================================
311005300000.053000.048.1 output = model(data)
32100210000.02100.01.9 loss = F.cross_entropy(output, target)
331005500000.055000.050.0 loss.backward()
Illustrative. The forward and backward lines each own about half of the step — which is what a healthy training step looks like. If the data-loading line were inside this function, it would own 60%.
The profiler flame chart
100 training steps, measured by cProfile -s cumtime. Bar width is cumulative time; the solid leading segment is self time. Find the 60% and apply num_workers=4. A teaching model, not a benchmark of your machine.
profile train.py · 100 steps · num_workers=0
wall time 30.00 s
data loading 60.0% (18.00 s)
forward 20.7%
backward 18.3%
GPU busy ~40% of wall time
selected DataLoader.__next__
cumulative 18.00 s
self 17.60 s
18.0 s of the 30.0 s run — the source's 60% finding. With num_workers=0 this runs in the same process as the model, so the GPU waits.
The top of a cumtime-sorted list is not always the bug: a frame that every step passes through accumulates everyone else’s time. Self time is where the time is actually spent.
05
WHERE THE MEMORY GOES
OOM is a budget problem. Read the ledger, then cut in order.
Time is not the only resource a training run spends. Memory is the one that kills the run — and the one where “just use a bigger machine” is usually the wrong first answer.
On the CPU (central processing unit) side, Python’s standard library carries a second profiler: tracemalloc traces every allocation and can attribute the bytes to the line that allocated them. The recipe is four steps — start tracing, run the suspicious code, take a snapshot, print the top statistics.
part 6 · who allocated the mostpython
import tracemalloc
tracemalloc.start()
# your code here
model = build_model()
data = load_dataset()
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")
for stat in top_stats[:10]:
print(stat)
The source's recipe. `snapshot.statistics('lineno')` groups allocations by file and line, so the output is a ranked list of the lines that hold the most bytes.
what the top of that list looks like (illustrative output)output · illustrative
Illustrative. The teaching detail is the count: 1000 MiB in 50 objects of 20 MiB each is one more object every step — a leak. 250 MiB in one object is a big-but-fixed budget item. `detach()` frees the autograd graph, not the tensor; the list still references the data.
For line-by-line memory rather than allocation attribution, the third-party memory_profiler decorates a function the same way line_profiler does, and reports MiB per line:
memory per lineshell · python
pip install memory_profiler
from memory_profiler import profile
@profile
def load_data():
raw = read_csv("data.csv") # watch memory jump here
processed = preprocess(raw) # and herereturn processed
# run with: python -m memory_profiler your_script.py
The source's example, comments and all. Run the script through the module and each line gets an Increment and a Mem usage column — the two jumps are the two allocations.
The GPU (graphics processing unit) has its own ledger, and PyTorch exposes it directly. torch.cuda.memory_summary() prints a block-by-block account; the two numbers worth memorizing are memory_allocated() — bytes held by live tensors — and memory_reserved() — bytes held by PyTorch’s caching allocator.
The source's snippet. “Cached” is the reserved number: the allocator keeps freed blocks in a pool so the next tensor does not have to ask the driver for memory. Reserved is always at least allocated — the gap is the pool.
That gap explains both why OOM (out of memory) happens and why empty_cache() is not magic: it returns unused cached blocks to the driver so the process can avoid one more allocation failure — it does not free a single live tensor. With the two ledgers in hand, the source’s OOM checklist becomes a sequence to work down, in order:
Reduce the batch size — first thing to try, always. Activations scale roughly linearly with batch size. One image at [3, 224, 224] in FP32 (32-bit floating point) is 3 × 224 × 224 × 4 = 602,112 bytes ≈ 0.6 MB; a batch of 32 is ≈19.3 MB, and every activation inside the model multiplies that again. Halving the batch roughly halves the activation bill, and it costs one config line. Nothing else on this list is that cheap.
Use torch.cuda.empty_cache() to free cached memory. If reserved is far above allocated, the pool holds blocks nothing is using. Clearing it gives the next allocation room — and gives you an honest reading of what the live tensors actually need.
del large intermediates, then clear the cache. A Python name is a reference: del big_tensor drops yours, and the memory returns to the allocator when the last reference goes. This is where a helper function is a feature — locals die on return.
Mixed precision (AMP, automatic mixed precision; torch.cuda.amp — the older API, still works; newer code uses torch.amp.autocast("cuda")) to cut activation memory. FP16 (16-bit floating point) stores 2 bytes per number instead of 4, but the standard recipe keeps FP32 master weights — and usually FP32 gradients — for stability; what autocast changes is that eligible ops inside the region run in FP16, so their activations and outputs take half the space. The 0.6 MB image becomes ≈0.3 MB; the batch becomes ≈9.6 MB. The cost is numerics care: loss scaling exists because small gradients underflow FP16.
Gradient checkpointing for very deep models. The backward pass needs the forward pass’s activations, so training normally keeps them all. Checkpointing stores a few and recomputes the rest during backward — trading compute for memory. It is the last resort because it is the only one that makes the run slower.
One more number makes the OOM conversation concrete. The optimizer state is not free: with Adam, every parameter carries a gradient plus two moment estimates, all in FP32 — 4 + 4 + 4 + 4 = 16 bytes per parameter before a single activation. A 125-million-parameter model therefore needs 125,000,000 × 16 = 2.0 GB just for weights, gradients and optimizer state; everything the forward pass computes sits on top of that. (Scale that model up by 56× to a 7-billion-parameter model and the same arithmetic gives 112 GB — which is why the largest models need sharding and offloading, tools that arrive in later phases.)
The order matters because the checklist is sorted by cost: a config change, then a cache call, then a reference deletion, then a numerical technique, then a compute-for-memory trade. Reaching for gradient checkpointing first is like emptying a house to make room for one suitcase.
The memory tracker
Run the steps and watch the traced allocations — the process’s resident set size (RSS) against a 1024 MB budget. Then read the top three lines and apply the fix to the one that is actually leaking. A tracemalloc-style teaching model: the curve is generated, not measured on your machine.
steps 10 / 50
memory now 539 MB
peak 539 MB
budget 1024 MB
status under budget
selected line
train.py:41 1000 MiB count=50
cache.append(batch.detach())
apply the fix — this line grows with the step count
tracemalloc attributes bytes to the line that allocated them. The largest line is not automatically the leak: a 250 MB dataset loaded once is a budget item, while 20 MB per step is a trajectory toward OOM.
Quick check
Your GPU run OOMs with batch size 64. What is the first thing to try, and what does torch.cuda.empty_cache() actually free?
06
THE FOUR CLASSIC BUGS
Four failures. Four catchers.
The source names the bugs that account for most silent training disasters — shape mismatch, NaN loss, data leakage and the wrong device — and pairs each one with a small function that turns it into evidence.
Shape mismatch is the most frequent bug in deep learning. A tensor has shape [batch, features] when the model expects [batch, channels, height, width]; a flattened MNIST image is 28 × 28 = 784 numbers, but an ImageNet-shaped image is 3 × 224 × 224 = 150,528 — 192 times wider than the layer that receives it. The error message names two tensors and no intent, so the source’s catcher runs a sample batch through forward hooks — callbacks each layer calls with its input and output — and prints every shape transformation at once.
catcher 1 · map every shapepython
def check_shapes(model, sample_input):
print(f"Input: {sample_input.shape}")
hooks = []
def make_hook(name):
def hook(module, inp, out):
in_shape = inp[0].shape if isinstance(inp, tuple) else inp.shape
out_shape = out.shape if hasattr(out, "shape") else type(out).__name__
print(f" {name}: {in_shape} -> {out_shape}")
return hook
for name, module in model.named_modules():
if name:
hooks.append(module.register_forward_hook(make_hook(name)))
with torch.no_grad():
model(sample_input)
for h in hooks:
h.remove()
Adapted from the source's check_shapes. Run it once with a real sample batch before training: one pass, one map, hooks removed afterward. The `if name:` guard skips the top-level module, which has no input to report.
NaN loss means something exploded — and the source lists the usual suspects: a learning rate too high, a division by zero in a custom loss, a logarithm of zero or a negative number, exploding gradients in recurrent networks. The catcher walks every named parameter and reports which gradient went NaN or Inf, because the first bad gradient is usually one layer below the arithmetic that produced it.
catcher 2 · find the poisoned gradientpython
def detect_nan(model, loss, step):
if torch.isnan(loss):
print(f"NaN loss detected at step {step}")
for name, param in model.named_parameters():
if param.grad is notNone:
if torch.isnan(param.grad).any():
print(f" NaN gradient in {name}")
if torch.isinf(param.grad).any():
print(f" Inf gradient in {name}")
returnTruereturnFalse
The source's detector. Pair it with a gradient-health check for the step before the NaN: a total norm climbing from 1 toward 100 is the warning shot the loss curve never shows you.
the warning shotpython
def check_gradient_health(model):
total_norm = 0.0for name, param in model.named_parameters():
if param.grad is notNone:
grad_norm = param.grad.data.norm(2).item()
total_norm += grad_norm ** 2if grad_norm > 100:
print(f" WARNING: large gradient in {name}: {grad_norm:.2f}")
if grad_norm == 0:
print(f" WARNING: zero gradient in {name}")
total_norm = total_norm ** 0.5
print(f"Total gradient norm: {total_norm:.4f}")
return total_norm
Adapted from code/debug_tools.py. Two magnitudes worth knowing: 0 means no signal is reaching those weights at all, 37.4 (the breakpoint lab's leftover gradient) is already 37× a healthy norm of about 1, and anything past 100 is a spike waiting to become Inf.
Data leakage is the bug that flatters you. Your model reports 99% accuracy on the test set, the source says — and it is a bug: test samples leaked into training, or a feature contains the answer. The catcher intersects the identifiers of the two splits; anything it finds disqualifies the result. The second leak is temporal: using future data to predict the past — sort by timestamp before splitting, and check that every feature was knowable at prediction time.
catcher 3 · prove the splits are disjointpython
def check_data_leakage(train_set, test_set, id_column="id"):
train_ids = set(train_set[id_column].tolist())
test_ids = set(test_set[id_column].tolist())
overlap = train_ids & test_ids
if overlap:
print(f"DATA LEAKAGE: {len(overlap)} samples in both train and test")
returnTruereturnFalse
The source's check. One overlap is one too many — and the same check catches the subtler cousin: duplicate rows with different identifiers, which the id intersection misses and a hash of the feature columns catches.
Wrong device is the quiet one. Tensors on different devices usually raise a RuntimeError the moment an operation mixes them — but sometimes a tensor silently stays on the CPU while everything else is on the GPU, and training simply runs slowly. The source’s catcher compares every candidate tensor with the model’s device, and the honest detail is that it must be called before the run: once the mismatch is inside an operation the error names the op, not the line that forgot to move.
catcher 4 · the one-line device auditpython
def check_devices(model, *tensors):
model_device = next(model.parameters()).device
print(f"Model device: {model_device}")
for i, t in enumerate(tensors):
status = "OK"if t.device == model_device else"MISMATCH"
print(f" Tensor {i}: {t.device} [{status}]")
Adapted from the source. Run it at the top of the step (or at the top of training) with the batch tensors: every line should say OK, and one MISMATCH explains a run that is three times slower than the hardware.
The silent-bug taxonomy board
Four symptoms that AI code produces without crashing. For each one, name the bug — then check to see its level, its catcher and its fix. The symptoms are the source’s four classic AI bugs, worded as terminals word them.
symptom 1 / 4unnamed
RuntimeError: mat1 and mat2 shapes cannot bemultiplied (32x784 and 128x10)
symptom 2 / 4unnamed
step 499: loss=148.6201step 500: loss=nan
symptom 3 / 4unnamed
kernel RSS +20 MB every stepa cell that took 2 s now takes 40 s
symptom 4 / 4unnamed
model: cuda:0 · labels: cputraining runs — just 3× slower than it should
score 0 / 4
focus symptom 1
Name the bug for each symptom, then check. The giveaway
is not the error text — it is which level of the stack
produces the evidence: Python memory, tensor metadata,
or the training curve.
No symptom here raises a friendly exception at the right place: a shape error names two tensors, a leak names no one, and a device mismatch may not raise at all. The catcher is what turns silence into evidence.
Quick check
A tensor sits on the CPU while the model is on cuda:0. Why does this not always crash?
One boundary is worth drawing explicitly, because it saves a lot of duplicated effort. Everything in this chapter is evidence collection: shapes, gradients, devices, overlap. When the loss curve itself is the mystery — parked at the chance level, busy memorizing one batch, or unresponsive at every learning rate — the diagnosis becomes neural-network-specific. That is Phase 3, Lesson 13 (Debugging Neural Networks): the chance-level arithmetic ln C (0.693 for two classes, 2.303 for ten), the overfit-one-batch test, gradient checking against a numerical gradient, the learning-rate range test, and gradient clipping. This lesson gets the numbers on the table; that lesson reads the neural-network half of them.
07
WATCH IT OVER TIME
The curve is a dashboard. Not a verdict.
TensorBoard turns a training run into pictures: loss and learning rate every step, weight and gradient distributions every hundred. The skill is reading each pattern as a specific hypothesis.
The writer loop is small. Create a SummaryWriter pointed at a run directory, add the scalars you watch every step, add histograms of weights and gradients every hundred steps (writing a histogram is far more expensive than writing a scalar), and close the writer when the run ends.
part 8 · the TensorBoard looppython
from torch.utils.tensorboard import SummaryWriter
writer = SummaryWriter("runs/experiment_1")
for step in range(num_steps):
loss = train_step(model, batch)
writer.add_scalar("loss/train", loss.item(), step)
writer.add_scalar("lr", optimizer.param_groups[0]["lr"], step)
if step % 100 == 0:
for name, param in model.named_parameters():
writer.add_histogram(f"weights/{name}", param, step)
if param.grad is notNone:
writer.add_histogram(f"grads/{name}", param.grad, step)
writer.close()
The source's loop. The `step % 100` condition is doing real work: at 5,000 steps and 40 tensors, per-step histograms would write 200,000 histograms instead of 2,000 — and the histogram write would start showing up in the profile.
launch the dashboardshell
tensorboard --logdir=runs
Point it at the parent directory and every run underneath appears in the same dashboard, which is how you compare experiment_1 with experiment_2.
The source gives six patterns and their meanings. Read them as hypotheses, not verdicts — each one narrows the search, and each one has an instrument in this lesson that can confirm it:
What you see
What it usually means
Loss not decreasing
Learning rate too low, or a model architecture issue
Loss oscillating wildly
Learning rate too high — the steps overshoot the minimum
Loss goes to NaN
Numerical instability: overflow, a log of zero, a divide by zero
Train loss falling, validation loss rising
Overfitting — the model is memorizing the training set
Weight histograms collapsing to zero
Vanishing gradients — the signal is dying layer by layer
Gradient histograms exploding
Gradient clipping is needed
The TensorBoard reader
Six patterns from a SummaryWriter; six diagnoses. Read the shape before you read the number — the source’s “what to look for” list is the answer key. The curves are drawn, not captured.
patterns read 0 / 6
showing loss/train — flat
selected — nothing yet
Pick a diagnosis. The six patterns come from the source's add_scalar and add_histogram sections: scalars every step, histograms every 100.
Scalars are cheap — loss and learning rate go in every step. Histograms are expensive, so weights and gradients go in every 100 steps. If the histogram itself never changes shape, the layer is dead weight; if it explodes, clipping is the conversation.
Then there is the debugger you already own. Visual Studio Code (VS Code) can attach to your training script with a launch.json — a JavaScript Object Notation (JSON) file in .vscode/ describing how to start a debug session — using the debugpy adapter. Click the gutter to set a breakpoint, use the Variables pane to inspect tensor properties, and use the Debug Console to evaluate expressions mid-run. The justMyCode: false setting is the one to understand: it lets the debugger step into library code, so a breakpoint can land inside PyTorch itself, not only inside your file.
The source's configuration, verbatim. `${file}` means “debug whatever file is open”; the integrated terminal keeps the run's output where you can see it; justMyCode:false lets you follow a call into a library.
Everything in this lesson now composes into the source’s five-step workflow — the sequence to follow for every training run, from the quiet before it starts to the performance question after it works:
Before training: run check_shapes with a sample batch and verify the input and output dimensions match expectations.
First 10 steps: use debug_print on loss, outputs and gradients; confirm nothing is NaN and the values are in reasonable ranges.
During training: log loss, learning rate and gradient norms; use TensorBoard for the curves and histograms.
When something breaks: drop breakpoint() at the failure point and inspect the tensors interactively.
For performance: time data loading versus forward versus backward; profile memory if the run is near OOM.
And when the curves are read and the evidence is in — shapes, dtypes, devices, gradients, timing, memory — the diagnosis for a neural-network-specific failure follows the playbook in Phase 3, Lesson 13 (Debugging Neural Networks): compare the loss against the chance level ln C, overfit one tiny batch to prove the model can learn at all, check gradients numerically, and sweep the learning rate. This lesson made the measurements; that one turns them into a diagnosis.
08
CHECK YOURSELF
Six questions. Then the terms worth keeping.
Answer before you look. The profiler question and the NaN question decide whether you can find a silent failure or only describe it; the exercises end with the OOM order and the TensorBoard reading in one hand.
0 / 6 answered · 0 correct
01What makes debugging AI/ML code fundamentally different from debugging a typical web application?
02What does a profiler measure?
03Your model achieves 99% accuracy on the test set. What AI-specific bug should you suspect first?
04What is the most common finding when profiling a training loop's time breakdown?
05You see NaN loss at step 500. Which approach will help you find the root cause?
06In TensorBoard, the train loss keeps falling while the validation loss turns upward. What does the pattern mean, and which check comes first?
Key terms, demystified
Click a card to swap the lazy description for what it actually means.
Exercises from the lesson
Four problems with exact commands: inject a NaN and catch it, profile a training loop and fix what the profile names, hunt a leak with tracemalloc and then work the OOM checklist, and read a TensorBoard run for overfitting. Try first; a worked answer is one click away.
Run the lesson's debug_tools.py and read through each section's output. Then modify the dummy model to introduce a NaN (the source's hint: divide by zero in the forward pass) and watch the detector catch it. Then practice conditional breakpointing: drop breakpoint() into the loop and inspect shapes, devices and gradients from the prompt. (The source's Exercises 1 and 5.)Show one worked answer
Run it first: `python phases/00-setup-and-tooling/12-debugging-and-profiling/code/debug_tools.py`. The script gates on PyTorch (`HAS_TORCH`) and prints ten sections — print debugging, timing two matmuls, a tracemalloc top-5, shape checking through a three-layer model, NaN detection, device checking, gradient health, GPU memory (skipped with a friendly note when `torch.cuda.is_available()` is False), logging at four levels, and the conditional-breakpoint pattern with its pdb command list. To inject a NaN honestly, use the divide-by-zero that actually produces one — 0/0, not finite/0 (which gives Inf): make a column constant and standardize. `x = torch.randn(32, 784); x[:, 0] = 1.0; x = (x - x.mean(dim=0)) / x.std(dim=0)` → column 0 is 0/0 → NaN. Then `debug_print("x_scaled", x)` shows `has_nan=True` with min/max/mean all NaN, `detect_nan` reports NaN gradients in every parameter that touched the column, and `check_gradient_health` prints a norm of nan. For the breakpoint practice, put `if loss.item() > 100 or torch.isnan(loss): breakpoint()` in the loop and try the source's commands: `p outputs.shape` → `torch.Size([32, 10])`; `p torch.isnan(outputs).sum()` → count the poisoned rows; `p labels.device` → catch the silent CPU tensor; `p model.fc1.weight.grad.norm().item()` → remember it is last step's, because the pause is before backward; `c` resumes, `q` ends the run through BdbQuit.
Profile a training loop with cProfile and identify the slowest function. Then fix what the profile names and prove the fix with a Timer. (The source's Exercise 2, extended.)Show one worked answer
Run `python -m cProfile -s cumtime train.py` and read the report top-down: rows are sorted by cumulative time, `ncalls` counts the calls, `tottime` is self time and `cumtime` includes callees. The chapter's worked model shows what to expect when the input pipeline is the problem: `DataLoader.__next__` at 18.0 s cumtime of a 30.0 s run (60%), `train_step` at 6.2 s, `Linear.forward` at 5.3 s, `Linear.backward` at 4.9 s, `optimizer.step` at 0.2 s. Two readings matter: the top row is the finding, and `train_step`'s small tottime (0.1 s) is why you must not read the list as 'train_step is fast'. Then apply the fix — `DataLoader(dataset, batch_size=64, shuffle=True, num_workers=4, pin_memory=True)` — and verify with `with Timer("data loading")` around `next(dataloader_iter)`: the main-process wait drops to about 0.8 s of a 12.8 s run while forward and backward are unchanged. Do not profile the whole 8-hour run: cProfile adds per-call overhead, so profile a few hundred steps for the ratios, then trust the Timer for the seconds.
Use tracemalloc to find which line in your data-loading pipeline allocates the most memory — and then work the OOM checklist in order on a GPU run that will not fit. (The source's Exercise 3, extended to the GPU.)Show one worked answer
CPU side: `tracemalloc.start()` before the pipeline, run a few steps, then `snapshot = tracemalloc.take_snapshot()` and print `snapshot.statistics("lineno")[:10]`. Read each line's size AND count: 1000 MiB in 50 objects of 20 MiB is one object per step (a leak — look for a list that keeps tensors; `.detach()` releases the graph, not the data), while 250 MiB in one object is a one-time allocation. Fix by keeping floats (`.item()`) instead of tensors, `del` the intermediates, and for a kernel that stays open `gc.collect()` or restart. `memory_profiler` gives the line-by-line view: `@profile` on the loader function, `python -m memory_profiler your_script.py`, and the two jumps in the Mem usage column are the two allocations. GPU side: `print(torch.cuda.memory_summary())` and read `memory_allocated()` (live tensors) against `memory_reserved()` (the allocator's pool); the arithmetic to plan with is 4 bytes per FP32 number — a [3, 224, 224] image is 0.6 MB, a batch of 32 is 19.3 MB, and Adam needs 16 bytes per parameter (weights + gradients + two moments), so 125M parameters is 2.0 GB before activations. Then the checklist in order: (1) halve the batch size 64 → 32 and watch the reserved number fall; (2) `torch.cuda.empty_cache()` to return unused cached blocks — it cannot free live tensors, so if reserved == allocated it will change nothing; (3) `del` the large intermediates and clear again; (4) `with torch.cuda.amp.autocast():` plus a `GradScaler` to halve the bytes per number (9.6 MB per batch instead of 19.3); (5) gradient checkpointing if the model is deep — the only option that costs speed.
Set up TensorBoard for a simple training run and identify whether the model is overfitting. Then check the histograms: are the weights collapsing or the gradients exploding? (The source's Exercise 4, extended with the histogram reading.)Show one worked answer
Add the writer loop: `writer = SummaryWriter("runs/experiment_1")`, `add_scalar("loss/train", loss.item(), step)` and `add_scalar("loss/val", val_loss, step)` every step (validation usually every N steps), `add_histogram(f"weights/{name}", param, step)` and `add_histogram(f"grads/{name}", param.grad, step)` inside `if step % 100 == 0`, then `writer.close()` and `tensorboard --logdir=runs`. The overfitting signature is two curves that separate: train loss keeps falling while val loss bottoms out and turns upward — the point where they cross is the early-stopping point, and the response is regularization, a smaller model, augmentation, or more data. If instead the train curve is still descending with val flat, suspect the data pipeline (train/val mismatch) before the architecture. The histograms answer the second question: `weights/fc1` narrowing onto zero is vanishing gradients (check the per-layer gradient norms — if they shrink layer by layer, revisit initialization or add normalization), and `grads/fc1` spreading wider with step count is the explosion whose end state is Inf and then NaN — `torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)` before `optimizer.step()` is the guard. Remember the cadence: histograms every 100 steps, because 5,000 steps × 40 tensors at every step would be 200,000 histogram writes — instrumentation that shows up in its own profile.
Terms this lesson borrows from later lessons (or outside)
You do not need to master these here. Each one gets a proper treatment in its own lesson; the one-line meaning is enough to keep reading. Orange dotted underlines in the prose point back to this list.
tensor, dtype and device — The vocabulary this lesson's `debug_print` patrols: a tensor's shape, its numeric type (float32 vs float64), and which device (CPU or GPU) holds it. Phase 1, Lesson 12 (Tensor Operations) explains shapes and strides properly; Phase 3, Lesson 11 (Introduction to PyTorch) puts tensors on the GPU.
autograd, .detach() and zero_grad() — Why a tensor kept in a list can hold its whole graph, why `.detach()` releases the graph but not the data, and why the optimizer needs a fresh zero each step. Phase 3, Lesson 11 (Introduction to PyTorch) builds the training loop that makes all of this concrete.
DataLoader and num_workers — The object whose `__next__` shows up at the top of every profile in this lesson, and the knob that fixes it: `num_workers > 0` loads batches in parallel worker processes, `pin_memory=True` stages them for a cheaper transfer to the GPU. Phase 3, Lesson 11 (Introduction to PyTorch).
Adam's optimizer state — The reason memory arithmetic in this lesson is 16 bytes per parameter: FP32 weights (4) + gradients (4) + two moment estimates (4 + 4). 125M parameters is 2.0 GB before activations. Phase 3, Lesson 6 (Optimizers) derives what those two moments are doing.
loss-curve diagnosis — The neural-network-specific half of the search: the chance level `ln C` (0.693 for two classes, 2.303 for ten), the overfit-one-batch test, numerical gradient checking, the learning-rate range test, and gradient clipping. Phase 3, Lesson 13 (Debugging Neural Networks) takes the evidence this lesson collects and turns it into a diagnosis.
KEEP GOING
A picture is a start. Practice is the rest.
This lesson is a port of an open course. Everything here traces back to it — and the next step is running the code yourself.
Lesson text adapted from AI Engineering from Scratch (Phase 00, Lesson 12), its quiz, its code/debug_tools.py and its outputs/prompt-debug-ai-code.md. Everything the source states is kept as-is: the 8-hour run that burns $200 and produces a model that predicts the mean, the three levels (standard Python, tensor operations, training dynamics) and the 80/20 claim, the debug_print field list, the conditional breakpoint with its pdb commands, the logging.basicConfig setup with a FileHandler and a StreamHandler, the Timer and the 60%-data-loading finding, `python -m cProfile -s cumtime`, line_profiler with `kernprof -l -v`, tracemalloc snapshots with `statistics('lineno')`, memory_profiler, the GPU calls (memory_summary, memory_allocated, memory_reserved), the OOM checklist order, check_shapes / detect_nan / check_data_leakage / check_devices with check_gradient_health, the TensorBoard SummaryWriter loop and its six patterns, the VS Code launch.json, the five-step workflow and the five exercises. Original to this page: the seven labs (the debug_print inspector, the conditional breakpoint simulator, the logging console, the profiler flame chart, the memory tracker, the bug taxonomy board and the TensorBoard pattern reader) plus the arithmetic they work with — 8 h × $25/h = $200 behind the opening line; the 100-step profile at 30.0 s (data 18.0 s = 60%, forward 6.2, backward 5.5, optimizer 0.2, self 0.1; GPU busy ~40%) versus 12.8 s after num_workers=4, i.e. 2.3×, taking 10,000 steps from 50.0 to 21.3 minutes and the 8-hour run from 8.0 to 3.4 hours (≈$200 to ≈$85 at the same invented rate); the faster-GPU trap (halving compute in the same profile reaches 24.0 s, a 1.25× win); the memory arithmetic ([3, 224, 224] FP32 = 0.6 MB per image and ≈19.3 MB per batch of 32, ≈9.6 MB at FP16; Adam's 16 bytes per parameter → 2.0 GB for 125M parameters); the shape arithmetic (28 × 28 = 784 versus 3 × 224 × 224 = 150,528, a 192× mismatch) and the gradient magnitudes 0 / ~1 / 37.4 / over 100; the histogram cadence (5,000 steps × 40 tensors = 200,000 writes versus 2,000 at every 100 steps); the memory hook (allocated is the water, reserved is the glass); and the forward cross-reference to Phase 3, Lesson 13 for the neural-network-specific diagnosis (chance levels ln C, overfit-one-batch, gradient checking, the learning-rate range test). Every simulation is labelled: the field values, the loss sequence, the profile, the growth curve and the TensorBoard patterns are teaching models built on the source's numbers, not measurements of your machine.