Python from First PrinciplesPost 15 of 20 · 20 of 20 written
- How to use this guide
- 01What Python actually is
- 02Names, objects and references
- 03Built-in types and what they cost
- 04Control flow and expressions
- 05Functions in depth
- 06Iteration and generators
- 07Classes and the data model
- 08Context managers and exceptions
- 09Modules, packages, imports and environments
- 10Type hints and Pydantic
- 11The performance model
- 12Concurrency and parallelism
- 13NumPy: the array model everything else copies
- 14Data and serialization
- 15PyTorch mental model
- 16AI systems engineering in Python
- 17Tooling, debugging and testing
- 18Asking the right questions
- 19Roadmap and build-to-learn projects
- 20Glossary and resources
15. PyTorch mental model
A PyTorch tensor is a NumPy-style array that also knows its device and can record the operations that produced it. Training is three moves: run the forward computation, let autograd walk that record backward to get gradients, and let the optimizer update the parameters.
Tensors: array, plus device, plus autograd
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
x = torch.randn(32, 128, device=device) # float32
w = torch.randn(128, 10, device=device, requires_grad=True)
y = x @ w # (32, 10)
y.shape, y.dtype, y.device, y.requires_grad- Shapes, strides, views and broadcasting work as in NumPy (Part 13). PyTorch says
dimwhere NumPy saysaxis. - Every tensor in an operation must live on the same device. “Expected all tensors to be on the same device” means something was not moved.
torch.from_numpy(a)shares memory witha. Going back requirest.detach().cpu().numpy().- In-place operations end in an underscore (
add_,zero_). They can break autograd if they overwrite a value that backward still needs.
Autograd from first principles
Each operation on a tensor that requires gradients records a node: its inputs, the operation, and how to compute its local derivatives. The result’s grad_fn points to that node. loss.backward() walks the graph in reverse, applies the chain rule, and accumulates gradients into the .grad of leaf tensors (your parameters).
a = torch.tensor(2.0, requires_grad=True)
b = torch.tensor(3.0, requires_grad=True)
c = a * b + b ** 2 # dc/da = b = 3; dc/db = a + 2b = 8
c.backward()
a.grad, b.grad # (tensor(3.), tensor(8.))- Gradients accumulate with
+=. That is why you calloptimizer.zero_grad()every step, and why gradient accumulation over micro-batches works. - The graph is rebuilt on every forward pass (“define-by-run”), so ordinary Python
ifandforinsideforwardjust work. - The graph holds intermediate activations until backward runs. That is where training memory goes.
.detach()cuts a tensor out of the graph;.item()returns a Python number and forces a GPU sync.torch.no_grad()andtorch.inference_mode()record no graph, so evaluation and inference use less memory and run faster.
Building a tiny autograd engine yourself (Part 19) removes the magic for good.
nn.Module
- A module is a container of parameters and submodules with a
forwardmethod. Part 7 explains whymodel(x)works and whysuper().__init__()andnn.ModuleListmatter. model.parameters()andmodel.named_parameters()yield every trainable tensor, recursively.model.state_dict()is an ordered dict of names to tensors. Save that, not the whole model object.model.train()andmodel.eval()flip a flag that changes dropout and batch-norm behavior. That is separate fromno_grad, and evaluation usually needs both.register_bufferstores state that moves with.to()and is saved, but is not trained, such as running statistics or attention masks.
The training loop, annotated
model = MLP(784, 256, 10).to(device)
opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01)
loss_fn = torch.nn.CrossEntropyLoss() # expects raw logits and integer class labels
for epoch in range(epochs):
model.train()
for xb, yb in train_loader: # DataLoader: batching, shuffling, workers
xb, yb = xb.to(device), yb.to(device)
logits = model(xb) # forward pass builds the graph
loss = loss_fn(logits, yb)
opt.zero_grad(set_to_none=True) # clear the previous step's gradients
loss.backward() # fill .grad on every parameter
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step() # update the parameters
model.eval()
correct = total = 0
with torch.no_grad():
for xb, yb in val_loader:
xb, yb = xb.to(device), yb.to(device)
pred = model(xb).argmax(dim=-1)
correct += (pred == yb).sum().item()
total += yb.numel()
print(f"epoch {epoch}: val acc {correct / total:.3f}")Dataset and DataLoader
- A map-style
Datasetimplements__len__and__getitem__(i), returning one example. - A
DataLoadersamples indices, calls__getitem__, and collates the examples into batch tensors. For variable-length text, a customcollate_fnpads sequences and builds attention masks. num_workersruns loading in separate processes;pin_memory=Truespeeds up copies to the GPU.- Iterable-style datasets handle streams too large to index.
Precision, speed and memory
- On modern GPUs, wrap the forward pass and loss in
torch.autocast(device_type="cuda", dtype=torch.bfloat16). float16 additionally needs a gradient scaler. torch.compile(model)fuses operations into faster kernels. The first call is slow because it compiles.- Memory levers: smaller batches with gradient accumulation, activation checkpointing, lower precision, frozen layers, and adapters such as LoRA.
Bugs and their signatures
| Symptom | Likely cause |
|---|---|
| Loss never decreases | missing opt.step(), zero_grad in the wrong place, learning rate off by 10×, layers in a plain list |
| Loss becomes NaN | learning rate too high, log of zero, float16 overflow, bad data |
| Eval metrics vary between runs of the same weights | model.eval() not called, so dropout is active |
| Out of memory only during evaluation | torch.no_grad() missing |
| Memory grows every step | storing loss tensors instead of loss.item() |
| “Expected all tensors to be on the same device” | a tensor or buffer created without device= or never moved |
| Runs fine, results wrong | broadcasting (n,) against (n, 1); softmax over the wrong dim; probabilities passed to CrossEntropyLoss |
| Low GPU utilization | data loading bottleneck, .item() or .cpu() syncs in the loop, tiny batches |
Reading transformer code
Open any Hugging Face model file with this lens: a config class, a module tree (embeddings, then N blocks of attention plus MLP, then an output head), and forward methods with shape comments. Trace one tensor’s shape through one block; everything after that is repetition.
Questions to ask:
- What device and dtype is every tensor on this line?
- Is a graph being built where none is needed?
- Is the model in train mode or eval mode?
- Does this loss function expect logits or probabilities, class indices or one-hot vectors?