LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 196 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/9
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningBayesian Inference from EvidenceStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/15
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageStatistical Learning and GeneralizationConformal Prediction and UncertaintyClustering and PCAProbabilistic Generative ModelsCore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingCausal Inference and CounterfactualsPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/25
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernancePrivacy-Preserving Machine LearningData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/17
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningTraining Run OperationsReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/31
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceAccelerator Architecture Field GuideCorrect GPU Profiling and BenchmarkingGPU Kernel Performance Engineering LabGPU Parallel Primitives LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingGPU Runtime Execution LabAI-Generated Kernel EngineeringModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingGPU Failure OperationsDistributed Inference Data PlaneServing Benchmark EngineeringA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning Agent System Design
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnML Algorithms & EvaluationPyTorch Training Loops
⚡MediumFine-Tuning & Training

PyTorch Training Loops

Master the mechanics of PyTorch training loops: canonical execution phases, autograd state transitions, AMP dynamic loss scaling, micro-batch accumulation, and resilient atomic checkpointing.

31 min read
Learning path
Step 45 of 196 in the full curriculum
Causal Inference and CounterfactualsDataset Pipelines and Data Quality

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

A training run reports steadily dropping loss, yet its saved incident router behaves erratically the moment you deploy it to staging. Nothing threw an exception. No crash log appeared in your terminal. Behind that quiet execution, one of several silent state bugs occurred: gradients carried over between mini-batches, Dropout stayed active during validation, or saving a live reference meant subsequent epochs quietly mutated your supposedly frozen checkpoint. We'll build an automated ticket router from scratch and trace every one of these state transitions directly.

Suppose an upstream NLP feature pipeline converts each incoming customer ticket into two normalized numerical features: an urgency score and a severity-language score. A binary target indicates whether the ticket requires specialist escalation (1) or standard routing (0). Untrained linear weights guess no better than a coin flip. A training loop passes examples forward through the model, measures prediction error with a loss function, calculates how each individual weight contributed to that error, and updates the weights in the direction of steepest descent.

The earlier causal reasoning lesson explains how to test whether a deployed model update actually improves user outcomes. An A/B test provides that evaluation. Neither technique builds the model itself. First, the loop has to learn it.

PyTorch doesn't hide this learning cycle behind a monolithic .fit() call: you write the loop yourself. That imperative design makes every tensor, gradient, and memory buffer inspectable at runtime. Later, when you scale up to fine-tune a large language model or run distributed training across multiple nodes, the fundamental handoffs remain identical: batches, logits, loss, gradients, optimizer updates, validation passes, and durable checkpoints.[1]Reference 1PyTorch: An Imperative Style, High-Performance Deep Learning Library.https://arxiv.org/abs/1912.01703[2]Reference 2Optimizing Model Parameters.https://docs.pytorch.org/tutorials/beginner/basics/optimization_tutorial.html

The gradient descent and cross-entropy concepts build on backpropagation and softmax classification. Here we implement them in executable PyTorch code. Each code example runs self-contained on CPU unless explicitly noted otherwise. The small numbers are designed to expose tensor mechanics clearly rather than optimize classification accuracy.

These examples were validated on PyTorch 2.x on CPU. Random seeds make the fixtures repeatable, but floating-point numbers can vary across different architectures or CUDA backends. Check the stated invariants, such as monotonic loss reduction or matching reloaded logits, rather than treating a last-decimal discrepancy as an error.[3]Reference 3Reproducibilityhttps://docs.pytorch.org/docs/stable/notes/randomness.html

Turn a ticket into tensors

We'll bypass raw text tokenization for now. The feature extraction pipeline has converted each incident ticket into two normalized floating-point numbers:

FeatureMeaningExample range
urgencyPhrases signaling strict deadlines, financial loss, or security exposure-2.0 to 2.0
severity_languageWords indicating system outages, downtime, or broad customer impact-2.0 to 2.0
label0 = standard queue, 1 = specialist escalation0 or 1

Every training instance consists of a two-element feature vector and an integer class target. A mini-batch stacks those rows into a two-dimensional tensor. Six incident tickets produce a feature tensor of shape (6, 2) and a label tensor of shape (6,).

01-ticket-tensors.py
1import torch 2 3features = torch.tensor( 4 [ 5 [-2.0, -1.2], 6 [-1.3, -0.8], 7 [-0.8, -1.4], 8 [0.9, 1.1], 9 [1.4, 0.8], 10 [1.8, 1.6], 11 ], 12 dtype=torch.float32, 13) 14labels = torch.tensor([0, 0, 0, 1, 1, 1], dtype=torch.long) 15 16print("features:", tuple(features.shape), features.dtype) 17print("labels:", tuple(labels.shape), labels.dtype) 18print("first ticket:", features[0].tolist(), "route:", labels[0].item())
Tensor contract
1features: (6, 2) torch.float32 2labels: (6,) torch.int64 3first ticket: [-2.0, -1.2000000476837158] route: 0

That output represents a strict interface contract. Floating-point features (torch.float32) pass through linear matrix multiplications, while integer targets (torch.long, equivalent to 64-bit integers) supply class indices. Mismatched shapes or floating targets will cause downstream loss functions to fail immediately.

Now that inputs and targets match the expected shapes, inspect how the model scores each ticket before introducing optimization.

Logits are scores, not probabilities

A linear classification head ending in nn.Linear(2, 2) produces two unconstrained scores for each ticket:

Output columnDestination route
0standard queue
1specialist escalation

These unnormalized outputs are logits. Logits can be positive, negative, or zero, and their values don't sum to one. For multi-class classification, PyTorch's nn.CrossEntropyLoss expects these raw logits alongside integer target indices. Under the hood, it combines log-softmax with negative log-likelihood in a single, numerically stabilized operation.[4]Reference 4CrossEntropyLosshttps://docs.pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html

Consider raw ticket logits [2.0, -1.0] for an example whose ground-truth class is 0. To see how cross-entropy scores this prediction, convert the logits to probabilities:

p0=e2.0e2.0+e−1.0=7.3897.389+0.368≈0.9526,−log⁡(p0)≈0.0486.p_0 = \frac{e^{2.0}}{e^{2.0} + e^{-1.0}} = \frac{7.389}{7.389 + 0.368} \approx 0.9526,\qquad -\log(p_0) \approx 0.0486.p0​=e2.0+e−1.0e2.0​=7.389+0.3687.389​≈0.9526,−log(p0​)≈0.0486.

The second ticket outputs logits [-0.5, 1.5] with ground-truth class 1. The correct class wins, but with less margin: p1≈0.8808p_1 \approx 0.8808p1​≈0.8808, yielding loss −log⁡(p1)≈0.1269-\log(p_1) \approx 0.1269−log(p1​)≈0.1269. The average loss over both tickets is (0.0486+0.1269)/2≈0.0878(0.0486 + 0.1269) / 2 \approx 0.0878(0.0486+0.1269)/2≈0.0878. PyTorch's default reduction="mean" computes exactly this batch average.

02-raw-logits.py
1import torch 2from torch import nn 3 4logits = torch.tensor([[2.0, -1.0], [-0.5, 1.5]], dtype=torch.float32) 5labels = torch.tensor([0, 1], dtype=torch.long) 6loss = nn.CrossEntropyLoss()(logits, labels) 7 8routes = ["standard", "escalate"] 9predictions = logits.argmax(dim=1).tolist() 10print("predicted routes:", [routes[index] for index in predictions]) 11print("cross entropy:", round(loss.item(), 4))
Raw logits and loss
1predicted routes: ['standard', 'escalate'] 2cross entropy: 0.0878

