Use when training or debugging a neural net in PyTorch — the forward/loss/backward/step loop and its silent bugs, mixed precision (AMP), AdamW/LR schedules, DDP/FSDP/ZeRO, checkpoints and seeds. NOT LoRA/QLoRA on a pretrained LLM (that is `finetuning`), NOT tabular sklearn/XGBoost (that is `machine-learning`), NOT tokenization or NLP metrics (that is `nlp`).
Installs into .claude/skills of the current project.
Are you the author of Deep Learning?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/ericrisco-deep-learning)
---
name: deep-learning
description: "Use when training or debugging a neural net in PyTorch — the forward/loss/backward/step loop and its silent bugs, mixed precision (AMP), AdamW/LR schedules, DDP/FSDP/ZeRO, checkpoints and seeds. NOT LoRA/QLoRA on a pretrained LLM (that is `finetuning`), NOT tabular sklearn/XGBoost (that is `machine-learning`), NOT tokenization or NLP metrics (that is `nlp`)."
tags: [pytorch, deep-learning, neural-networks, training-loop, autograd, mixed-precision, amp, ddp, fsdp, optimizer, lr-scheduler, reproducibility, torch-compile]
recommends: [machine-learning, finetuning, nlp, python]
origin: risco
---
# deep-learning — train a neural net in PyTorch without the silent bugs
You own **training and understanding neural networks in PyTorch**: the loop, autograd, mixed
precision, optimizers/schedulers, multi-GPU, and the reproducibility/checkpoint hygiene that
separates a real result from a lucky one. Nets from scratch, vision, custom architectures — all
here. This is PyTorch-first by design: JAX and TensorFlow are real and fine, but the patterns,
APIs, and gotchas below are Torch's.
**Version reality (verify at author time).** Current stable is PyTorch **2.x** — ~**2.13** as of
mid-2026 (`pytorch.org/get-started`, releases move fast; don't hard-pin a minor). Everything below
is stable 2.x API. The one namespace shift to know: AMP now lives under `torch.amp`
(`torch.amp.GradScaler("cuda")`), not the old `torch.cuda.amp.*`.
## Am I in the right skill?
| You are doing… | Skill |
|---|---|
| Training/debugging a net in PyTorch (from scratch, vision, custom loop, AMP, multi-GPU) | **deep-learning (here)** |
| Adapting a *pretrained LLM* — LoRA/QLoRA, SFT, trl/peft | [`finetuning`](../finetuning/SKILL.md) |
| Classic/tabular — sklearn, XGBoost/LightGBM, feature engineering | [`machine-learning`](../machine-learning/SKILL.md) |
| Tokenization, NLP task modeling, task metrics (F1/BLEU/ROUGE) | [`nlp`](../nlp/SKILL.md) |
| Envs, packaging, tests and hygiene around the model code | [`python`](../python/SKILL.md) |
## 1. PyTorch essentials
Three objects carry everything.
- **Tensor** — an n-d array on a `device` (`cpu`/`cuda`/`mps`) with a `dtype`. `requires_grad=True`
makes autograd track ops on it. `.to(device)` / `.detach()` / `.item()` are the moves you use
constantly; `.item()` pulls a Python scalar and **drops the graph** (see the loop bugs below).
- **autograd** — a tape. Every op on a `requires_grad` tensor records a node; `loss.backward()`
walks it and *accumulates* into each leaf's `.grad`. "Accumulates" is the word that bites people
(§2). Wrap read-only regions in `torch.no_grad()` to skip taping.
- **`nn.Module`** — the model container. `__init__` registers submodules/params; `forward` defines
compute. `model.parameters()` feeds the optimizer; `model.train()` / `model.eval()` flip
train-vs-eval behavior for Dropout and BatchNorm.
```python
import torch, torch.nn as nn
device = "cuda" if torch.cuda.is_available() else "cpu"
class MLP(nn.Module):
def __init__(self, d_in, d_h, d_out, p=0.1):
super().__init__()
self.net = nn.Sequential(nn.Linear(d_in, d_h), nn.ReLU(),
nn.Dropout(p), nn.Linear(d_h, d_out))
def forward(self, x):
return self.net(x)
model = MLP(784, 256, 10).to(device)
```
**`torch.compile`** — wrap the model once and get a JIT-optimized (Dynamo→Inductor) graph, often a
real speedup with no code change: `model = torch.compile(model)`. Modes: `"default"` (balanced),
`"reduce-overhead"` (CUDA graphs, best for small batches), `"max-autotune"` (Triton autotuning,
slowest to warm up). First step(s) are slow — that's compilation, not your model. Keep it *after*
`.to(device)` and before the loop. (`pytorch.org/docs/stable/generated/torch.compile.html`)
## 2. The training loop — and its classic bugs
The whole game is five ordered steps. Get the order or the resets wrong and it fails *silently* —
no exception, just a model that won't learn.
```python
model.train() # Dropout/BN in TRAIN mode
for epoch in range(epochs):
for x, y in train_loader:
x, y = x.to(device), y.to(device)
optimizer.zero_grad(set_to_none=True) # clear LAST step's grads
out = model(x) # forward
loss = loss_fn(out, y) # loss (raw logits in, e.g. CrossEntropyLoss)
loss.backward() # autograd fills .grad (ACCUMULATES)
optimizer.step() # update weights from .grad
scheduler.step() # if a per-step scheduler
```
The load-bearing traps, stated next to the lines they govern:
- **Missing `optimizer.zero_grad()` → gradients silently add up across batches.** PyTorch
*accumulates* `.grad` by design (so you can split a batch); if you never zero it, every step
updates on the sum of all prior batches' grads and training destabilizes. Zero every step.
(`pytorch.org/tutorials` optimization tutorial; `torch.optim` docs.) `set_to_none=True` is the
default in modern Torch and is slightly faster.
- **`CrossEntropyLoss` eats raw logits.** It applies `log_softmax` internally — do **not** put a
`Softmax`/`Sigmoid` before it or you double-activate and learning stalls. Same for
`BCEWithLogitsLoss`.
- **Accumulating `loss` without `.item()` leaks memory.** `total += loss` keeps the whole autograd
graph alive across the epoch. Use `total += loss.item()` (or `.detach()`).
- **`backward()` twice on one graph** raises unless you meant it (`retain_graph=True`) — usually a
sign you forgot to zero or re-ran forward.
## 3. Mixed precision (AMP)
Run forward/loss in low precision for speed and memory, keep a master copy for the update. Two
pieces: `torch.autocast` (picks per-op precision) + `torch.amp.GradScaler` (rescues fp16 gradients
from underflow). Verified against `pytorch.org/docs/stable/notes/amp_examples.html`.
```python
scaler = torch.amp.GradScaler("cuda") # current namespace (not torch.cuda.amp)
for x, y in train_loader:
x, y = x.to(device), y.to(device)
optimizer.zero_grad(set_to_none=True)
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
out = model(x) # forward + loss ONLY under autocast
loss = loss_fn(out, y)
scaler.scale(loss).backward() # backward OUTSIDE autocast, on scaled loss
scaler.step(optimizer) # unscales, skips step if inf/NaN grads
scaler.update() # adjusts the scale for next step
```
**bf16 vs fp16 — the choice that matters:**
| dtype | Exponent range | Needs GradScaler? | Use when |
|---|---|---|---|
| **bf16** (`bfloat16`) | same as fp32 | **No** (range can't underflow) | Ampere+ (A100/H100/…) or CPU — **prefer this**, it's the robust default |
| **fp16** (`float16`) | narrow → underflows | **Yes** — scaler is mandatory | older GPUs without bf16; squeeze extra range with the scaler |
- With **bf16 you can skip `GradScaler`** entirely (`GradScaler(enabled=False)` or just don't use
it) — its whole job is fp16 underflow. With **fp16 the scaler is not optional.**
- **Gradient clipping under AMP:** `scaler.unscale_(optimizer)` first, *then*
`torch.nn.utils.clip_grad_norm_(...)`, then `scaler.step`. Clipping scaled grads clips the wrong
magnitude.
- Autocast wraps **forward + loss only**. Never wrap `backward()`.
## 4. Optimizers and LR schedules
- **`torch.optim.AdamW`** is the default for most modern nets (transformers, and increasingly
vision): Adam with *decoupled* weight decay (correct L2, unlike plain `Adam(weight_decay=)`).
**SGD + momentum + Nesterov** still wins on many CNNs and generalizes well — worth trying.
- **Don't weight-decay everything.** Biases and Norm (LayerNorm/BatchNorm) weights should get
`weight_decay=0`; use two param groups. Decaying them hurts with no upside.
- **The LR is the hyperparameter.** Too high → loss NaNs or oscillates; too low → crawls. A warmup
then cosine decay is the reliable modern recipe.
```python
opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01, betas=(0.9, 0.999))
# Warmup (linear ramp) THEN cosine decay, composed with SequentialLR — verified 2.x API.
from torch.optim.lr_scheduler import LinearLR, CosineAnnealingLR, SequentialLR
warmup = LinearLR(opt, start_factor=0.1, total_iters=500) # 500 steps ramp-in
cosine = CosineAnnealingLR(opt, T_max=total_steps - 500, eta_min=3e-5)
scheduler = SequentialLR(opt, [warmup, cosine], milestones=[500]) # switch at step 500
# call scheduler.step() every STEP here (T_max/total_iters are in steps).
```
Alternatively `OneCycleLR(opt, max_lr=..., total_steps=...)` bakes warmup+anneal into one
scheduler. **Match `step()` cadence to the scheduler's units** — per-step schedulers stepped once
per epoch (or vice-versa) silently give the wrong curve. (`pytorch.org/docs/stable/optim.html`)
## 5. Distributed training — pick by what doesn't fit
One line each on *when*; mechanics and launch commands in `references/distributed.md`.
- **DDP** (`torch.nn.parallel.DistributedDataParallel`) — replicate the **whole model** on each GPU,
all-reduce grads every step. Use when **the model fits on one GPU** and you just want more
throughput / a larger effective batch. Default choice; one process per GPU via `torchrun`.
- **FSDP2** (`torch.distributed.fsdp.fully_shard`) — **shard** parameters, gradients, and optimizer
states across GPUs (gather just-in-time). Use when **the model does NOT fit on one GPU.** FSDP2 is
the current DTensor-based, per-parameter API; apply it bottom-up (wrap layers, then the root).
- **DeepSpeed ZeRO** (external Microsoft library, not in core Torch) — config-driven sharding:
**ZeRO-1** shards optimizer states, **ZeRO-2** adds gradients, **ZeRO-3** adds parameters
(≈ FSDP), plus CPU/NVMe **offload**. Use when you want offload or config-first scaling, or already
live in the DeepSpeed/Accelerate ecosystem. Verify the current version — it moves independently.
Rule of thumb: fits on one GPU → **DDP**; too big → **FSDP2** (or ZeRO-3); need to offload to
CPU/NVMe → **ZeRO**.
## 6. Regularization, checkpointing, reproducibility
**Regularization** (reach for these when val loss diverges from train — i.e. overfitting):
weight decay (AdamW), `nn.Dropout`, data augmentation, early stopping on a val metric, label
smoothing (`CrossEntropyLoss(label_smoothing=0.1)`), and gradient clipping for stability (not
generalization).
**Checkpointing — save `state_dict`s, not the model object.** Pickling the module ties the file to
your code layout and breaks on refactor.
```python
torch.save({"epoch": epoch,
"model": model.state_dict(), # DDP/FSDP: model.module.state_dict(), rank 0 only
"optimizer": opt.state_dict(),
"scheduler": scheduler.state_dict(),
"scaler": scaler.state_dict()}, # AMP scale must survive a resume
"ckpt.pt")
# Resume: load model FIRST, then optimizer/scheduler; move optimizer state to device if needed.
```
Save optimizer + scheduler + scaler too, or a resumed run restarts its LR schedule and loss scale.
**Reproducibility — seed everything, and know the ceiling.** From
`pytorch.org/docs/stable/notes/randomness.html`, quoted: *"Completely reproducible results are not
guaranteed across PyTorch releases, individual commits, or different platforms. Furthermore,
results may not be reproducible between CPU and GPU executions, even when using identical seeds."*
```python
import random, numpy as np, torch
seed = 42
random.seed(seed); np.random.seed(seed); torch.manual_seed(seed) # covers CPU + all CUDA devices
torch.use_deterministic_algorithms(True) # errors on ops with no deterministic impl
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False # benchmark=True picks fastest algo → nondeterministic
# For CUDA >= 10.2, deterministic matmul also needs, set BEFORE launch:
# export CUBLAS_WORKSPACE_CONFIG=:4096:8
```
DataLoader workers need their own seeding (each worker forks its RNG), via `worker_init_fn` +
a seeded `generator` — full snippet in `references/training-recipes.md`. Determinism costs speed
(`benchmark=False` disables autotuned convs).
## Anti-patterns (the silent killers)
| Anti-pattern | What actually happens | Instead |
|---|---|---|
| Validating with the model left in `train()` mode, or without `torch.no_grad()` | Dropout keeps dropping and BatchNorm uses *batch* stats instead of running stats, so val numbers are wrong; the graph is still built and can OOM | `model.eval()` + `torch.no_grad()` around val/inference, `model.train()` after (`nn.Module.eval` / autograd docs, pytorch.org) |
| Reading a NaN loss as a modeling problem | It is almost always LR too high, fp16 with no `GradScaler`, unclipped exploding grads, or a `log(0)`/`0/0` in a custom loss | Bisect: drop LR 10×, clip grads, switch fp16→bf16 |
| fp16 with the scaler left off | Gradients underflow to zero → no learning, and no error to tell you | Keep `GradScaler` on fp16, or move to bf16 where underflow can't happen |
| A stray CPU tensor in a CUDA graph | Hard device-mismatch error mid-step, usually on the targets or a hand-built mask rather than the inputs | `.to(device)` inputs, targets **and** model |
| Promising bit-exact reproducibility from seeds | Not guaranteed across releases, commits, platforms, or CPU-vs-GPU even with identical seeds | Seed for debugging/regression and say so; state the caveat with the result (randomness note, pytorch.org) |