This presentation explains tensors, gradients, modules, losses, training, and inference without a companion project. Start with the included line-fitting lab and its verified output.
The core data structure
Automatic differentiation
Reusable layers
Linear, Embedding, ops
view, transpose, @, …
The 4-line pattern
no_grad, eval mode
Checkpoints & devices
Can gradients learn the unknown slope and intercept of a line?
Understand the data, the rule, and the required result. Then run the complete Python program and modify one thing at a time.
No companion presentation is required. Python syntax and any mathematical notation are explained as they are introduced.
The axes and counts expected by an operation.
A tensor the optimizer is allowed to update.
Loss sensitivity to a parameter.
Measure predictions without weight updates.
Trace each step and name what changed. Ask which fact stayed true and what should happen on an empty, invalid, or boundary input.
A tensor is a multi-dimensional array (like NumPy's ndarray) with two superpowers: it can run on a GPU, and it can track gradients automatically.
import torch # From a Python list x = torch.tensor([1, 2, 3]) # 1-D, shape (3,) x = torch.tensor([[1, 2], [3, 4]]) # 2-D, shape (2, 2) # Special constructors torch.zeros(2, 3) # 2x3 of zeros torch.ones(5) # length-5 of ones torch.arange(10) # [0,1,…,9] torch.randn(4, 8) # Gaussian noise, shape (4,8) torch.randint(0, 100, (2, 5)) # random ints in [0,100)
x.shape # torch.Size([2, 2]) x.dtype # torch.int64 (int literals; 1.0 → float32) x.device # device(type='cpu') x.numel() # 4 — total number of elements
A tensor of shape (2, 3):
A 4-D tensor in deep learning is typically (batch, time, heads, dim). You'll see this constantly in attention.
x.to("cuda") moves data to GPU. Operations on GPU tensors run on GPU.requires_grad=True, every op on it is recorded so we can compute derivatives later.
Every operation on a tensor with requires_grad=True creates a new tensor that remembers what op produced it and which tensors went in. The result is a directed graph from leaves (your params) to outputs (your loss).
# Two learnable scalars w = torch.tensor([2.0], requires_grad=True) b = torch.tensor([1.0], requires_grad=True) x = torch.tensor([3.0]) # input (no grad) # Forward pass — every op gets recorded a = w * x # 6.0 grad_fn=<MulBackward0> z = a + b # 7.0 grad_fn=<AddBackward0> y = z ** 2 # 49.0 grad_fn=<PowBackward0> print(y.grad_fn) # <PowBackward0 object at 0x…>
Autograd stores local derivative rules and composes them by the chain rule. It does not invert or undo the forward operation; noninvertible functions can still have useful derivatives.
| Kind | Example |
|---|---|
| Leaf w/ grad | w, b — params we'll update |
| Leaf w/o grad | x — input data, frozen |
| Intermediate | a, z, y — built by ops, hold a grad_fn |
Orange-rimmed = leaf with requires_grad. Purple = op node (knows how to compute its derivative). Green = output we'll backprop from.
Calling y.backward() walks the graph in reverse, multiplying local derivatives along each edge. The result lands in .grad on every leaf with requires_grad.
If y = f(g(x)) then dy/dx = f′(g(x)) · g′(x)
For a longer chain y = f(g(h(x))):
dy/dx = f′(g(h)) · g′(h) · h′(x)
Each op contributes its own local derivative. Autograd's job is to multiply them together — in the right order, automatically, for every parameter.
Each op type knows its own forward formula and its own derivative. PyTorch ships these as built-in Backward classes.
| Op | Forward | Local derivative | Value here |
|---|---|---|---|
| Mul MulBackward |
a = w · x |
∂a/∂w = x∂a/∂x = w |
∂a/∂w = 3∂a/∂x = 2 |
| Add AddBackward |
z = a + b |
∂z/∂a = 1∂z/∂b = 1 |
∂z/∂a = 1∂z/∂b = 1 |
| Pow PowBackward |
y = z² |
∂y/∂z = 2z |
∂y/∂z = 2·7 = 14 |
# Seed: ∂y/∂y = 1
∂y/∂w = ∂y/∂z · ∂z/∂a · ∂a/∂w
= 14 · 1 · 3 = 42
∂y/∂b = ∂y/∂z · ∂z/∂b
= 14 · 1 = 14
PyTorch does exactly this — but for millions of parameters at once, with matrix-valued local derivatives (Jacobians).
w = torch.tensor([2.0], requires_grad=True) b = torch.tensor([1.0], requires_grad=True) x = torch.tensor([3.0]) y = (w * x + b) ** 2 # 49.0 y.backward() # ← runs the whole reverse pass print(w.grad) # tensor([42.]) print(b.grad) # tensor([14.])
backward() twice adds to .grad. That's why we call optimizer.zero_grad() every step..grad. Intermediate tensors like a, z have a grad_fn but no .grad by default.backward() to save memory. Pass retain_graph=True if you really need to do it twice.Before any layers or optimizers, here is the whole idea of training in 15 lines. We learn w and b so that w·x + b matches data made from y = 2x + 1.
import torch
x = torch.tensor([1., 2., 3., 4., 5.])
y = 2 * x + 1 # the "true" rule
w = torch.zeros(1, requires_grad=True) # start knowing nothing
b = torch.zeros(1, requires_grad=True)
lr = 0.05
for step in range(501):
pred = w * x + b # 1. forward
loss = ((pred - y) ** 2).mean() # 2. MSE loss
loss.backward() # 3. fills w.grad, b.grad
if step in (0, 1, 10, 100, 500):
print(step, loss.item(), w.item(), b.item())
with torch.no_grad(): # 4. update, not tracked
w -= lr * w.grad
b -= lr * b.grad
w.grad.zero_(); b.grad.zero_() # 5. clear for next step
optimizer.step() and zero_grad() do steps 4 and 5 for you.step 0 loss 57.0000 w 0.000 b 0.000 step 1 loss 1.9400 w 2.500 b 0.700 step 10 loss 0.0222 w 2.097 b 0.651 step 100 loss 0.0010 w 2.021 b 0.925 step 500 loss 0.0000 w 2.000 b 1.000
It finds w = 2, b = 1. Nobody told it the rule. The gradients pointed the way.
−3, −5, −7, −9, −11. Mean of squares = 285 / 5 = 57.dL/dw = (2/5)·Σ(pred−y)·x = (2/5)·(−125) = −50dL/db = (2/5)·Σ(pred−y) = (2/5)·(−35) = −14w = 0 − 0.05·(−50) = 2.5, b = 0 − 0.05·(−14) = 0.7. Matches step 1.Every layer, every sub-network, every full model in PyTorch inherits from nn.Module. You define two things: what's inside (__init__) and what to compute (forward).
import torch.nn as nn
import torch.nn.functional as F
class FeedForward(nn.Module):
def __init__(self, d_model):
super().__init__()
# declare learnable sub-modules here
self.fc1 = nn.Linear(d_model, 4 * d_model)
self.fc2 = nn.Linear(4 * d_model, d_model)
def forward(self, x):
# describe the computation
return self.fc2(F.gelu(self.fc1(x)))
# use it
ff = FeedForward(128)
y = ff(x) # calls forward() under the hood
self.X that's a Module or Parameter.model.parameters() returns an iterator over all learnable tensors recursively. The optimizer uses this.model.to(device) moves everything at once.model.state_dict() dumps all weights for saving.model.train() / model.eval() toggles modes (dropout/batchnorm care).A regular Python list of modules is invisible to parameters(). Use nn.ModuleList:
# ❌ Wrong — params hidden from optimizer self.blocks = [Block(cfg) for _ in range(N)] # ✅ Right self.blocks = nn.ModuleList([Block(cfg) for _ in range(N)])
PyTorch ships with hundreds of layers. For an LLM, you only need a handful.
| Layer | What it does | Code example |
|---|---|---|
nn.Linear(in, out) |
Fully connected: y = x · Wᵀ + b (weight has shape (out, in)). The fundamental "linear neural network layer."
Params:
in_features = input vector size ·
out_features = output vector size ·
bias=True = whether to add learnable bias b
|
Teaching excerpt · complete runnable lab available from “Code” fc = nn.Linear(128, 256) y = fc(x) # x: (B, 128) → y: (B, 256) |
nn.Embedding(V, d) |
Lookup table: integer ID → vector ∈ ℝᵈ. The embedding layer from the LLM talk.
Params:
num_embeddings (V) = vocabulary size ·
embedding_dim (d) = dimension of each vector
|
Teaching excerpt · complete runnable lab available from “Code” emb = nn.Embedding(50257, 128) v = emb(token_ids) # (B,T) → (B,T,128) |
nn.LayerNorm(d) |
LayerNorm centers/scales over normalized_shape using an epsilon, then applies optional learned scale and bias. Its final output need not have exact zero mean or unit variance. | Teaching excerpt · complete runnable lab available from “Code” ln = nn.LayerNorm(128) y = ln(x) # (B, T, 128) → same shape |
nn.ModuleList |
A list container that does register its modules with the parent.
Params:
modules = iterable of nn.Module instances to register · no learnable params of its own
|
Teaching excerpt · complete runnable lab available from “Code” self.blocks = nn.ModuleList([
Block(cfg) for _ in range(N)
]) |
nn.Sequential |
Chain modules together — runs them in order.
Params:
*args = modules to chain (or one OrderedDict) · output of each becomes input of next · no learnable params of its own
|
Teaching excerpt · complete runnable lab available from “Code” net = nn.Sequential(
nn.Linear(128, 256),
nn.GELU(),
nn.Linear(256, 128),
) |
nn.Dropout(p) |
Randomly zero out p fraction of activations during training. Regularization.
Params:
p = drop probability per element (default 0.5) ·
inplace=False = whether to modify input in place · no learnable params
|
Teaching excerpt · complete runnable lab available from “Code” drop = nn.Dropout(0.1) y = drop(x) # inactive in eval mode |
Every nn.* module has the same shape: declare in __init__, call in forward. Compose them however you want — that's how transformers, ResNets, and everything else gets built.
If a "layer" has no learnable parameters, use F.* instead of nn.*. Cleaner code, no module declaration needed.
Two ways to apply GeLU. Both produce the same output:
nn.GELU)class FFN(nn.Module):
def __init__(self):
super().__init__()
self.act = nn.GELU()
self.fc = nn.Linear(128, 128)
def forward(self, x):
return self.fc(self.act(x))
F.gelu)class FFN(nn.Module):
def __init__(self):
super().__init__()
self.fc = nn.Linear(128, 128)
def forward(self, x):
return self.fc(F.gelu(x)) # ← cleaner
| Op | What it does & parameters |
|---|---|
F.gelu(input, approximate) |
GELU is used in models such as BERT and GPT-2. Llama uses SiLU-based SwiGLU; activation choice varies by architecture. |
F.relu(input, inplace) |
Element-wise max(0, x). Cuts off everything negative.
Params:
input = any tensor ·
inplace=False = if True, modifies input directly to save memory
Use when:
CNNs and older / simpler MLPs; cheap, fast on hardware. For transformers prefer GeLU. Beware dead ReLUs — neurons that always output 0 stop learning.
|
F.softmax(input, dim) |
Turn raw scores into a probability distribution along one dim (sums to 1).
Params:
input = real-valued tensor ·
dim = axis to normalize over (must specify; -1 = last dim, the typical choice)
Use when:
computing attention weights, sampling at generation time, or showing probabilities to the user. Don't use it before
F.cross_entropy — that op wants raw logits.
|
F.cross_entropy(input, target, weight, ignore_index, reduction) |
Combines log-softmax + NLL loss. Standard classification / next-token loss.
Params:
input = logits, shape (N, C) ·
target = class indices, shape (N,), ints in [0, C) ·
weight=None = per-class weights for imbalanced data ·
ignore_index=-100 = label value to skip in loss (used for SFT masking!) ·
reduction='mean' = aggregate via 'mean' / 'sum' / 'none'
Use when:
any classification task — image labels, sentiment, and especially next-token prediction in LLMs. The
ignore_index trick is exactly how SFT masks the user prompt.
|
F.mse_loss(input, target, reduction) |
Mean squared error: ((input − target)²).mean(). Regression default.
Params:
input = predictions ·
target = ground truth (same shape) ·
reduction='mean' = how to aggregate
Use when:
regression (continuous targets), reconstruction losses (VAEs, autoencoders), distillation, or fitting any continuous quantity. For heavy-tailed errors prefer
F.smooth_l1_loss or F.huber_loss.
|
F.dropout(input, p, training) |
Zero out elements at random with probability p · scale survivors by 1/(1-p).
Params:
input = tensor to drop from ·
p=0.5 = drop probability ·
training=True = if False, becomes a no-op (rarely used directly — prefer nn.Dropout which respects model.eval())
Use when:
almost never directly. Prefer
nn.Dropout as a sub-module — it auto-disables in model.eval(). Reach for the functional form only when you need conditional/dynamic dropout that nn.Dropout can't express.
|
F.scaled_dot_product_attention(q, k, v, attn_mask, dropout_p, is_causal) |
Eligible fused attention kernels avoid materializing the full score matrix. SDPA can also dispatch to a math backend; backend selection depends on hardware, shapes, dtype, and options. |
nn.*nn.*F.*Most "deep learning code" is shape gymnastics. Each op below shows the input shape → output shape and a concrete value.
view — reshape without copying memoryx = torch.arange(6) # tensor([0,1,2,3,4,5]) shape (6,) x.view(2, 3) # tensor([[0, 1, 2], # [3, 4, 5]]) shape (2, 3)
transpose — swap two dimsx = torch.tensor([[1,2,3],[4,5,6]]) # (2, 3) x.transpose(0, 1) # tensor([[1, 4], # [2, 5], # [3, 6]]) shape (3, 2)
permute — reorder all dims at oncex = torch.randn(2, 3, 4) # shape (2, 3, 4) x.permute(2, 0, 1).shape # torch.Size([4, 2, 3])
unsqueeze / squeeze — add or drop size-1 dimsx = torch.tensor([1, 2, 3]) # shape (3,) x.unsqueeze(0).shape # torch.Size([1, 3]) x.unsqueeze(1).shape # torch.Size([3, 1]) y = torch.zeros(1, 3, 1) y.squeeze().shape # torch.Size([3])
x = torch.arange(12).view(3, 4) a, b = x.chunk(2, dim=1) # split last dim in 2 # a, b each have shape (3, 2) # Used in attention: q, k, v = qkv.chunk(3, dim=-1)
cat vs stacka = torch.tensor([1, 2, 3]) # shape (3,) b = torch.tensor([4, 5, 6]) # shape (3,) torch.cat([a, b], dim=0) # tensor([1,2,3,4,5,6]) shape (6,) — joins along existing dim torch.stack([a, b], dim=0) # tensor([[1,2,3], # [4,5,6]]) shape (2,3) — creates a NEW dim
contiguous — needed after transpose before viewx = torch.randn(2, 3).transpose(0, 1) # transpose doesn't move data — just changes strides x.view(6) # ❌ RuntimeError: view size not compatible x.contiguous().view(6) # ✅ now it's a flat copy
a = torch.tensor([1, 2, 3]) b = torch.tensor([10, 20, 30]) a + b # tensor([11, 22, 33]) a * b # tensor([10, 40, 90]) b / a # tensor([10., 10., 10.]) a + 100 # tensor([101, 102, 103]) — scalar broadcasts
@ / matmul — matrix multiply (last 2 dims)A = torch.tensor([[1., 2.],
[3., 4.]]) # (2, 2)
B = torch.tensor([[5., 6.],
[7., 8.]]) # (2, 2)
A @ B
# tensor([[19., 22.],
# [43., 50.]]) shape (2, 2)
# Batched: works for any leading dims
x = torch.randn(8, 4, 3, 5) # (8, 4, 3, 5)
y = torch.randn(8, 4, 5, 7) # (8, 4, 5, 7)
(x @ y).shape # torch.Size([8, 4, 3, 7])
sum / mean / max — reductions over a dimx = torch.tensor([[1., 2., 3.],
[4., 5., 6.]]) # (2, 3)
x.sum() # tensor(21.) scalar
x.sum(dim=0) # tensor([5., 7., 9.]) shape (3,)
x.sum(dim=1) # tensor([6., 15.]) shape (2,)
x.sum(dim=1, keepdim=True) # shape (2, 1) — useful for broadcasting
x.mean(dim=-1) # tensor([2., 5.])
x.max() # tensor(6.)
argmax / topk — get indices of largest valueslogits = torch.tensor([1.2, 3.4, 0.8, 5.6, 2.1]) logits.argmax() # tensor(3) — index of max vals, idx = torch.topk(logits, k=2) # vals = tensor([5.6, 3.4]) # idx = tensor([3, 1]) ← used in top-k sampling
Selecting subsets, drawing random tensors, and the rule that lets (B, T, D) + (D,) just work.
x = torch.arange(24).view(2, 3, 4) # shape (2, 3, 4) x[0] # first batch: shape (3, 4) x[:, -1, :] # last token, all batch: shape (2, 4) x[..., 0] # first elem of last dim: shape (2, 3) x[:, :, :2] # first 2 along last dim: shape (2, 3, 2)
x = torch.tensor([10, 20, 30, 40, 50]) idx = torch.tensor([0, 2, 4]) x[idx] # tensor([10, 30, 50]) # This is exactly how nn.Embedding works internally
masked_fill — replace by conditionx = torch.tensor([1., 2., 3., 4.])
mask = torch.tensor([True, False, True, False])
x.masked_fill(mask, float('-inf'))
# tensor([-inf, 2., -inf, 4.])
# Causal mask in attention:
mask = torch.triu(torch.ones(T,T), diagonal=1).bool()
scores.masked_fill(mask, float('-inf')) # future → -inf → 0 after softmax
randint — random integerstorch.randint(0, 100, (2, 3)) # tensor([[42, 7, 91], # [ 3, 55, 28]]) shape (2, 3) — used in get_batch()
randn — Gaussian noisetorch.randn(3) # tensor([0.21, -1.45, 0.83]) — mean 0, std 1 (used to init weights)
multinomial — sample from a distributionprobs = torch.tensor([0.1, 0.7, 0.2]) torch.multinomial(probs, num_samples=1) # tensor([1]) — most likely 1 (highest prob), occasionally 0 or 2 # Used in generate() to sample the next token
arange + tril — building maskstorch.arange(4) # tensor([0, 1, 2, 3]) — position indices torch.tril(torch.ones(4, 4)) # tensor([[1, 0, 0, 0], # [1, 1, 0, 0], # [1, 1, 1, 0], # [1, 1, 1, 1]]) ← causal mask in attention
When tensor shapes don't match, PyTorch virtually expands the smaller one to match the larger — no actual copy is made. This makes code dramatically cleaner and is used everywhere.
x = torch.randn(32, 128) # (B, D) bias = torch.randn(128) # (D,) x + bias # bias is treated as (1, 128) → expanded to (32, 128) # Same vector added to every row
col = torch.tensor([[1], [2], [3]]) # shape (3, 1) row = torch.tensor([[10, 20, 30, 40]]) # shape (1, 4) col + row # tensor([[11, 21, 31, 41], # [12, 22, 32, 42], # [13, 23, 33, 43]]) shape (3, 4)
tok = self.tok_embed(idx) # (B, T, D) pos = self.pos_embed(torch.arange(T)) # (T, D) x = tok + pos # pos is treated as (1, T, D) → expanded to (B, T, D) # Same position vectors added to every batch item
a = torch.randn(3, 4) b = torch.randn(3, 5) a + b # RuntimeError: 4 ≠ 5 and neither is 1
Most PyTorch bugs are shape bugs. Write the shape next to every line. Example sizes: batch B=4, context T=8, width D=64, heads H=4, head size hd=D/H=16, vocab V=65.
| Step | Code | Shape | Example |
|---|---|---|---|
| token ids | idx | (B, T) | (4, 8) |
| embed | tok_emb(idx) + pos_emb(pos) | (B, T, D) | (4, 8, 64) |
| project | qkv = c_attn(x) | (B, T, 3D) | (4, 8, 192) |
| split | q, k, v = qkv.split(D, dim=-1) | (B, T, D) ×3 | (4, 8, 64) |
| heads | q.view(B,T,H,hd).transpose(1,2) | (B, H, T, hd) | (4, 4, 8, 16) |
| scores | q @ k.transpose(-2,-1) | (B, H, T, T) | (4, 4, 8, 8) |
| mix | att @ v | (B, H, T, hd) | (4, 4, 8, 16) |
| merge | .transpose(1,2).contiguous().view(B,T,D) | (B, T, D) | (4, 8, 64) |
| logits | lm_head(x) | (B, T, V) | (4, 8, 65) |
| loss | F.cross_entropy(logits.view(B*T, V), y.view(B*T)) | (B·T, V) vs (B·T,) | (32, 65) vs (32,) |
(B,T,64) → (B,T,192)..contiguous() before .view(), or use .reshape().(B,T,V) to (B·T,V).# drop this anywhere in forward()
print("q", q.shape, q.dtype, q.device)
# or make it a hard check
assert q.shape == (B, H, T, hd), q.shape
The (B,H,T,T) scores tensor grows with T². That is why long contexts are costly and why fused SDPA helps.
Correct accumulation uses sample-weighted losses. BatchNorm/statistical coupling and partial final microbatches can invalidate exact equivalence to a larger batch.
Every PyTorch training script — from a 2-layer MLP to a 70B-parameter LLM — boils down to the same four lines repeated millions of times.
# ─── ONE-TIME SETUP ─────────────────────────
model = MyModel().to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)
model.train() # enable dropout / batchnorm-train mode
# ─── (optional) OUTER LOOP: epochs ──────────
for epoch in range(NUM_EPOCHS):
# ─── INNER LOOP: one mini-batch per step ───
for step, (x, y) in enumerate(dataloader):
x, y = x.to(device), y.to(device)
# ── 1. ZERO grads — BEFORE backward ────────
# grads accumulate by default; must clear
# each step or you'd add this step's grads
# to last step's leftover grads.
optimizer.zero_grad()
# ── 2. FORWARD — build the graph ───────────
logits = model(x)
loss = loss_fn(logits, y)
# ── 3. BACKWARD — fill .grad on every leaf ─
# needs the forward graph to exist!
loss.backward()
# ── 4. STEP — apply grads to weights ───────
# must come AFTER backward (it reads .grad)
optimizer.step()
# ── 5. (optional) log / eval / checkpoint ──
if step % 100 == 0:
print(f"step {step}: loss {loss.item():.4f}")
| Optimizer | Typical LR | When to reach for it |
|---|---|---|
SGD |
0.01 – 0.1 |
Classic, low memory. Needs careful LR tuning. Rarely the best choice today, but cheapest in memory. |
SGD(momentum=0.9) |
0.01 – 0.1 |
Vision models (ResNets, CNNs). Still the default for ImageNet — often beats Adam on convolutional architectures. |
Adam |
1e-4 – 5e-4 |
The "just works" optimizer. Per-parameter adaptive LR via running estimates of mean & variance. Higher memory (stores 2 extra tensors per param). |
AdamW |
1e-4 – 5e-4 |
LLMs & transformers — the default. Same as Adam but decouples weight decay from the gradient update, which generalizes better. Used by GPT-style models, Llama and most modern LLMs (BERT used Adam with weight decay). |
RMSprop |
1e-3 |
Predecessor of Adam. Mostly historical now — pick Adam instead unless you're reproducing an old paper. |
Adafactor |
1e-3 |
Huge models / tight memory. Approximates Adam's variance with row + column statistics → O(n+m) state per n×m matrix instead of O(n·m). Used in training T5 / PaLM. |
torch.optim.SGD(model.parameters(), lr=0.05, momentum=0.9)
torch.optim.Adam(model.parameters(), lr=3e-4)
torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1)
# LLM default
Rules of thumb:
training a transformer → AdamW ·
training a CNN → SGD+momentum ·
unsure → AdamW with lr=3e-4 ("the Karpathy constant") ·
out of GPU memory → switch to Adafactor or 8-bit Adam (bitsandbytes).
Pair any of these with a learning-rate scheduler (cosine decay, warmup) — the schedule often matters more than the optimizer choice.
| Call | When & why |
|---|---|
| optimizer.zero_grad() | First (any time before backward). Sets every .grad to zero (or None). Without this, gradients accumulate across steps and you'd train on the sum of multiple batches. |
| model(x) forward |
Second. Builds the autograd graph by running every op. Loss is the scalar root we'll backprop from. |
| loss.backward() | After forward, before step. Walks the graph backwards. Needs the graph to exist (forward must have happened) and writes into .grad. |
| optimizer.step() | Last. Reads .grad values and applies the update w ← w − lr · grad. Must come after backward filled them in. |
Mnemonic: Z·F·B·S — Zero, Forward, Backward, Step.
| Mistake | Symptom |
|---|---|
Forgot zero_grad() |
Loss goes wild after a few steps; effective batch size keeps growing. |
step() before backward() |
Uses stale grads from the last step, or does nothing at all if grads are None after zero_grad(). No error — silently wrong. |
backward() twice without retain_graph=True |
RuntimeError: Trying to backward through the graph a second time… |
backward() on a non-scalar |
RuntimeError: grad can be implicitly created only for scalar outputs — call .sum() or .mean() first. |
Forgot x.to(device) |
RuntimeError: Expected all tensors to be on the same device |
Want a bigger effective batch than fits in memory? Skip zero_grad and step for K steps:
for i, (x, y) in enumerate(dataloader):
loss = model(x, y) / ACCUM_STEPS
loss.backward() # grads keep accumulating
if (i + 1) % ACCUM_STEPS == 0: # every K steps:
optimizer.step() # apply combined grads
optimizer.zero_grad() # then clear
Gradient accumulation matches a larger batch only with correct per-example loss weighting and compatible model behavior. BatchNorm statistics, dropout randomness, and a partial final accumulation group can change equivalence.
Each of these has bitten almost everyone once. Several give no error at all, just a model that trains badly.
| Mistake | What you see | Fix |
|---|---|---|
softmax before F.cross_entropy | No error. Loss drops slowly or stalls. | Pass raw logits. CE does log_softmax inside. |
| Incorrect target dtype/shape | index targets need Long; probability targets need float and class dimensions | Use indices (N,) or normalized probability targets matching logits |
MSE with pred (N,1), target (N,) | A warning, then loss over an (N,N) grid. Model learns the mean. | pred.squeeze(-1) so shapes match. |
Forgot model.eval() | Test accuracy jumps around between runs. | Dropout/BatchNorm need eval() at test time, train() after. |
Updating a leaf outside no_grad | a leaf Variable that requires grad is being used in an in-place operation | Wrap manual updates in torch.no_grad(). |
total += loss for logging | Memory grows every step until OOM. | total += loss.item() drops the graph. |
| NumPy float64 input | mat1 and mat2 must have the same dtype | torch.from_numpy(a).float() |
| Model on GPU, data on CPU | Expected all tensors to be on the same device | Move each batch: x.to(device). |
pred = model(x) # (100, 1) target = y # (100,) (pred - target).shape # (100, 100) !! # fix pred = model(x).squeeze(-1) # (100,)
(N,1) and (N,) broadcast to (N,N). Every pred gets compared to every target.
V classes and random init it should be about ln(V). For V=65, about 4.17.forward().During inference you don't need gradients. Telling PyTorch lets it skip building the graph — saving memory and speeding things up significantly.
# As a context manager
with torch.no_grad():
logits = model(x)
preds = logits.argmax(dim=-1)
# Or as a decorator (cleaner for whole functions)
@torch.no_grad()
def generate(model, idx, max_new_tokens):
for _ in range(max_new_tokens):
...
idx = torch.cat([idx, next_token], dim=1)
return idx
model.eval() # dropout off, batchnorm uses running stats # …generate / predict… model.train() # back to training mode
no_grad skips this — often a big memory saving (how much depends on the model).model.eval() is a classic bug.torch.inference_mode()with torch.inference_mode():
logits = model(x)
Even more aggressive than no_grad — additional optimizations that assume the tensors won't ever need gradients again. Use it for pure inference workloads.
model.eval()
with torch.no_grad():
output = model(x)
Run this anytime you're predicting/generating, not training.
A checkpoint must match names, shapes, architecture, and tokenizer where relevant. GGUF/Ollama conversion supports particular architectures, not arbitrary PyTorch .pt models.
Save the weights, not the whole module. Rebuild the architecture from code, then load the weights into it. This is portable and version-safe.
# Get a dict of {param_name → tensor}
state = model.state_dict()
# Save it (plus anything else you'll need later)
torch.save({
"model": state,
"optimizer": optimizer.state_dict(),
"config": config,
"step": step,
}, "checkpoints/model.pt")
ckpt = torch.load(
"checkpoints/model.pt",
map_location=device, # load onto current device
weights_only=False, # allow non-tensor objects (config)
) # ⚠ False runs pickle: ONLY for files you trust.
# Default is True since PyTorch 2.6.
# Rebuild the architecture from code
model = GPT(ckpt["config"]).to(device)
model.load_state_dict(ckpt["model"])
An ordered dict mapping every parameter (and buffer) to its tensor. For our tiny GPT it looks roughly like:
{
"tok_embed.weight": tensor(50257, 128),
"pos_embed.weight": tensor(128, 128),
"blocks.0.ln1.weight": tensor(128),
"blocks.0.ln1.bias": tensor(128),
"blocks.0.attn.qkv.weight": tensor(384, 128),
"blocks.0.attn.proj.weight": tensor(128, 128),
"blocks.0.ffn.fc1.weight": tensor(512, 128),
"blocks.0.ffn.fc1.bias": tensor(512),
...
}
Loading state_dict requires compatible parameter/buffer names and shapes and the intended architecture. Resuming training also needs optimizer, scheduler, step, and possibly RNG state.
Every tensor lives on one device. Operations require all operands to be on the same device. The model and the inputs must agree.
device = (
"cuda" if torch.cuda.is_available()
else "mps" if torch.backends.mps.is_available()
else "cpu"
)
print(f"running on {device}")
| Device | When you'll see it |
|---|---|
"cuda" | NVIDIA GPU (Linux / Windows servers, gaming PCs) |
"mps" | Apple Silicon (M1/M2/M3/M4 Macs) |
"cpu" | Always available. Slow for big models. |
# Move the whole model model = model.to(device) # Move tensors x = x.to(device) y = y.to(device) # Or create directly on a device x = torch.zeros(3, 4, device=device)
RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda:0 and cpu!
You moved the model to GPU but forgot to move x. Or vice versa. Always .to(device) both.
| Device | Tiny LLM (7M params) per step |
|---|---|
| CPU (M2 Pro) | ~800 ms |
| MPS (M2 Pro) | ~80 ms (10× speedup) |
| CUDA (RTX 4090) | ~5 ms (160× speedup) |
For training large models, GPUs aren't a luxury — they're a necessity.
map_locationckpt = torch.load("model.pt", map_location=device)
Load a checkpoint that was saved on a CUDA machine onto a CPU/MPS machine. Without this you'll hit a "no CUDA" error.
.pt to Production: GGUF & Ollama Modelfiles SHIP IT LOCALLYGGUF conversion requires an architecture, tensor mapping, and tokenizer supported by the converter and inference runtime. A generic trained .pt model cannot automatically be deployed through Ollama.
GGUF = GPT-Generated Unified Format. A binary container designed for fast, memory-mapped inference. Built by Georgi Gerganov for llama.cpp; now the de-facto standard for local LLM deployment.
GGUF bundles model data and metadata, but it still needs a compatible inference runtime. Memory mapping defers I/O and does not make a large model instantly resident or free to execute.
| Level | Bits/weight | Notes |
|---|---|---|
F32 | 32 | Full precision. Training default. Huge. |
F16 | 16 | Half precision. Near-lossless for inference. |
Q8_0 | ~8.5 | 8-bit (plus a scale per 32 weights). Near-lossless, about ½ memory of F16. |
Q5_K_M | ~5.5 | Excellent quality, big size win. |
| Q4_K_M | ~4.5 | The sweet spot. About ¼ to ⅓ the size of F16, small quality drop. Default for most users. |
Q2_K | ~2.5 | Tiny but quality degrades visibly. Last resort. |
A 7B Llama at Q4_K_M weighs ~4 GB and runs comfortably on a 16 GB laptop.
Like a Dockerfile for AI models. Declares: base weights, system prompt, sampling defaults, chat template. Build it once → run it anywhere Ollama runs.
FROM <base> # required: model to start from SYSTEM "..." # default system prompt PARAMETER temperature 0.7 # sampling knobs PARAMETER top_p 0.9 PARAMETER num_ctx 8192 TEMPLATE "..." # optional: chat format override LICENSE "..." # optional: license text
# Modelfile FROM llama3.2:3b SYSTEM """You are a patient Python tutor. Always show runnable code examples and explain edge cases.""" PARAMETER temperature 0.5 PARAMETER top_p 0.9 PARAMETER num_ctx 8192 PARAMETER stop "<|eot_id|>"
$ ollama create py-tutor -f Modelfile # transferring model data 100% # creating model layer # writing manifest # success
$ ollama list # NAME ID SIZE MODIFIED # py-tutor:latest a3b1c… 2.0 GB 3 minutes ago # llama3.2:3b 7d8c9… 2.0 GB 2 days ago $ ollama show py-tutor # config, params, template $ ollama show py-tutor --modelfile # dump the Modelfile $ ollama cp py-tutor py-tutor-v2 # clone $ ollama rm py-tutor # delete $ ollama pull llama3.2:3b # fetch from registry $ ollama push user/py-tutor # publish to ollama.com
$ ollama run py-tutor >>> Explain decorators with an example. # …streaming response… >>> /set system "Now answer in haikus." # override at runtime >>> /show modelfile # inspect >>> /bye # exit
$ ollama run py-tutor "Why is Python slow?" $ echo "Summarize this:" | ollama run py-tutor cat log.txt | ollama run py-tutor "Find errors:"
$ ollama serve # starts on http://localhost:11434
# Generate (single-turn)
$ curl http://localhost:11434/api/generate -d '{
"model": "py-tutor",
"prompt": "Explain async/await",
"stream": false
}'
# Chat (multi-turn, OpenAI-style messages)
$ curl http://localhost:11434/api/chat -d '{
"model": "py-tutor",
"messages": [
{"role": "user", "content": "Hi"}
]
}'
# pip install ollama
from ollama import chat
response = chat(
model="py-tutor",
messages=[{"role": "user", "content": "Explain GIL"}],
)
print(response.message.content)
# Or stream tokens as they arrive
for chunk in chat(model="py-tutor", messages=[...], stream=True):
print(chunk.message.content, end="", flush=True)
# Modelfile FROM ./my-model.Q4_K_M.gguf SYSTEM "You are my fine-tuned model." PARAMETER temperature 0.3
FROM can reference a compatible local GGUF model supported by the installed runtime. Arbitrary architectures or incompatible tokenizer metadata are not automatically supported.
Same pipeline used to ship Llama, Mistral, Qwen — just at much larger scale. llama.cpp provides the conversion + quantization tools; Ollama wraps it all for one-command deployment.
Try each one in your head first. Then click to see the answer.
x = torch.ones(3, requires_grad=True), y = (x * x).sum(), y.backward(). What is x.grad?y and call backward() again with no zero_grad. Now what is x.grad?a = w * x have a grad_fn but no .grad after backward?requires_grad=True keep .grad. Use a.retain_grad() to keep it.nn.Linear(128, 512)? What is weight.shape?weight.shape is (512, 128), which is (out, in).self.blocks = [Block() ...]. What goes wrong?nn.ModuleList.a is (4, 1) and b is (3,). What is (a + b).shape?b is treated as (1, 3). Both size-1 dims stretch.(8, 16, 1000), targets are (8, 16). How do you call F.cross_entropy?F.cross_entropy(logits.view(-1, 1000), targets.view(-1)). That is (128, 1000) vs (128,).step, backward, zero_grad, forward?model.eval() and torch.no_grad(): why use both?eval() changes layer behaviour (dropout off, BatchNorm uses running stats). no_grad() stops graph building to save memory.ln(65) ≈ 4.17. A much higher start means init or scaling is off.| Tensor | multi-dim array, lives on a device |
| Autograd | tracks ops, computes gradients via .backward() |
| nn.Module | reusable computation block (params + forward) |
| Optimizer | reads .grad, updates weights via .step() |
| Device | CPU / CUDA / MPS — all tensors must agree |
for step in range(MAX_ITERS):
x, y = get_batch()
logits, loss = model(x, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
If you understand these 5 lines, you understand 95% of every PyTorch script ever written.
pytorch.org/tutorials — the "Learn the Basics" tracknn/modules/linear.py teaches you how to write your own layerQuestions?
Every term this deck uses, in one line each.
| Tensor | An n-dimensional array with a dtype and a device. |
| dtype | Number type: float32, bfloat16, int64… |
| Device | Where a tensor lives: cpu, cuda, mps. |
| Broadcasting | Size-1 or missing dims stretch so shapes match. |
| view / reshape | New shape, same data. view needs contiguous memory. |
| Autograd | Records ops in forward, applies the chain rule in backward. |
| Leaf tensor | A tensor you made yourself, not the result of an op. Gets .grad. |
| grad_fn | The backward rule attached to a tensor made by an op. |
| Gradient | How much the loss changes per unit change in a weight. |
| no_grad | Context that turns off graph recording. |
| nn.Module | Base class for layers and models. Tracks parameters. |
| Parameter | A tensor the optimizer updates. Buffers are saved but not trained. |
| state_dict | Dict of name → tensor for all parameters and buffers. |
| Logits | Raw, unnormalized scores before softmax. |
| Loss | One number saying how wrong the model is. |
| Cross-entropy | Loss for classification: −log p(correct class). |
| Optimizer | Uses .grad to update weights (SGD, AdamW…). |
| Learning rate | Step size of each update. |
| Batch / step / epoch | Examples per update / one update / one pass over the data. |
| train() / eval() | Mode switch for dropout and BatchNorm. |
| Quantization / GGUF | Storing weights in fewer bits / llama.cpp's file format. |
Can gradients learn the unknown slope and intercept of a line?
The file contains its own imports, data generator or fixture, implementation, and checks.
python3 -m venv .venv source .venv/bin/activate python -m pip install torch python pytorch_101.py python pytorch_101.py --self-test
Use Python 3.10+. On Windows, activate with .venv\Scripts\Activate.ps1. Required packages: torch>=2.2.
Read the contract first. Identify the inputs, intermediate state, and return value in this excerpt from the verified file.
def fit_line(*, epochs=300):
"""Pre: epochs>0. Post: trained model and loss trace on deterministic synthetic data."""
if epochs <= 0:
raise ValueError("epochs must be positive")
torch.manual_seed(7)
x = torch.linspace(-1, 1, 64).reshape(-1, 1)
y = 2*x + 1
model = nn.Linear(1, 1)
optimizer = torch.optim.SGD(model.parameters(), lr=.05)
history = []
# Invariant: completed steps have finite recorded losses. Bound: epochs-step.
for _ in range(epochs):
optimizer.zero_grad(set_to_none=True)
predictions = model(x)
loss = F.mse_loss(predictions, y)
loss.backward()
# ... remainder in the complete downloadable fileInitial/final MSE: 3.848921298980713 1.0434358932442933e-09 Learned w,b: 1.9999468326568604 0.9999997615814209 Prediction for x=.5: 1.999973177909851 ; target=2 CrossEntropy accepts float soft targets: 0.7076059579849243 A shape is a contract: inputs and regression targets here are both (64,1).
Output was captured from the complete program running in an isolated temporary directory. Floating-point digits and compressed sizes may vary across versions/hardware.
5 checks passed, including boundary cases and independent reference comparisons where applicable. These executed checks complement the reasoning; they are not a universal proof.
Change the target to y=−3x+2 and check the learned slope/intercept. Deliberately mismatch (N,1) and (N,) target shapes and explain broadcasting.
The model can learn a different affine rule with the same loop. Matching shapes prevents accidental comparisons between every prediction and every target.