Failure case: Don't pass torch.softmax(logits, dim=-1) into CrossEntropyLoss. The loss function expects unnormalized logits. If you pass probabilities, CrossEntropyLoss treats them as raw scores and applies a second softmax normalization, distorting the objective and corrupting gradient calculations. A naive manual calculation like torch.log(torch.softmax(logits)) can also overflow or underflow when logits are large or small, whereas CrossEntropyLoss uses the log-sum-exp trick internally: log⁡∑jezj=c+log⁡∑jezj−c\log \sum_j e^{z_j} = c + \log \sum_j e^{z_j - c}log∑j​ezj​=c+log∑j​ezj​−c, where c=max⁡jzjc = \max_j z_jc=maxj​zj​.

Why should CrossEntropyLoss receive raw logits rather than probabilities from a separate softmax?

Answer

It combines log-softmax and negative log-likelihood in a numerically stable operation using the log-sum-exp trick. Applying softmax first makes the loss treat probabilities as unnormalized scores and apply another normalization, altering the objective and its gradients.

CrossEntropyLoss summarizes batch predictions into a single scalar value. That scalar quantifies the model's current error, but it hasn't changed any weights. To update parameters, we need to trace one batch through the training loop's five execution phases.

The five execution phases in a training step

Every iteration in a PyTorch training step executes five distinct phases in a precise order. Before checking the table below, determine which specific step actually alters the model's weight tensors:

PhasePyTorch statementPurposeParameter values change?
1optimizer.zero_grad(set_to_none=True)Clear stored gradients left by earlier iterations.No
2logits = model(xb)Run the forward pass and build the autograd computation graph.No
3loss = loss_fn(logits, yb)Score predictions against ground-truth labels.No
4loss.backward()Traverse the graph backward to populate .grad attributes.No
5optimizer.step()Update parameter values using the stored gradients.Yes

Calling loss.backward() doesn't modify weight values. It traverses the dynamic computation graph in reverse via the chain rule, computing partial derivatives of the scalar loss with respect to every trainable leaf tensor (requires_grad=True), and stores those values in each parameter's .grad field.[5]Reference 5Deep Learning.https://www.deeplearningbook.org/[6]Reference 6Autograd mechanicshttps://docs.pytorch.org/docs/stable/notes/autograd.html

The parameter values themselves remain completely unchanged until optimizer.step() runs. The optimizer reads the accumulated gradients, applies its update rule (such as SGD or Adam), and updates the parameter storage in place.

Why use set_to_none=True in optimizer.zero_grad()? Setting .grad = None rather than filling tensors with zeros provides three practical advantages:

  1. Memory efficiency: Setting .grad = None immediately deallocates the gradient tensor memory, lowering peak memory consumption between iterations.
  2. Autograd efficiency: When a parameter's .grad is None, the autograd engine during backward() simply assigns the newly computed gradient tensor (p.grad = grad) instead of performing an in-place addition (p.grad.add_(grad)), saving memory bandwidth and kernel overhead.[7]Reference 7Optimizer.zero_gradhttps://docs.pytorch.org/docs/stable/generated/torch.optim.Optimizer.zero_grad.html
  3. Unused parameter behavior: In architectures with conditional routing (such as mixture of experts), parameters not used in a forward pass keep .grad = None. Optimizers like Adam skip parameters with None gradients, whereas a zero-filled tensor would cause Adam's weight decay and momentum buffers to update those parameters anyway.

Trace this step with exact arithmetic. Consider two symmetric tickets: ticket [1.0, 1.0] has label 1 (escalate), and ticket [-1.0, -1.0] has label 0 (standard). Let WWW be a 2x2 weight matrix where row 0 scores class 0 and row 1 scores class 1. Initializing W=0W = 0W=0 with zero bias yields logits [0.0, 0.0] for both tickets. The predicted class probabilities are [0.5, 0.5], producing a cross-entropy loss of −log⁡(0.5)≈0.6931-\log(0.5) \approx 0.6931−log(0.5)≈0.6931 for each row.

For cross-entropy loss with mean reduction, each row's gradient contribution to weight Wc,jW_{c,j}Wc,j​ is (pc−yc)⋅xj/N(p_c - y_c) \cdot x_j / N(pc​−yc​)⋅xj​/N, where N=2N=2N=2 is the batch size. Both tickets produce positive gradient values for class 0 weights and negative gradient values for class 1 weights, yielding an aggregate gradient matrix dW=[0.50.5−0.5−0.5]dW = \begin{bmatrix} 0.5 & 0.5 \\ -0.5 & -0.5 \end{bmatrix}dW=[0.5−0.5​0.5−0.5​]. The bias gradients cancel to zero. loss.backward() leaves WWW at zero while writing dWdWdW into W.gradW\text{.grad}W.grad. Finally, SGD with learning rate η=0.2\eta = 0.2η=0.2 updates the weights:

W1=W0−η⋅dW=[0000]−0.2⋅[0.50.5−0.5−0.5]=[−0.1−0.10.10.1].W_1 = W_0 - \eta \cdot dW = \begin{bmatrix} 0 & 0 \\ 0 & 0 \end{bmatrix} - 0.2 \cdot \begin{bmatrix} 0.5 & 0.5 \\ -0.5 & -0.5 \end{bmatrix} = \begin{bmatrix} -0.1 & -0.1 \\ 0.1 & 0.1 \end{bmatrix}.W1​=W0​−η⋅dW=[00​00​]−0.2⋅[0.5−0.5​0.5−0.5​]=[−0.10.1​−0.10.1​].
Exact PyTorch optimizer update showing a two-ticket input matrix, zero initial weights, cross-entropy loss 0.6931, the gradient matrix written by backward, unchanged weights after backward, SGD weights after a 0.2 learning-rate step, and the resulting correct route logits.
In the exact zero-initialized fixture, backward() fills dW but leaves W unchanged. With learning rate 0.2, optimizer.step() creates weights [[-0.1, -0.1], [0.1, 0.1]], which route the positive ticket to escalation and the negative ticket to the standard queue.

Let's execute this exact mathematical calculation in PyTorch to confirm every state transition.

03-one-update.py
1import torch 2from torch import nn 3 4features = torch.tensor([[1.0, 1.0], [-1.0, -1.0]], dtype=torch.float32) 5labels = torch.tensor([1, 0], dtype=torch.long) 6model = nn.Linear(2, 2) 7with torch.no_grad(): 8 model.weight.zero_() 9 model.bias.zero_() 10 11optimizer = torch.optim.SGD(model.parameters(), lr=0.2) 12loss_fn = nn.CrossEntropyLoss() 13 14before = model.weight.detach().clone() 15optimizer.zero_grad(set_to_none=True) 16loss = loss_fn(model(features), labels) 17loss.backward() 18gradient = model.weight.grad.detach().clone() 19after_backward = model.weight.detach().clone() 20optimizer.step() 21after_step = model.weight.detach().clone() 22 23print("loss:", round(loss.item(), 4)) 24print("gradient filled:", bool(gradient.abs().sum() > 0)) 25print("changed by backward:", not torch.equal(before, after_backward)) 26print("changed by step:", not torch.equal(before, after_step)) 27torch.testing.assert_close(gradient, torch.tensor([[0.5, 0.5], [-0.5, -0.5]])) 28torch.testing.assert_close(after_step, torch.tensor([[-0.1, -0.1], [0.1, 0.1]]))
One optimizer update
1loss: 0.6931 2gradient filled: True 3changed by backward: False 4changed by step: True

