30-second mental model
- Forward pass computes the loss; backprop multiplies local derivatives layer by layer (chain rule); the optimizer steps weights against the gradient.
- Depth only buys you something if a non-linearity sits between layers:
W2(W1x) = (W2W1)x. If a deep model scores exactly like logistic regression, the activations are missing. - Almost every knob you turn is one of three things: step size (lr, schedule), gradient noise (batch size), or capacity/constraint (width, depth, regularization).
- Training memory = params + grads + optimizer state + stored activations. That last term is why a batch that fits at inference OOMs during training.
Pick the output head and loss
Match head, activation and loss to the task
| Task | Final layer | Output activation | Loss (PyTorch) |
|---|---|---|---|
| Binary classification | Linear(h, 1) |
none (raw logit) | BCEWithLogitsLoss |
| Multi-class, one label | Linear(h, C) |
none (raw logits) | CrossEntropyLoss |
| Multi-label | Linear(h, C) |
none | BCEWithLogitsLoss |
| Regression | Linear(h, 1) |
none | MSELoss, or HuberLoss with outliers |
| Class probabilities at serving | - | softmax(logits, dim=-1) |
applied after the loss, not before |
Never apply softmax/sigmoid before
CrossEntropyLoss/BCEWithLogitsLoss- they take logits. Double-squashing trains, but badly, and silently.
Sanity-check loss at step 0
| Setup | Expected starting loss |
|---|---|
| Cross-entropy, C classes | ln(C) -> 10 classes = 2.303, 1000 = 6.91 |
| Binary cross-entropy | ln(2) = 0.693 |
| MSE on standardized target | ~1.0 |
Far off at step 0 means wrong label encoding or wrong head shape, not a bad model.
Fix class imbalance
CrossEntropyLoss(weight=w) or BCEWithLogitsLoss(pos_weight=n_neg/n_pos). Also init the final bias to log(p/(1-p)) so the model starts at the base rate instead of spending epochs getting there.
Choose an activation
| Function | Range | Use for | Watch out |
|---|---|---|---|
| ReLU | [0, inf) |
default hidden layers | units pushed negative die permanently |
| GELU / SiLU | ~`[-0.28, inf)` | transformers, modern convnets | slightly slower, otherwise free |
| LeakyReLU(0.01) | (-inf, inf) |
when many ReLUs are dead | rarely needed if lr is sane |
| tanh | [-1, 1] |
RNN candidate values, bounded outputs | saturates |
| sigmoid | (0, 1) |
gates, single binary output | max gradient 0.25 - never in deep hidden stacks |
Many dead ReLUs (a whole channel outputting 0 for all inputs) usually means the learning rate is too high, not that ReLU is wrong.
Set the optimizer and learning rate
Start from these defaults
| Optimizer | Defaults | Use when |
|---|---|---|
| AdamW | lr=3e-4 (transformers) or 1e-3 (small nets), betas=(0.9, 0.999), eps=1e-8, weight_decay=0.01-0.1 |
default; sparse gradients, embeddings, little tuning budget |
| SGD + momentum | lr=0.1 at batch 256, momentum=0.9, nesterov=True, weight_decay=1e-4 |
vision models chasing the last point of accuracy |
Adam converges faster; well-tuned SGD+momentum often generalizes slightly better.
Tune in this order
learning rate -> schedule/warmup -> batch size -> regularization strength -> depth/width. Move lr by factors of 3 or 10, not 10%. Sweeping everything at once wastes compute, because lr swamps the rest.
Pick a schedule
- Warmup for the first 1-5% of steps (or 500-2000 steps), linear from 0.
- Then cosine decay to ~0, or step decay (x0.1 at 50%/75%).
- Linear scaling rule: multiply batch size by k -> multiply lr by k, and add warmup or the first steps explode.
Find a starting lr in one run
LR range test: ramp lr exponentially over ~100 steps, plot loss, take roughly one order of magnitude below the lr where loss starts diverging.
Get the update order right
model.train()
for xb, yb in train_loader: # shuffle=True
optimizer.zero_grad(set_to_none=True)
loss = criterion(model(xb), yb) # criterion takes logits
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
scheduler.step() # per-step schedules only
Choose batch size and fit it in memory
Choose a batch size
- Start with the largest batch that fits comfortably, then confirm validation still holds up.
- Typical range 32-512. Below ~16 per device, BatchNorm statistics go unreliable - switch to GroupNorm or LayerNorm.
- Small batches inject noise that acts as mild regularization; very large batches need a larger lr plus warmup and can land in sharp minima that score worse held-out.
Estimate training memory
| Item (fp32) | Bytes per parameter |
|---|---|
| Weights | 4 |
| Gradients | 4 |
| Adam state (m and v) | 8 |
| Subtotal before activations | 16 |
Activations scale with batch size x sequence/resolution and dominate for big inputs.
Cut memory without shrinking the model
| Lever | Cost |
|---|---|
Gradient accumulation (loss/k, step every k) |
none - large effective batch, same allocation |
Mixed precision (bf16 preferred; fp16 needs a GradScaler) |
~2x saving, minor accuracy risk |
| Gradient checkpointing | ~sqrt memory in depth, ~30% slower |
| 8-bit optimizer / fused AdamW | saves optimizer state |
Regularize an overfitting model
Work through the fixes in this order
- Verify the split is honest - leakage, duplicate rows, time-shifted validation all fake this curve.
- Early stopping with best-checkpoint restore (patience 5-10). Free, recovers most of the gap.
- More data or augmentation. Beats every regularizer when available.
- Then raise weight decay, add dropout to the head, or shrink the model.
Pick the right regularizer
| Technique | Typical value | Use when |
|---|---|---|
| Weight decay (AdamW) | 0.01-0.1; 1e-4 for SGD | almost always; exclude biases and norm params |
| Dropout | 0.1 in transformers, 0.5 in wide FC heads | overfitting concentrated in the head |
| Label smoothing | 0.1 | val loss climbs while val accuracy still improves (overconfidence) |
| Data augmentation | task-specific | always, when cheap |
| L1 | rare | you want sparsity / feature selection |
Dropout stacked on BatchNorm often hurts - they disagree about variance. Pick one per block.
Keep dropout out of your validation numbers
Dropout is off at inference and activations are rescaled automatically. Forgetting model.eval() leaves it active, so validation looks noisy and worse than it is.
Normalize activations
Pick the normalization axis
| Norm | Statistics over | Use for |
|---|---|---|
| BatchNorm | the batch, per channel | convnets with batch >= 32 |
| LayerNorm | features of one example | transformers, RNNs, batch of 1, variable length |
| GroupNorm(32) | channel groups, per example | conv nets with tiny batches, detection, segmentation |
| RMSNorm | features, no mean subtraction | modern LLM blocks, cheaper than LayerNorm |
Stop BatchNorm from breaking at serving time
Training uses the current batch's mean/variance; inference uses running averages. Call model.eval() (or training=False). Symptoms of getting it wrong: the same input gives different answers depending on batch composition, and single-example inference is garbage.
Freezing a pretrained backbone with
requires_grad=Falsedoes not freeze its BN statistics. Put the backbone in.eval()too, or running stats drift while the frozen weights still expect the old ones.
Know why transformers use LayerNorm
Variable sequence lengths and padding make batch statistics meaningless, autoregressive decoding runs with an effective batch of 1, and layer norm needs no cross-device sync. Pre-LN blocks (norm before attention) train stably; post-LN needs careful warmup.
Initialize weights
| Situation | Choice |
|---|---|
| ReLU / GELU layers | He (Kaiming) normal, fan_out |
| tanh / sigmoid layers | Xavier (Glorot) |
| Biases | 0 |
| LSTM forget-gate bias | 1.0 to 2.0 |
| Last layer of a residual branch | zero-init, helps very deep stacks |
| All weights zero | never - identical gradients, the whole layer behaves like one unit |
Scale matters more than distribution: too small and activations collapse with depth, too large and they saturate. Normalization plus residuals make this forgiving on modern architectures; it still bites deep nets without norm and RNNs.
Diagnose gradient problems
| Symptom | Likely cause | Fix |
|---|---|---|
| Loss -> NaN in one step | exploding gradients, lr too high, fp16 overflow, log(0) |
clip_grad_norm_(params, 1.0), lower lr, bf16 or loss scaling, add eps |
| Only the last layers move | vanishing gradients | ReLU family, residual connections, norm layers, better init |
| Clipping fires on most steps | lr too high | lower lr and add warmup - clipping is a net, not a cure |
| Gradient norm ~0 everywhere | detached graph, dead ReLUs, saturated sigmoid | check for stray .detach() / no_grad, lower lr |
| Isolated loss spikes that recover | one bad batch: outliers, corrupt labels, unnormalized feature | log and inspect that batch, standardize inputs |
Clip by global norm across all parameters, not per element - norm clipping keeps the update direction and only shortens the step. With AMP, scaler.unscale_(optimizer) before clipping.
Size a convolutional layer
Compute the output size
out = (n + 2*p - k) // s + 1
# n=32, k=3, p=1, s=1 -> 32 (size preserved)
# n=32, k=3, p=1, s=2 -> 16 (halved)
# dilation d: replace k with d*(k-1) + 1
"Same" padding at stride 1: p = (k-1)//2 -> 3x3 uses p=1, 5x5 p=2, 7x7 p=3.
padding='same'does not preserve size once stride > 1, and even-sized kernels cannot pad symmetrically - the framework pads one side more and shifts your features.
Count conv parameters
(k * k * C_in + 1) * C_out. A 3x3 conv, 64 -> 128 channels: (9*64 + 1) * 128 = 73,856. Compare a fully connected layer on a 200x200 image into 1000 units: 40 million weights.
Reach for the right layer
| Goal | Layer |
|---|---|
| Downsample | stride-2 conv (keeps learnable detail) or 2x2 max pool |
| Change channel count / mix channels cheaply | 1x1 conv |
| Cut conv cost ~k^2 | depthwise (groups=C_in) + 1x1 pointwise |
| Head: grid -> vector | global average pooling, then one Linear (replaces flatten + big FC) |
| Pixel-accurate tasks | replace pooling with strided/dilated convs |
Max pool asks "did this feature fire nearby"; average pool keeps the general level. Max inside the network, global average at the end. Pooling has no learned weights.
Handle sequences with RNNs
Know why plain RNNs fail on long inputs
The same weight matrix multiplies the gradient at every timestep, so 100 steps means 100 similar factors multiplied. Below 1 the signal dies, above 1 it explodes. Dependencies more than a few dozen steps back never get learned.
Read the LSTM gates
| Gate | Controls | Note |
|---|---|---|
| Forget | how much of the old cell state survives | near 1 = memory kept and gradient flows; init bias to 1.0 |
| Input | how much new candidate content is written | candidate comes from a tanh layer, not a gate |
| Output | what part of the cell state becomes the hidden state | filters what the next layer sees |
The cell state updates by addition, which is the whole reason gradients travel further than in a plain RNN. It stretches the useful range; it does not remove the limit.
Choose LSTM vs GRU
GRU = 2 gates, no separate cell state, ~25% fewer parameters, slightly faster. Pick GRU for limited data, short sequences, tight compute. Pick LSTM for long sequences needing precise memory. The gap is usually smaller than what tuning the learning rate buys.
Batch variable-length sequences
Pad, then pack_padded_sequence(x, lengths, batch_first=True, enforce_sorted=False) so the RNN skips pad steps and the final state is the real last step. Mask padded positions out of the loss too.
Work with Transformers and attention
Recall the attention formula
Attention(Q, K, V) = softmax(Q K^T / sqrt(d_k)) V
| Vector | Role | Source in cross-attention |
|---|---|---|
| Query | what this position is looking for | decoder |
| Key | what a position advertises | encoder |
| Value | the content actually mixed in | encoder |
Query and key dims must match (dot product); the value dim is free.
Remember why the scores are scaled
Dot products of d_k-dim vectors have variance d_k. Unscaled, one score dominates, softmax saturates into near one-hot, and its gradient collapses. In fp16 the logits can overflow before softmax even runs.
Split the model dimension across heads
d_model = n_heads * d_head -> 8 heads x 64 = 512, costing about what one 512-dim head costs. More heads = more relations tracked, each in a smaller subspace. Head count is tuned, not maximized.
Mask the future in a decoder
Add a strictly-upper-triangular matrix of -inf to the scores before softmax, so illegal weights become 0. Without it, position 5 attends to position 6 and copies the answer: training loss looks wonderful and generation is nonsense.
Give the model a sense of position
| Scheme | Property |
|---|---|
| Sinusoidal | fixed, extrapolates past training length |
| Learned absolute | slightly better in-range, fails beyond the longest training sequence |
| RoPE / relative | injected inside attention, the current default for long contexts |
Attention is permutation equivariant - with no position signal, "dog bites man" and "man bites dog" encode identically.
Budget attention cost
- Time and memory:
O(n^2 * d)in sequence length. Doubling context quadruples attention work. - KV cache bytes ≈
2 * n_layers * n_kv_heads * d_head * seq_len * batch * bytes_per_elem. This is where most inference memory goes; grouped-query attention shrinksn_kv_headsspecifically to cut it.
Pick the architecture family
| Stack | Use for |
|---|---|
| Encoder-only | classification, retrieval, embeddings (sees both sides of a token) |
| Decoder-only | text generation (the default today) |
| Encoder-decoder | translation and other seq2seq with a distinct input |
Feed text into a model
Tokenize correctly
- Always serve a model with the exact tokenizer it was trained with. Different ids mean the input is nonsense.
- BPE merges the most frequent adjacent pair repeatedly until the vocab hits its target size. Frequent words stay whole; rare ones split.
- Rough budgeting for English: 1 token ≈ 4 characters ≈ 0.75 words.
- Merges follow frequency, not morphology - code identifiers, numbers, and languages thin in the training corpus fragment badly and cost extra tokens.
Read an embedding space
- Similarity is cosine (angle), not Euclidean distance.
- Closeness means "appears in similar contexts", which is not meaning: hot and cold sit close because they fill the same slots. Sentiment work built on cosine similarity breaks on this.
- word2vec/GloVe give one static vector per word, so senses of "bank" are averaged and unseen words have no vector at all. Use contextual embeddings from a transformer when polysemy matters.
Fine-tune a pretrained model
Follow the standard recipe
- Replace the output head to match your class count.
- Freeze the backbone (and put it in
.eval()so BN stats stay put), train the head at ~1e-3. - Unfreeze and fine-tune everything at 10-100x lower lr (1e-5 to 1e-4), watching validation.
- Optional: discriminative lrs, lower for earlier layers.
Know when it pays off
Pays off with scarce labels and a source domain close to yours (photos -> photos). Pays off less across a domain gap (photos -> medical scans). With millions of task-specific examples, from scratch can match it. Failure mode is negative transfer: aggressive fine-tuning on tiny data overwrites the representations you wanted.
Debug a run that will not train
Run these checks before touching the architecture
| Check | What it proves |
|---|---|
| Overfit a single batch to ~0 loss | the model/loss/optimizer wiring is correct; if it fails, it is a bug, not capacity |
Compare step-0 loss to ln(C) |
labels and head shape line up |
| Train on shuffled labels | if it still fits well, you have leakage or too much capacity |
| Log grad-norm, lr and loss every step | tells you overshoot vs bad batch vs vanishing |
Read the two loss-curve failure modes
| Curve | Meaning | Response |
|---|---|---|
| Train loss down, val loss up | overfitting | check split first, then early stop, then data/aug, then regularize |
| Val loss up but val accuracy still up | overconfidence, not overfitting | label smoothing, not more dropout |
| Steady oscillation around a plateau | step size too large for the curvature | halve lr, add decay, larger effective batch |
| Loss flat from step 0 | lr far too low, dead activations, broken data pipeline | LR range test, inspect a batch of inputs |
Pitfalls that cost the most time
- Missing
optimizer.zero_grad()- gradients accumulate silently across steps. - Missing
model.eval()andtorch.no_grad()at validation - dropout and BN corrupt the numbers. - Softmax applied before
CrossEntropyLoss, or one-hot float targets where int class ids are required. - Normalization statistics computed over train + validation together (leakage).
shuffle=Truemissing on the train loader.- Batch size changed without rescaling the learning rate.
- Frozen backbone whose BatchNorm running stats are still updating.
- Tokenizer or image preprocessing at serving that differs from training.
- Gradient descent has no guarantee of a global optimum on a non-convex surface, and in high dimensions most stationary points are saddles, not bad minima. The target is a flat low region that generalizes, not the lowest point - removing gradient noise can improve train loss and hurt test loss.