The output confirms the state boundaries: loss.backward() populated .grad without touching parameter values, and optimizer.step() moved the parameters. What happens if leftover gradients from an earlier iteration remain in .grad when the next backward() executes?

Clearing gradients and micro-batch accumulation

PyTorch adds newly computed gradients into existing .grad buffers rather than overwriting them (param.grad += new_grad). That accumulation default is intentional: it enables gradient accumulation, letting multiple micro-batches build up an effective batch update when memory is constrained. In standard training where each mini-batch should trigger an independent step, failing to clear gradients causes stale derivatives to pollute future updates.

To observe this accumulation directly, differentiate y=w2y = w^2y=w2 twice at w=1.0w = 1.0w=1.0. The derivative is dydw=2w=2.0\frac{dy}{dw} = 2w = 2.0dwdy​=2w=2.0. Calling backward() a second time without clearing gradients adds another 2.02.02.0, producing an accumulated gradient of 4.04.04.0. Resetting weight.grad = None restores the clean single-step value:

04-zero-grad.py
1import torch 2 3weight = torch.tensor(1.0, requires_grad=True) 4 5(weight**2).backward() 6first_grad = weight.grad.item() 7 8(weight**2).backward() 9accumulated_grad = weight.grad.item() 10 11weight.grad = None 12(weight**2).backward() 13cleared_grad = weight.grad.item() 14 15print("first backward:", first_grad) 16print("without clearing:", accumulated_grad) 17print("after clearing:", cleared_grad)
Accumulating gradients
1first backward: 2.0 2without clearing: 4.0 3after clearing: 2.0

Without clearing, the derivative silently doubles. Calling optimizer.zero_grad(set_to_none=True) inside the batch loop ensures each update depends solely on its own data.[7]Reference 7Optimizer.zero_gradhttps://docs.pytorch.org/docs/stable/generated/torch.optim.Optimizer.zero_grad.html

Gradient accumulation across micro-batches

When a model or batch size exceeds available GPU memory, gradient accumulation lets you simulate a larger effective batch size Beff=K×BmicroB_{\text{eff}} = K \times B_{\text{micro}}Beff​=K×Bmicro​ using KKK smaller micro-batches of size BmicroB_{\text{micro}}Bmicro​.

The loss function usually averages errors over the current micro-batch: Lk=1B∑i=1Bℓ(xk,i,yk,i)L_k = \frac{1}{B} \sum_{i=1}^B \ell(x_{k,i}, y_{k,i})Lk​=B1​∑i=1B​ℓ(xk,i​,yk,i​). The true mean loss over the combined batch of K⋅BK \cdot BK⋅B examples is:

Leff=1K⋅B∑k=1K∑i=1Bℓ(xk,i,yk,i)=1K∑k=1K(1B∑i=1Bℓ(xk,i,yk,i))=1K∑k=1KLk.L_{\text{eff}} = \frac{1}{K \cdot B} \sum_{k=1}^K \sum_{i=1}^B \ell(x_{k,i}, y_{k,i}) = \frac{1}{K} \sum_{k=1}^K \left( \frac{1}{B} \sum_{i=1}^B \ell(x_{k,i}, y_{k,i}) \right) = \frac{1}{K} \sum_{k=1}^K L_k.Leff​=K⋅B1​k=1∑K​i=1∑B​ℓ(xk,i​,yk,i​)=K1​k=1∑K​(B1​i=1∑B​ℓ(xk,i​,yk,i​))=K1​k=1∑K​Lk​.

Because autograd sums gradients across multiple backward() calls, you must divide each micro-batch loss by KKK before backpropagating: (loss / K).backward(). If you skip this division, the accumulated gradient will be KKK times larger than the true average, which acts like multiplying your learning rate by KKK and frequently causes training to diverge.[8]Reference 8Automatic Mixed Precision Exampleshttps://docs.pytorch.org/docs/stable/notes/amp_examples.html

In distributed training using DistributedDataParallel (DDP), every backward() call triggers an AllReduce network synchronization across all GPUs. Calling AllReduce on every micro-batch wastes network bandwidth. PyTorch provides the model.no_sync() context manager to suppress gradient communication during the first K−1K-1K−1 micro-batches, synchronizing gradients across nodes only on the final step right before optimizer.step().[9]Reference 9DistributedDataParallelhttps://docs.pytorch.org/docs/stable/generated/torch.nn.parallel.DistributedDataParallel.html

What happens if optimizer.zero_grad() runs only once per epoch instead of once per mini-batch in standard training?

Answer

Each backward pass adds newly computed derivatives into existing .grad buffers. Later updates combine current gradients with stale gradients from earlier batches, destabilizing optimization. Reset inside the batch loop unless accumulation is deliberate.

One update on a synthetic fixture verifies autograd mechanics, but it doesn't constitute a complete loop. We need abstractions to manage datasets and partition rows into mini-batches.

Batching and row-weighted epoch loss

A dataset stores samples and their corresponding labels. A data loader coordinates batching, worker multiprocessing, and shuffling between epochs. Shuffling ensures the optimizer encounters diverse gradient directions across iterations, preventing cyclical oscillation.

Using our six tickets with batch_size=4, the data loader yields two batches: the first contains four rows, and the second contains the remaining two rows.

05-dataloader.py
1import torch 2from torch.utils.data import DataLoader, TensorDataset 3 4features = torch.tensor( 5 [ 6 [-2.0, -1.2], 7 [-1.3, -0.8], 8 [-0.8, -1.4], 9 [0.9, 1.1], 10 [1.4, 0.8], 11 [1.8, 1.6], 12 ], 13 dtype=torch.float32, 14) 15labels = torch.tensor([0, 0, 0, 1, 1, 1], dtype=torch.long) 16loader = DataLoader(TensorDataset(features, labels), batch_size=4, shuffle=False) 17 18for batch_index, (xb, yb) in enumerate(loader, start=1): 19 print(f"batch {batch_index}: shape={tuple(xb.shape)} labels={yb.tolist()}")
Mini-batches
1batch 1: shape=(4, 2) labels=[0, 0, 0, 1] 2batch 2: shape=(2, 2) labels=[1, 1]

The final batch contains only two rows. When calculating average loss across an epoch, don't simply average the reported batch loss numbers. Because batch sizes differ, taking an unweighted mean of batch averages assigns excessive influence to the smaller batch.

To compute the exact epoch loss, weight each batch mean by its row count: sum the batch losses multiplied by their batch sizes, then divide by the total number of rows across the entire dataset. For instance, batches of size 4 and 2 with average losses 0.20 and 0.50 yield a true epoch average of (4×0.20+2×0.50)/6=0.30(4 \times 0.20 + 2 \times 0.50) / 6 = 0.30(4×0.20+2×0.50)/6=0.30, whereas an unweighted average of the two numbers would report 0.350.350.35.[4]Reference 4CrossEntropyLosshttps://docs.pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html

With data loading established, we can construct a minimal training loop. Before evaluating generalization on held-out data, we test whether the model can memorize a small, clean batch.

Overfitting one batch as an isolation test

Before training on large datasets, run an overfit-one-batch test. Take a single batch of clean, separable examples and verify that the model drives training loss to near zero. If the model can't memorize eight clean rows, don't debug data distributions or regularization: inspect label encodings, tensor shapes, loss configurations, learning rate scale, and the order of optimizer operations.

We'll define a multi-layer classifier: nn.Linear(2, 8) expands the two inputs to eight hidden activations, nn.ReLU() introduces non-linearity by zeroing negative values, and nn.Linear(8, 2) produces class logits.

06-overfit-one-batch.py
1import torch 2from torch import nn 3 4torch.manual_seed(7) 5features = torch.tensor( 6 [ 7 [-2.0, -1.2], [-1.3, -0.8], [-0.8, -1.4], [-1.6, -0.4], 8 [0.9, 1.1], [1.4, 0.8], [1.8, 1.6], [0.7, 1.7], 9 ], 10 dtype=torch.float32, 11) 12labels = torch.tensor([0, 0, 0, 0, 1, 1, 1, 1], dtype=torch.long) 13model = nn.Sequential(nn.Linear(2, 8), nn.ReLU(), nn.Linear(8, 2)) 14loss_fn = nn.CrossEntropyLoss() 15optimizer = torch.optim.SGD(model.parameters(), lr=0.15) 16 17with torch.no_grad(): 18 initial_loss = loss_fn(model(features), labels).item() 19 20for _ in range(120): 21 optimizer.zero_grad(set_to_none=True) 22 loss = loss_fn(model(features), labels) 23 loss.backward() 24 optimizer.step() 25 26with torch.no_grad(): 27 final_logits = model(features) 28 final_loss = loss_fn(final_logits, labels).item() 29 accuracy = (final_logits.argmax(dim=1) == labels).float().mean().item() 30 31print("initial loss:", round(initial_loss, 4)) 32print("final loss:", round(final_loss, 4)) 33print("memorized batch:", accuracy == 1.0)
Overfit one batch
1initial loss: 0.6521 2final loss: 0.0056 3memorized batch: True

The loss drops from 0.6521 to 0.0056 and classification accuracy reaches 100%. This passes the basic wiring check. Now we can separate training from held-out validation.

Isolating validation from training updates

Memorizing training rows proves that gradients flow through the model, but it provides zero evidence that the classifier generalizes to unseen tickets. To assess generalization, split the workflow into two phases:

  • Training phase: Mini-batches flow forward, autograd constructs the computation graph, loss.backward() calculates derivatives, and optimizer.step() updates parameters.
  • Validation phase: Held-out examples evaluate the current weights without updating parameters or recording gradient history.

Validation relies on two independent controls:

ControlResponsibility
model.eval()Configures layers like Dropout and batch normalization (BatchNorm) for evaluation.
torch.no_grad()Halts autograd graph construction, saving compute and memory.

The script below trains on eight tickets and tracks validation loss across four held-out tickets over 60 epochs.

07-train-and-validate.py
1import copy 2import torch 3from torch import nn 4from torch.utils.data import DataLoader, TensorDataset 5 6torch.manual_seed(7) 7train_x = torch.tensor( 8 [ 9 [-2.0, -1.2], [-1.3, -0.8], [-0.8, -1.4], [-1.6, -0.4], 10 [0.9, 1.1], [1.4, 0.8], [1.8, 1.6], [0.7, 1.7], 11 ], 12 dtype=torch.float32, 13) 14train_y = torch.tensor([0, 0, 0, 0, 1, 1, 1, 1], dtype=torch.long) 15val_x = torch.tensor([[-1.1, -0.6], [-0.5, -1.7], [1.0, 0.6], [1.7, 0.4]]) 16val_y = torch.tensor([0, 0, 1, 1], dtype=torch.long) 17 18loader = DataLoader(TensorDataset(train_x, train_y), batch_size=4, shuffle=True) 19model = nn.Sequential(nn.Linear(2, 8), nn.ReLU(), nn.Linear(8, 2)) 20loss_fn = nn.CrossEntropyLoss() 21optimizer = torch.optim.SGD(model.parameters(), lr=0.15) 22best_loss = float("inf") 23best_state = None 24best_epoch = None 25 26for epoch in range(1, 61): 27 model.train() 28 train_total = 0.0 29 train_count = 0 30 for xb, yb in loader: 31 optimizer.zero_grad(set_to_none=True) 32 loss = loss_fn(model(xb), yb) 33 if not torch.isfinite(loss): 34 raise RuntimeError(f"non-finite training loss at epoch {epoch}") 35 loss.backward() 36 optimizer.step() 37 train_total += loss.item() * len(yb) 38 train_count += len(yb) 39 40 model.eval() 41 with torch.no_grad(): 42 val_logits = model(val_x) 43 val_loss = loss_fn(val_logits, val_y).item() 44 if not torch.isfinite(torch.tensor(val_loss)): 45 raise RuntimeError(f"non-finite validation loss at epoch {epoch}") 46 47 if val_loss < best_loss: 48 best_loss = val_loss 49 best_epoch = epoch 50 best_state = copy.deepcopy(model.state_dict()) 51 52assert best_state is not None 53model.load_state_dict(best_state) 54model.eval() 55with torch.no_grad(): 56 selected_accuracy = (model(val_x).argmax(dim=1) == val_y).float().mean().item() 57 58print("best validation loss:", round(best_loss, 4)) 59print("selected epoch:", best_epoch) 60print("selected validation accuracy:", round(selected_accuracy, 3)) 61print("saved tensors:", len(best_state))
Training and validation
1best validation loss: 0.0118 2selected epoch: 60 3selected validation accuracy: 1.0 4saved tensors: 4

The checkpoint snapshot aliasing trap

Notice the call to copy.deepcopy(model.state_dict()) in line 432 above. A common bug is writing best_state = model.state_dict(). Calling model.state_dict() returns a dictionary containing direct references to the live parameter tensors. As future epochs update model weights, those changes mutate the referenced tensors. Your saved best_state quietly drifts toward the final epoch's weights rather than preserving the best checkpoint.[10]Reference 10Saving and Loading Modelshttps://docs.pytorch.org/tutorials/beginner/saving_loading_models.html

We can demonstrate this reference leak directly:

07b-checkpoint-snapshot.py
1import copy 2import torch 3from torch import nn 4 5model = nn.Linear(2, 2) 6with torch.no_grad(): 7 model.weight.zero_() 8 9aliased = model.state_dict() 10snapshot = copy.deepcopy(model.state_dict()) 11with torch.no_grad(): 12 model.weight.add_(1.0) 13 14print("plain state followed the update:", torch.equal(aliased["weight"], model.weight)) 15print("copied state stayed at zero:", torch.count_nonzero(snapshot["weight"]).item() == 0)
Snapshot independence
1plain state followed the update: True 2copied state stayed at zero: True
Measured training and validation cross-entropy for all 60 epochs of the ticket fixture, on a logarithmic loss axis. Validation falls from 0.535825 to 0.011815; epoch 60 is selected. Four validation cases are selection evidence, not a production accuracy guarantee.
In the seeded fixture, validation loss falls from 0.535825 at epoch 1 to 0.011815 at epoch 60, which becomes the selected checkpoint with held-out accuracy 1.0. Validation uses eval() and no_grad(); it never calls backward() or step().

The validation loop called model.eval() even though the model lacked Dropout or BatchNorm layers. Adding a mode-sensitive layer makes the purpose of that call immediately visible.

Operational modes and context managers

In a network composed strictly of linear transformations and ReLU activations, model.train() and model.eval() yield identical outputs because neither layer maintains mode-dependent state. In production architectures, however, several layers change their behavior across modes:

  • Dropout: During training (train()), Dropout randomly zeroes activations with probability ppp and scales surviving activations by 1/(1−p)1 / (1 - p)1/(1−p) to preserve expected magnitude. During evaluation (eval()), Dropout acts as an identity pass-through.
  • BatchNorm: During training, BatchNorm computes mean and variance across the current mini-batch and updates its exponential moving average running buffers (running_mean, running_var). During evaluation, it freezes buffer updates and normalizes activations using the accumulated running statistics.
  • LayerNorm: Unlike BatchNorm, LayerNorm normalizes across the hidden channel dimensions of each sample independently. Its forward pass is identical in both train() and eval().

The snippet below introduces Dropout to our classifier, runs the same input ticket twice in training mode, and then runs it twice in evaluation mode.

08-train-eval-modes.py
1import torch 2from torch import nn 3 4torch.manual_seed(0) 5model = nn.Sequential(nn.Linear(2, 6), nn.ReLU(), nn.Dropout(p=0.5), nn.Linear(6, 2)) 6ticket = torch.tensor([[1.2, 0.9]]) 7 8model.train() 9train_a = model(ticket) 10train_b = model(ticket) 11 12model.eval() 13with torch.no_grad(): 14 eval_a = model(ticket) 15 eval_b = model(ticket) 16 17print("training outputs equal:", torch.equal(train_a, train_b)) 18print("evaluation outputs equal:", torch.equal(eval_a, eval_b))
Train mode versus eval mode
1training outputs equal: False 2evaluation outputs equal: True

The output illustrates the separation of responsibilities: model.eval() controls layer-specific operational logic, while torch.no_grad() controls autograd tape construction. Calling one without the other addresses only half of validation.

Why do validation loops require both model.eval() and torch.no_grad()?

Answer

model.eval() switches mode-sensitive layers (like Dropout and BatchNorm) into deterministic evaluation behavior. torch.no_grad() separately stops the autograd engine from constructing a computation graph. Neither replaces the other.

Saving weights to disk produces the deployable artifact. Checkpoints also protect ongoing training against sudden hardware faults and preemption.

Resilient checkpointing and atomic state snapshots

A trained model isn't production-ready until its parameters and configurations are saved in a durable, reloadable format. Never save the Python model instance directly using torch.save(model, path). That approach uses Python's pickle to serialize class definitions and file paths, binding the checkpoint to your exact codebase structure.

Save the model's state dictionary (state_dict) instead. A state_dict is an ordered Python dictionary mapping parameter and buffer names to their underlying tensor storage.

The complete resumption bundle

If training will resume later, saving only model weights is insufficient. A complete checkpoint must capture all state required for exact continuation:

  1. model_state_dict: Trainable parameters and persistent buffers (such as BatchNorm running stats).
  2. optimizer_state_dict: Optimization state, including Adam's first moment (mmm) and second moment (vvv) vectors alongside step counts. If you omit optimizer state upon resumption, momentum resets to zero, causing abrupt parameter oscillations that degrade convergence.[10]Reference 10Saving and Loading Modelshttps://docs.pytorch.org/tutorials/beginner/saving_loading_models.html
  3. lr_scheduler_state_dict: Current learning rate step counter and cycle phase.
  4. scaler_state_dict: The dynamic loss scale factor and consecutive finite step count when using Automatic Mixed Precision.
  5. rng_state: Random number generator states across PyTorch CPU, CUDA (torch.cuda.get_rng_state_all()), Python random, and NumPy to ensure exact data shuffling and dropout repeatability upon resumption.[3]Reference 3Reproducibilityhttps://docs.pytorch.org/docs/stable/notes/randomness.html
  6. Metadata: The epoch index, global step count, validation metrics, git commit hash, and dataset split identifier.

Preventing corruption with atomic file writes

On compute clusters with spot instances or preemptible cloud nodes, a training process can be terminated without warning. If preemption strikes while torch.save(checkpoint, "checkpoint.pt") is mid-write, the target file is left truncated. When the job restarts, torch.load crashes with EOFError: Ran out of input, permanently destroying days of training progress.

To make checkpointing resilient against preemption, write atomically:

  1. Serialize the state bundle to a temporary staging file in the same filesystem directory (checkpoint.pt.tmp).
  2. Flush application buffers and invoke os.fsync(file_descriptor) to guarantee the operating system commits write caches to physical storage.
  3. Call os.replace("checkpoint.pt.tmp", "checkpoint.pt"). On POSIX systems, os.replace is an atomic filesystem operation. At any instant, the filesystem points either to the previous valid checkpoint or the new complete checkpoint, eliminating the risk of corrupted files.
Resilient atomic checkpointing architecture showing the complete state dict bundle with model, optimizer moments, scheduler, GradScaler, and RNG state, contrasting naive direct torch.save that leaves corrupt files on preemption against the atomic pattern using staging write, fsync, and POSIX os.replace.
Preemption-resilient checkpointing protocol: the full state bundle captures model, optimizer momentum buffers, scheduler trajectory, scaler state, and RNG seeds. Saving to a staging file with os.fsync followed by atomic os.replace ensures a crash never corrupts on-disk checkpoints.

In PyTorch 2.6 and later, torch.load(..., weights_only=True) is the default standard, restricting unpickling to tensors, primitive values, and basic containers to prevent arbitrary code execution vulnerabilities.[11]Reference 11Serialization Semanticshttps://docs.pytorch.org/docs/stable/notes/serialization.html

09-save-and-reload.py
1import os 2from pathlib import Path 3from tempfile import TemporaryDirectory 4 5import torch 6from torch import nn 7 8torch.manual_seed(11) 9features = torch.tensor([[-1.5, -1.0], [-0.9, -1.2], [1.1, 0.8], [1.6, 1.4]]) 10labels = torch.tensor([0, 0, 1, 1], dtype=torch.long) 11model = nn.Linear(2, 2) 12optimizer = torch.optim.SGD(model.parameters(), lr=0.2) 13loss_fn = nn.CrossEntropyLoss() 14 15for _ in range(80): 16 optimizer.zero_grad(set_to_none=True) 17 loss = loss_fn(model(features), labels) 18 loss.backward() 19 optimizer.step() 20 21model.eval() 22with torch.no_grad(): 23 original_logits = model(features) 24 original = original_logits.argmax(dim=1) 25 26with TemporaryDirectory() as directory: 27 final_path = Path(directory) / "incident-router.pt" 28 tmp_path = Path(directory) / "incident-router.pt.tmp" 29 30 checkpoint = { 31 "model_state_dict": model.state_dict(), 32 "optimizer_state_dict": optimizer.state_dict(), 33 "model_config": {"in_features": 2, "out_features": 2}, 34 "feature_names": ["urgency", "severity_language"], 35 "label_names": ["standard", "escalate"], 36 "training_steps": 80, 37 } 38 39 with open(tmp_path, "wb") as f: 40 torch.save(checkpoint, f) 41 f.flush() 42 os.fsync(f.fileno()) 43 os.replace(tmp_path, final_path) 44 45 loaded = torch.load(final_path, map_location="cpu", weights_only=True) 46 restored = nn.Linear(**loaded["model_config"]) 47 restored.load_state_dict(loaded["model_state_dict"]) 48 restored.eval() 49 with torch.no_grad(): 50 reloaded_logits = restored(features) 51 reloaded = reloaded_logits.argmax(dim=1) 52 53torch.testing.assert_close(original_logits, reloaded_logits) 54print("routes:", original.tolist()) 55print("reload agrees:", torch.equal(original, reloaded)) 56print("labels:", loaded["label_names"]) 57print("has optimizer state:", "optimizer_state_dict" in loaded)
Reloaded checkpoint
1routes: [0, 0, 1, 1] 2reload agrees: True 3labels: ['standard', 'escalate'] 4has optimizer state: True

Testing reloaded logits against original outputs using torch.testing.assert_close verifies that parameter tensors loaded faithfully.

Diagnosing failures and bounding gradients

When a training script runs without throwing exceptions, subtle bugs can still compromise learning. Use this triage matrix to isolate root causes:

SymptomLikely causeDiagnostic check
Target shape error in CrossEntropyLossHard-label targets have float dtype or shape (batch, 1).Use 1D integer tensors with shape (batch,) and dtype torch.long.
Loss drops on one batch, but held-out accuracy failsOverfitting, data distribution shift, or label noise.Verify preprocessing consistency across data splits.
Inconsistent predictions during validationModel left in training mode with active Dropout or BatchNorm.Call model.eval() before running validation loops.
Gradients grow larger on each batchGradients aren't reset before backward passes.Call optimizer.zero_grad(set_to_none=True) inside the batch loop.
GPU out-of-memory error during loggingTracking raw loss tensors keeps computation graphs in memory.Accumulate loss.item() scalars instead of graph-attached tensors.
Loss becomes NaN or InfExploding gradients, extreme inputs, or numerical overflow.Check inputs, logits, and parameter gradients for non-finite values.

Five-step numerical triage when loss becomes NaN

When loss becomes non-finite, don't immediately reduce the learning rate blindly. Follow this ordered diagnostic sequence to find the earliest invalid value:

  1. Inputs: Check raw batch inputs (torch.isfinite(xb).all()). Unsanitized nulls or extreme feature values corrupt the forward pass immediately.
  2. Logits: Check model output scores (torch.isfinite(logits).all()). If inputs are finite but logits are NaN, examine numerical stability in custom layers or activations.
  3. Loss: Check loss output (torch.isfinite(loss)). If logits are finite but loss is Inf, verify class target indices and label smoothing parameters.
  4. Gradients: After backward(), check parameter gradients (torch.isfinite(p.grad).all()). If loss was finite but gradients exploded, examine deep layer backpropagation paths.
  5. Parameters: After optimizer.step(), check updated weights (torch.isfinite(p).all()).

Bounding gradients with global norm clipping

When dealing with deep recurrent networks or transformer architectures, gradient vectors can intermittently surge in magnitude. Gradient clipping enforces an upper bound on gradient size before the optimizer applies parameter updates.

PyTorch's nn.utils.clip_grad_norm_ calculates the total ℓ2\ell_2ℓ2​ norm across all model parameters concatenated into a single vector:

gnorm=∑p∈Θ∥∇pL∥22.g_{\text{norm}} = \sqrt{\sum_{p \in \Theta} \|\nabla_p L\|_2^2}.gnorm​=p∈Θ∑​∥∇p​L∥22​​.

If gnormg_{\text{norm}}gnorm​ exceeds max_norm, the function scales all gradients down proportionally:

∇pL←∇pL⋅max_normmax⁡(gnorm,max_norm).\nabla_p L \leftarrow \nabla_p L \cdot \frac{\text{max\_norm}}{\max(g_{\text{norm}}, \text{max\_norm})}.∇p​L←∇p​L⋅max(gnorm​,max_norm)max_norm​.

Global vector norm clipping preserves the exact direction of the gradient vector in parameter space, adjusting only its step magnitude. In contrast, per-parameter value clipping (clip_grad_value_) truncates individual elements independently, which distorts the search trajectory.[12]Reference 12clip_grad_norm_https://docs.pytorch.org/docs/stable/generated/torch.nn.utils.clip_grad_norm_.html

10-gradient-clipping.py
1import torch 2from torch import nn 3 4parameter = nn.Parameter(torch.tensor([3.0, 4.0])) 5parameter.grad = torch.tensor([6.0, 8.0]) 6 7before = torch.linalg.vector_norm(parameter.grad).item() 8reported_norm = nn.utils.clip_grad_norm_([parameter], max_norm=5.0, error_if_nonfinite=True).item() 9after = torch.linalg.vector_norm(parameter.grad).item() 10 11print("norm before:", round(before, 1)) 12print("reported norm:", round(reported_norm, 1)) 13print("norm after:", round(after, 1)) 14print("finite:", torch.isfinite(parameter.grad).all().item())
Gradient clipping check
1norm before: 10.0 2reported norm: 10.0 3norm after: 5.0 4finite: True

Setting error_if_nonfinite=True causes clip_grad_norm_ to raise a runtime error immediately if any gradient is NaN or Inf, halting training before non-finite updates corrupt model weights.

Inference mode versus no-grad contexts

During deployment or batch inference, the model generates predictions without ever calculating gradients. Autograd tracking is entirely unnecessary.

PyTorch offers two context managers for non-gradient execution:

  • torch.no_grad(): Disables dynamic graph recording, reducing compute and memory usage. However, it continues updating internal tensor version counters used for in-place mutation checks.
  • torch.inference_mode(): Disables both autograd graph recording and tensor version counter tracking. This provides higher execution speed and lower memory overhead, making it the preferred context manager for production serving, validation inference, and token generation loops.[6]Reference 6Autograd mechanicshttps://docs.pytorch.org/docs/stable/notes/autograd.html
11-inference-mode.py
1import torch 2from torch import nn 3 4model = nn.Linear(2, 2) 5with torch.no_grad(): 6 model.weight.copy_(torch.tensor([[-0.1, -0.1], [0.1, 0.1]])) 7 model.bias.zero_() 8model.eval() 9ticket = torch.tensor([[1.5, 1.2]]) 10 11with torch.inference_mode(): 12 logits = model(ticket) 13 prediction = logits.argmax(dim=1).item() 14 15print("gradient tracking:", logits.requires_grad) 16print("logits:", [round(value, 2) for value in logits[0].tolist()]) 17print("route class:", prediction)
Inference receipt
1gradient tracking: False 2logits: [-0.27, 0.27] 3route class: 1

Hardware accelerators alter this floating-point arithmetic to unlock higher training throughput.

Automatic mixed precision and dynamic loss scaling

Standard deep learning models perform arithmetic using 32-bit floating-point numbers (float32). On modern GPU architectures (such as NVIDIA Volta, Ampere, and Hopper), Tensor Cores execute 16-bit matrix multiplications with significantly higher throughput while cutting activation memory in half.

Automatic Mixed Precision (AMP) matches operations to their ideal precision: compute-intensive matrix multiplications run in lower precision (float16 or bfloat16), while sensitive operations (like softmax and reductions) remain in float32.[13]Reference 13Mixed Precision Training.https://arxiv.org/abs/1710.03740[14]Reference 14Automatic Mixed Precision package - torch.amphttps://docs.pytorch.org/docs/stable/amp.html

The float16 underflow hazard and GradScaler

Standard float16 reserves 5 bits for its exponent and 10 bits for its mantissa. That structure limits its minimum representable positive normal value to 2−14≈6.1×10−52^{-14} \approx 6.1 \times 10^{-5}2−14≈6.1×10−5. During backpropagation, parameter gradients frequently fall below 10−510^{-5}10−5. In pure float16, these tiny values underflow directly to zero, starving the optimizer of updates.

torch.amp.GradScaler resolves underflow through dynamic loss scaling:

  1. Forward pass: Operations execute inside torch.amp.autocast('cuda'). CrossEntropyLoss runs in float32 for numerical stability.
  2. Loss scaling: The scaler multiplies the scalar loss by a large scale factor SSS (initial default 216=65,5362^{16} = 65,536216=65,536). By linearity of differentiation, ∇W(S⋅L)=S⋅∇WL\nabla_W (S \cdot L) = S \cdot \nabla_W L∇W​(S⋅L)=S⋅∇W​L. This shifts gradient values upward by SSS, keeping them within the representable range of float16.
  3. Backward pass: Calling scaler.scale(loss).backward() calculates scaled gradients (S⋅∇WS \cdot \nabla WS⋅∇W).
  4. Unscaling before clipping: Before calling clip_grad_norm_, invoke scaler.unscale_(optimizer). This divides stored gradients by SSS so they reflect true magnitude. Order is critical here: if you clip before unscaling, you evaluate S⋅∇WS \cdot \nabla WS⋅∇W against max_norm, effectively clipping true gradients at max_norm/S\text{max\_norm} / Smax_norm/S and crushing updates to near zero!
  5. Conditional optimizer step: scaler.step(optimizer) inspects unscaled gradients. If all gradients are finite, it invokes optimizer.step(). If any gradient contains Inf or NaN (due to floating-point overflow), the scaler skips the optimizer step entirely, shielding weights from corruption.
  6. Dynamic scale update: scaler.update() adjusts the scale factor. If the step was skipped, it halves the scale (S←S×0.5S \leftarrow S \times 0.5S←S×0.5) and resets the consecutive clean counter. If training proceeds for 2,000 consecutive steps without non-finite gradients, it doubles the scale factor (S←S×2.0S \leftarrow S \times 2.0S←S×2.0) to maintain maximum numerical precision.[8]Reference 8Automatic Mixed Precision Exampleshttps://docs.pytorch.org/docs/stable/notes/amp_examples.html
Automatic Mixed Precision (AMP) dataflow showing FP32 master weights cast to FP16 in autocast, FP32 cross-entropy loss, loss scaling by factor S to prevent underflow, backward autograd producing scaled gradients, unscale before gradient clipping, and the two-branch outcome where clean steps update weights and double scale, while overflow skips the step and halves scale.
Automatic Mixed Precision dataflow: autocast executes forward passes in FP16/BF16 while maintaining FP32 master weights. GradScaler shifts loss by scale factor S before backward() to prevent underflow. Gradients must be unscaled before clipping. Clean steps update weights, while overflow events skip updates and halve the scale factor.

When training with bfloat16 on compatible hardware, the format's 8-bit exponent matches float32's dynamic range (∼1038\sim 10^{38}∼1038), which eliminates gradient underflow. Consequently, pure bfloat16 training typically omits GradScaler entirely.

12-amp-ordering-pattern.py
1import torch 2from torch import nn 3 4torch.manual_seed(7) 5device = torch.device("cuda" if torch.cuda.is_available() else "cpu") 6use_amp = device.type == "cuda" 7model = nn.Linear(2, 2).to(device) 8optimizer = torch.optim.SGD(model.parameters(), lr=0.1) 9loss_fn = nn.CrossEntropyLoss() 10xb = torch.tensor([[1.2, 0.8], [-1.0, -0.7]], device=device) 11yb = torch.tensor([1, 0], dtype=torch.long, device=device) 12scaler = torch.amp.GradScaler("cuda", enabled=use_amp) 13before = [parameter.detach().clone() for parameter in model.parameters()] 14 15optimizer.zero_grad(set_to_none=True) 16with torch.amp.autocast("cuda", dtype=torch.float16, enabled=use_amp): 17 logits = model(xb) 18 loss = loss_fn(logits, yb) 19 20scaler.scale(loss).backward() 21scaler.unscale_(optimizer) 22grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) 23scaler.step(optimizer) 24scaler.update() 25 26changed = any(not torch.equal(old, new) for old, new in zip(before, model.parameters())) 27print("AMP enabled:", use_amp) 28print("finite loss:", bool(torch.isfinite(loss))) 29print("finite unscaled gradient norm:", bool(torch.isfinite(grad_norm))) 30print("parameters changed:", changed)
AMP-compatible step
1AMP enabled: False 2finite loss: True 3finite unscaled gradient norm: True 4parameters changed: True

On CPU, enabled=False acts as a transparent pass-through: autocast is a no-op, scaler.scale(loss) returns the raw loss, unscale_ does nothing, and scaler.step(optimizer) executes optimizer.step() directly.

Graph compilation after eager verification

torch.compile optimizes PyTorch execution by capturing Python operations into a computational graph via TorchDynamo and compiling them into fused kernels via TorchInductor.[15]Reference 15PyTorch 2: Faster Machine Learning Through Dynamic Python Bytecode Transformation and Graph Compilationhttps://doi.org/10.1145/3620665.3640366[16]Reference 16torch.compilehttps://docs.pytorch.org/docs/main/generated/torch.compile.html

Compilation accelerates execution, but it doesn't fix logic errors. If your eager loop has misordered optimizer steps, unhandled NaN gradients, or leaky validation splits, torch.compile will simply compile those bugs. Always verify your loop's eager execution and numerical correctness before applying compilation.

We test graph capture below using backend="eager", which runs the captured graph through standard PyTorch operations to verify graph validity without requiring a GPU compiler.

13-compile-after-eager.py
1import torch 2from torch import nn 3 4torch.manual_seed(0) 5eager = nn.Linear(2, 2) 6compiled = torch.compile(eager, backend="eager") 7ticket = torch.tensor([[1.2, 0.8]]) 8 9eager_logits = eager(ticket) 10compiled_logits = compiled(ticket) 11print("compiled module:", type(compiled).__name__) 12print("logits match eager:", torch.allclose(eager_logits, compiled_logits))
Compile after eager
1compiled module: OptimizedModule 2logits match eager: True

If compiled outputs diverge from eager outputs, look for unsupported Python control flow or in-place tensor mutations. Graph breaks hurt runtime performance rather than producing numerical errors. Profile steady-state loop iterations separately to exclude one-time compilation warm-up overhead.[17]Reference 17Troubleshooting torch.compilehttps://docs.pytorch.org/docs/main/user_guide/torch_compiler/torch.compiler_troubleshooting.html

Artifacts and audit receipts for the training run

Before deploying a model to production, assemble an audit trail verifying training integrity:

Artifact componentOperational importance
Model architecture and optimizer configDocuments exact model hyperparameters and optimizer settings.
Epoch loss trajectories (train vs validation)Distinguishes steady optimization from overfitting or divergence.
Selected checkpoint and metric criteriaIdentifies the exact epoch weights promoted to serving.
Feature schema and label mappingsEnsures production feature pipelines match training tensor contracts.
Dataset split identifier and git commitGuarantees auditability and prevents data leakage across splits.

Practice: break and repair the loop

Solidify your understanding of loop mechanics by intentionally breaking individual steps in our runnable examples. Predict the failure symptom before running each test:

  1. In 03-one-update.py, comment out optimizer.step(). Predict whether changed by step remains True.
  2. In 04-zero-grad.py, invoke backward() three times before clearing gradients. Predict the resulting gradient magnitude.
  3. In 07b-checkpoint-snapshot.py, replace copy.deepcopy(...) with model.state_dict(). Predict which assertion fails when live weights change.
  4. In 08-train-eval-modes.py, leave the model in training mode during the second pair of predictions. Predict whether repeated forward passes match.
  5. In 12-amp-ordering-pattern.py, explain why calling scaler.unscale_(optimizer) before clip_grad_norm_ is mandatory.
  6. In 13-compile-after-eager.py, modify an eager weight after calling torch.compile and predict whether compiled output changes.

Expected observations

  1. Without optimizer.step(), autograd populates .grad, but parameter values remain unchanged (changed by step: False).
  2. The gradient accumulates to 6.0: each backward pass adds another derivative of 2.0 into .grad.
  3. copied state stayed at zero evaluates to False. Both dictionaries reference live parameter storage, so both follow the weight update.
  4. Dropout zeroes random neurons on each pass in training mode, causing repeated forward passes on identical inputs to disagree.
  5. GradScaler multiplies loss by SSS before backpropagation. Clipping before unscale_ compares S⋅∇WS \cdot \nabla WS⋅∇W against max_norm, effectively clipping true gradients at max_norm/S\text{max\_norm} / Smax_norm/S and eliminating updates.
  6. torch.compile wraps the original module rather than copying weights, so in-place edits to eager parameters reflect immediately in compiled outputs.
Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A classifier outputs unnormalized logits with shape (4, 2). The ground-truth class labels are yb = torch.tensor([0, 1, 0, 1], dtype=torch.long). Which loss invocation matches the required PyTorch contract?

Correct answer: nn.CrossEntropyLoss()(logits, yb), because the loss combines log-softmax and negative log-likelihood over raw logits and integer target indices.

CrossEntropyLoss applies log-softmax and negative log-likelihood in a single numerically stable operation using the log-sum-exp trick. Softmax before CrossEntropyLoss causes a redundant second normalization, corrupting gradients. Target indices must be integer class labels.

2.You clone a model's weights, call optimizer.zero_grad(set_to_none=True), run a forward pass, and execute loss.backward(). The gradient tensor is populated with non-zero values, yet parameter tensors equal their cloned counterparts. What explains this state?

Correct answer: loss.backward() computes derivatives and populates .grad, but parameter values don't move until optimizer.step() executes.

Autograd traverses the computation graph to write partial derivatives into parameter .grad attributes. It doesn't modify weight values. The optimizer reads those stored gradients and applies the parameter update formula during optimizer.step().

3.A model includes Dropout and BatchNorm layers. Validation must evaluate held-out loss without modifying weights or constructing gradient graphs. Which pattern is correct?

Correct answer: model.eval(); with torch.no_grad(): val_logits = model(val_x); val_loss = loss_fn(val_logits, val_y); omit backward() and step().

Validation requires both controls: model.eval() switches Dropout to identity and freezes BatchNorm running statistics, while torch.no_grad() halts autograd tape construction to conserve memory and compute.

4.You run an overfit-one-batch diagnostic on eight clean separable rows. After 120 optimizer updates, training loss remains high and accuracy does not improve. What should you investigate first?

Correct answer: Debug loop wiring, including label shapes and dtypes, loss input formatting, optimizer ordering, and learning rate scale.

The overfit-one-batch test isolates code wiring. A neural network should easily drive loss to near zero on eight separable rows. If it can't, investigate basic mechanics (label dtypes, loss inputs, learning rate, and zero_grad) before scaling up.

5.When training with CUDA Automatic Mixed Precision (AMP) and float16, why must scaler.unscale_(optimizer) be called before nn.utils.clip_grad_norm_?

Correct answer: Loss scaling multiplies gradients by scale factor S. Clipping before unscaling evaluates S * grad against max_norm, artificially crushing gradients by a factor of S.

GradScaler multiplies gradients by S to prevent FP16 underflow. If you clip before unscaling, you evaluate the scaled gradients against max_norm, effectively bounding true gradients at max_norm / S. Unscaling first restores true gradient magnitudes for accurate clipping.

6.In standard training, optimizer.zero_grad(set_to_none=True) is mistakenly called once before the epoch loop instead of inside the mini-batch loop. What happens during the second mini-batch?

Correct answer: The second mini-batch's gradients add into gradients left from the first batch, creating unintended gradient accumulation.

PyTorch accumulates newly computed derivatives into existing .grad buffers by default. Without zeroing between mini-batches, each update combines stale gradients from earlier batches with current gradients, corrupting optimization.

7.You implement gradient accumulation across K micro-batches to simulate a large effective batch. The loss function computes the mean over each micro-batch. How must each micro-batch loss be scaled before backward()?

Correct answer: Backpropagate (loss / K).backward(), because autograd sums gradients across micro-batches; dividing by K recovers the true batch mean.

Each micro-batch loss is an average over its own batch. Because autograd accumulates derivatives across multiple backward() passes, failing to divide by K makes accumulated gradients K times too large, effectively multiplying the learning rate by K.

8.Validation loss reaches an all-time low at epoch 14, but training continues for 50 epochs. Which in-memory assignment reliably preserves the model state from epoch 14?

Correct answer: best_state = copy.deepcopy(model.state_dict()), because a deep copy creates an independent snapshot detached from live tensor memory.

model.state_dict() returns a dictionary containing references to live parameter tensors. As future epochs update weights, those tensors mutate in place. A deep copy (or direct torch.save serialization) is required to preserve an independent snapshot.

9.Why is writing checkpoints to a temporary file followed by os.replace preferred over direct torch.save(bundle, 'checkpoint.pt')?

Correct answer: If a preemptible node terminates mid-write, direct saving leaves a truncated, corrupted checkpoint that crashes future restarts; atomic replacement guarantees disk holds either the old valid checkpoint or the new one.

If a compute job is preempted or crashes during torch.save, a directly overwritten checkpoint file is left half-written and unreadable. Staging to a temp file with fsync and executing an atomic POSIX replace ensures corrupt files never replace valid checkpoints.

10.How does torch.inference_mode() differ from torch.no_grad() for production model serving?

Correct answer: torch.inference_mode() disables both autograd graph recording and tensor version counter tracking, delivering lower overhead and faster execution.

While torch.no_grad() disables graph recording, tensors still maintain version counters for in-place mutation tracking. torch.inference_mode() disables both graph recording and version tracking, offering optimal performance for inference serving.

10 questions remaining.

Next Step
Continue to Dataset Pipelines and Data Quality

You can now train, validate, and reload a classifier from prepared tensors. Next you'll turn messy raw records into versioned train, validation, and test artifacts that those tensors can honestly come from.

PreviousCausal Inference and Counterfactuals
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

PyTorch: An Imperative Style, High-Performance Deep Learning Library.

Paszke, A., et al. · 2019 · NeurIPS 2019

https://arxiv.org/abs/1912.01703

Optimizing Model Parameters.

PyTorch Contributors · 2026 · Official tutorial

https://docs.pytorch.org/tutorials/beginner/basics/optimization_tutorial.html

Reproducibility

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/stable/notes/randomness.html

CrossEntropyLoss

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html

Deep Learning.

Goodfellow, I., Bengio, Y., Courville, A. · 2016

https://www.deeplearningbook.org/

Autograd mechanics

PyTorch Contributors · 2026 · Official documentation

https://docs.pytorch.org/docs/stable/notes/autograd.html

Optimizer.zero_grad

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/stable/generated/torch.optim.Optimizer.zero_grad.html

Automatic Mixed Precision Examples

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/stable/notes/amp_examples.html

DistributedDataParallel

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/stable/generated/torch.nn.parallel.DistributedDataParallel.html

Saving and Loading Models

PyTorch Contributors · 2026

https://docs.pytorch.org/tutorials/beginner/saving_loading_models.html

Serialization Semantics

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/stable/notes/serialization.html

clip_grad_norm_

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/stable/generated/torch.nn.utils.clip_grad_norm_.html

Mixed Precision Training.

Micikevicius, P., et al. · 2018

https://arxiv.org/abs/1710.03740

Automatic Mixed Precision package - torch.amp

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/stable/amp.html

PyTorch 2: Faster Machine Learning Through Dynamic Python Bytecode Transformation and Graph Compilation

Ansel, J., Yang, E., He, H., et al. · 2024 · ASPLOS 2024

https://doi.org/10.1145/3620665.3640366

torch.compile

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/main/generated/torch.compile.html

Troubleshooting torch.compile

PyTorch Contributors · 2026

https://docs.pytorch.org/docs/main/user_guide/torch_compiler/torch.compiler_troubleshooting.html

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.