PyTorch in One Sitting

You will be able to read and write every line of PyTorch this course uses: tensors and their shapes, autograd, modules, the training loop, and the shape errors you will hit along the way.

You now know that a model is matrix multiplies, a softmax, a loss, and gradient descent. Doing that in plain Python is possible and hopeless: a single GPT-2 forward pass is about a hundred billion multiplications, and you would have to write the derivative of every one by hand. You need a tool that does big matrix math fast and computes gradients for you.

That tool is PyTorch. It has two halves, and this chapter is organized around them. The first half is tensors: NumPy-like arrays that can live on a GPU. The second is autograd: PyTorch records every operation you do on a tensor and can replay the chain rule backwards through the record. Everything else (modules, optimizers, data loaders) is convenience built on those two.

Every code block here runs on PyTorch 1.12 or newer with no GPU. Type them in rather than reading them; the sitting is meant to be at a keyboard.

Tensors: creation, shapes, dtype, device

What is a tensor? A rectangular block of numbers with a shape, plus two labels: what kind of number (dtype) and where it lives (device). Vectors, matrices and the $(B, L, d)$ blocks from the math chapter are all tensors of different rank.

import torch

x = torch.tensor([[1., 2., 3.], [4., 5., 6.]])   # from a nested list
print(x.shape, x.dtype, x.device)

z = torch.zeros(2, 3)            # shape given as separate ints
o = torch.ones(2, 3)
r = torch.randn(2, 3)            # standard normal, mean 0, variance 1
a = torch.arange(6).reshape(2, 3)   # 0..5, integers
print(a.dtype)
torch.Size([2, 3]) torch.float32 cpu torch.int64

Three habits to build right now. First, print .shape constantly; it is the single most informative thing about a tensor. Second, notice the dtype: torch.tensor([1, 2]) is integer, torch.tensor([1., 2.]) is float32, and models want float32 (or float16/bfloat16 later, for speed). Third, .device is cpu until you move it, and two tensors on different devices cannot be combined.

Shapes are read left to right, outermost first. x.shape == (2, 3) means 2 rows of 3. A 1-d tensor has shape (3,), with the comma, and a scalar has shape (); x.sum() returns one, and .item() converts it to a plain Python number.

v = torch.tensor([1., 2., 3.])
print(v.shape, v.ndim)             # (3,)  1
s = v.sum()
print(s.shape, s.item())           # ()  6.0
h = torch.randn(4, 8, 16)          # batch=4, seq=8, dim=16
print(h.shape, h.numel())          # 512 numbers in total
torch.Size([3]) 1 torch.Size([]) 6.0 torch.Size([4, 8, 16]) 512
Elementwise ops and reductions

x + y, x * y, x.exp(), x.sqrt(), torch.tanh(x) all act entry by entry and keep the shape. Reductions such as x.sum(dim=1), x.mean(dim=-1), x.max(dim=-1) squash one axis away. dim=-1 means "the last axis", which for a $(B, L, d)$ tensor is the feature axis; you will write dim=-1 more than any other argument in this course.

Broadcasting: how shapes stretch to fit

You want to add a bias vector of shape (4,) to every row of a (3, 4) matrix. Writing a loop is slow and ugly. Should you copy the bias three times first? No. PyTorch does it for you, virtually, under a rule called broadcasting. The rule is short and you must know it exactly, because it silently produces wrong shapes when misapplied.

Align the two shapes on the right. Walk the dimensions from the right. At each position the sizes must either be equal, or one of them must be 1 (or missing, which counts as 1). A size-1 dimension is stretched to match the other. The result has the larger size at every position.

A : (3, 1) 102030 + B : (1, 4) 1234 = (3, 4) : A stretched across, B stretched down 11121314 21222324 31323334 align on the right; a 1 stretches to match; no copies are made
Figure 1. Broadcasting a column $(3,1)$ against a row $(1,4)$. Each size-1 axis is stretched to the other's size, giving a $(3,4)$ result where cell $(i,j) = A_i + B_j$. Nothing is copied in memory; PyTorch just reads the same value repeatedly.
InteractiveBroadcasting visualizerpick two shapes

Shapes are aligned on the right. Green positions are compatible; red is the mismatch that raises an error.

The code matches the picture exactly:

A = torch.tensor([[10.], [20.], [30.]])     # (3, 1)
B = torch.tensor([[1., 2., 3., 4.]])        # (1, 4)
print((A + B).shape)                        # (3, 4)

X = torch.randn(3, 4)
bias = torch.tensor([1., 2., 3., 4.])       # (4,) -> treated as (1, 4)
print((X + bias).shape)                     # (3, 4), bias added to every row

col = torch.tensor([1., 2., 3.])            # (3,)
# X + col  -> error: (3,4) vs (3,) aligns 4 against 3
print((X + col[:, None]).shape)             # (3,1) broadcasts down the columns
torch.Size([3, 4]) torch.Size([3, 4]) torch.Size([3, 4])
Common confusion

Broadcasting aligns on the right, never the left. A (3,) vector against a (3, 4) matrix does not "obviously" go down the columns; it tries to match 3 against 4 and fails. To broadcast down the columns you must give it shape (3, 1) with col[:, None] or col.unsqueeze(1). The worse case is when it does not fail: a (4,) against a (4, 4) silently broadcasts across rows when you meant columns, and your model trains slightly wrong for a week.

Indexing, and views versus copies

How do you get at one token's vector inside a $(B, L, d)$ block? Indexing, and it works like NumPy: integers pick, slices a:b take ranges, ... means "all the remaining axes", None inserts a new axis of size 1.

h = torch.randn(4, 8, 16)         # (B, L, d)
print(h[0].shape)                 # first sequence          (8, 16)
print(h[0, 3].shape)              # its 4th token           (16,)
print(h[:, -1].shape)             # last token of every seq (4, 16)
print(h[:, :4, :].shape)          # first 4 tokens          (4, 4, 16)
print(h[..., :8].shape)           # first 8 features        (4, 8, 8)
print(h[:, None].shape)           # insert an axis          (4, 1, 8, 16)

ids = torch.tensor([2, 5, 5])
print(h[0, ids].shape)            # gather tokens 2,5,5     (3, 16)
mask = torch.tensor([True, False, True, False])
print(h[mask].shape)              # boolean select on B     (2, 8, 16)
torch.Size([8, 16]) torch.Size([16]) torch.Size([4, 16]) torch.Size([4, 4, 16]) torch.Size([4, 8, 8]) torch.Size([4, 1, 8, 16]) torch.Size([3, 16]) torch.Size([2, 8, 16])

Here is the catch that bites everyone once. Slicing, .view(), .reshape() (usually), .transpose() and .T do not copy the numbers. They return a view: a new shape laid over the same memory. Modify the view and you modify the original.

x = torch.arange(6.)
v = x.view(2, 3)        # a view: same 6 numbers, new shape
v[0, 0] = 100.
print(x)                # x changed too
c = x.clone().view(2, 3)   # .clone() copies; now they are independent
c[0, 0] = -1.
print(x[0])
tensor([100., 1., 2., 3., 4., 5.]) tensor(100.)
one block of memory: 6 floats 10012345 x : shape (6,) reads it as one row v = x.view(2, 3) reads the SAME floats as 2 rows of 3 v[0, 0] = 100 wrote into the shared block, so x[0] is 100 too c = x.clone() new block, independent -112345 view / reshape / slice / transpose / .T → new shape, same floats (free, aliased) clone / contiguous (when needed) / .to(device) → new floats (costs memory, independent)
Figure 2. Views versus copies. x and v are two shapes laid over one block of memory, so a write through either is visible through both. clone() allocates a second block. The left column of operations never copies; the right column always does.

Why does PyTorch do this? Speed. Reshaping a billion-entry tensor should not take time or memory, and views make it free. The price is that you have to remember. Rule of thumb: if you are about to modify something in place and are not sure whether it aliases something else, .clone() it.

One more wrinkle: .view() only works when the underlying memory is laid out contiguously in the order the new shape needs. After a .transpose() it is not, and .view() raises an error. .reshape() does the same thing but silently copies when it must, and .contiguous() forces the copy explicitly. In the attention code you will see .transpose(1, 2).contiguous().view(B, L, d): swap axes, make it contiguous, reshape. Now you know why all three are there.

Matrix multiplication and @

The one operation that is 90% of a model's arithmetic. In PyTorch it is the @ operator (or torch.matmul), and it follows the shapes rule from the math chapter with one addition: extra leading dimensions are treated as a batch and broadcast.

X = torch.randn(5, 3)             # 5 tokens, 3 features
W = torch.randn(3, 2)             # maps 3 features -> 2
print((X @ W).shape)              # (5, 2)

q = torch.randn(4, 8, 16)         # (B, L, d)
k = torch.randn(4, 8, 16)
scores = q @ k.transpose(-2, -1)  # (4, 8, 16) @ (4, 16, 8) -> (4, 8, 8)
print(scores.shape)

v = torch.tensor([1., 2., 3.])
print((v @ v).item())             # vector @ vector = dot product = 14
torch.Size([5, 2]) torch.Size([4, 8, 8]) 14.0

Read the middle example carefully; it is the attention score computation. k.transpose(-2, -1) swaps the last two axes, turning $(B, L, d)$ into $(B, d, L)$ so the inner dimensions match, and the batch axis $B$ rides along. The result is $B$ separate $L \times L$ tables, one per sequence. The -2, -1 convention (count from the end) is what keeps the same line working whether or not there is a batch dimension in front.

Common confusion

* is elementwise (with broadcasting), @ is matrix multiplication. X * W with $X$ of shape $(5,3)$ and $W$ of shape $(3,2)$ raises a broadcasting error; with the wrong shapes it may silently succeed and compute nonsense. When you see the $\odot$ symbol in a formula that is *; when you see two matrices next to each other, that is @.

Autograd: PyTorch computes the gradients

You know from the math chapter that gradients come from the chain rule applied backwards through a pipeline. Writing that by hand for a whole model is exactly the tedium we want to avoid. How does PyTorch avoid it? By watching.

Mark a tensor with requires_grad=True. From then on, every operation involving it is recorded into a computation graph: a bookkeeping structure that remembers what was computed from what. When you call .backward() on a scalar at the end, PyTorch walks the graph in reverse, multiplying local derivatives, and deposits the result for each marked tensor in its .grad attribute.

x = torch.tensor(2.0)
w = torch.tensor(3.0, requires_grad=True)
b = torch.tensor(-1.0, requires_grad=True)

u = w * x          # 6
v = u + b          # 5
y = v ** 2         # 25
print(y, y.grad_fn)   # grad_fn shows the recorded op

y.backward()       # run the chain rule backwards
print(w.grad, b.grad)   # dy/dw = 2v * x = 20,  dy/db = 2v = 10
tensor(25., grad_fn=<PowBackward0>) <PowBackward0 object at 0x...> tensor(20.) tensor(10.)

Check it against the math: $y = (wx + b)^2$, so $dy/dw = 2(wx+b)\cdot x = 2 \cdot 5 \cdot 2 = 20$ and $dy/db = 2(wx+b) = 10$. The graph that produced those numbers looks like this.

w = 3requires_grad x = 2a plain input u = w·xMulBackward b = −1 v = u+bAddBackward y = v²PowBackward backward() starts here
Figure 3. The computation graph PyTorch recorded for $y = (wx+b)^2$. Each box is a tensor; each operation remembers its inputs and its own local derivative rule (grad_fn). y.backward() starts at the right with $dy/dy = 1$ and pushes gradients leftward, multiplying by each local rule, until they reach $w$ and $b$.

Step through the same graph with the numbers. Forward values first, then the backward pass one edge at a time.

InteractiveAutograd graph stepperuse the arrows

Forward fills in values left to right. Backward fills in gradients right to left, each one equal to the gradient downstream times the local derivative.

Three details that matter in practice. Gradients accumulate: a second .backward() adds to .grad rather than replacing it, which is why training loops zero the gradients every step. Only leaf tensors you marked keep a .grad; intermediate results like u do not, to save memory. And .backward() needs a scalar; for a loss that is a vector you first reduce it with .mean() or .sum().

Intuition

Autograd is a tape recorder. The forward pass records every operation on the tape. backward() plays the tape in reverse, and at each operation asks it: "given how much the loss cares about your output, how much does it care about each of your inputs?" Each operation only knows its own local rule; the chain rule does the rest by multiplication.

Linear regression by hand: the whole training idea in 20 lines

Before touching any convenience classes, train something with just tensors and autograd. The task: points $(x, y)$ that roughly follow $y = 1.5x + 0.5$; find $w$ and $b$ from the data. This is a two-parameter language model with no language, and every training loop in the course has exactly this skeleton.

import torch
torch.manual_seed(0)

# data: y = 1.5 x + 0.5 + noise
x = torch.linspace(-2, 2, 32)
y = 1.5 * x + 0.5 + 0.3 * torch.randn(32)

w = torch.zeros(1, requires_grad=True)
b = torch.zeros(1, requires_grad=True)
lr = 0.1

for step in range(100):
    pred = w * x + b                    # forward
    loss = ((pred - y) ** 2).mean()     # mean squared error, a scalar
    loss.backward()                     # gradients land in w.grad, b.grad
    with torch.no_grad():               # update without recording a graph
        w -= lr * w.grad
        b -= lr * b.grad
    w.grad.zero_(); b.grad.zero_()      # gradients accumulate: reset them
    if step % 25 == 0:
        print(f"step {step:3d}  loss {loss.item():.3f}  w {w.item():.2f}  b {b.item():.2f}")
step 0 loss 4.024 w 0.44 b 0.10 step 25 loss 0.104 w 1.51 b 0.44 step 50 loss 0.094 w 1.52 b 0.49 step 75 loss 0.094 w 1.52 b 0.50

Look at the four things inside the loop, because they are the four things inside every loop: forward, loss, backward, update. The torch.no_grad() block matters: without it the update w -= ... would itself be recorded into the graph, and the next backward pass would try to differentiate through your optimizer. The .zero_() calls matter because gradients accumulate.

Play with the same problem below. The learning rate and step count are the knobs; the fitted line and the loss curve are the readout. Push the learning rate past about 0.5 and watch the loss climb instead of fall, the divergence you saw on the 1-d bowl, now in two parameters.

InteractiveTraining-loop simulatorset lr and steps, then Replay

Left: the points and the current fitted line. Right: loss per step. The gradients are computed exactly as autograd would compute them.

nn.Module: packaging parameters and forward

The manual loop works, but a model has hundreds of weight tensors. Tracking them by hand, moving them all to the GPU, saving them, feeding them to an optimizer: that needs a container. nn.Module is that container. You subclass it, create sub-modules and parameters in __init__, and write the computation in forward.

import torch, torch.nn as nn

class TinyMLP(nn.Module):
    def __init__(self, d_in, d_hidden, d_out):
        super().__init__()
        self.fc1 = nn.Linear(d_in, d_hidden)   # weight (d_hidden, d_in), bias (d_hidden,)
        self.fc2 = nn.Linear(d_hidden, d_out)
    def forward(self, x):
        return self.fc2(torch.relu(self.fc1(x)))

m = TinyMLP(16, 64, 4)
out = m(torch.randn(8, 16))      # call the module, not .forward(), so hooks run
print(out.shape)
for name, p in m.named_parameters():
    print(f"{name:12s} {tuple(p.shape)}")
print(sum(p.numel() for p in m.parameters()), "parameters")
torch.Size([8, 4]) fc1.weight (64, 16) fc1.bias (64,) fc2.weight (4, 64) fc2.bias (4,) 1348 parameters

Notice nn.Linear(d_in, d_out) stores its weight as (d_out, d_in) and computes $xW^\top + b$. That transpose surprises people reading the math chapter's $XW$; it is just a storage convention. Anything assigned as an attribute that is itself a Module or an nn.Parameter is registered automatically, which is how .parameters() finds everything, including inside nested modules and nn.ModuleLists.

The three layers a transformer is made of

Besides nn.Linear, a GPT uses exactly two other parameterized layers, and both are simple.

emb = nn.Embedding(num_embeddings=50257, embedding_dim=768)   # a lookup table (V, d)
ids = torch.tensor([[464, 3290, 3332]])                       # (B=1, L=3) token ids
print(emb(ids).shape)                                         # (1, 3, 768): row lookup

ln = nn.LayerNorm(768)          # per-token: subtract mean, divide by std, then scale & shift
h = torch.randn(1, 3, 768) * 5 + 2
out = ln(h)
print(out.mean(-1), out.std(-1))   # each token now ~mean 0, std 1 (before learned scale/shift)
torch.Size([1, 3, 768]) tensor([[ 0.0000, -0.0000, 0.0000]], grad_fn=<MeanBackward1>) tensor([[1.0007, 1.0007, 1.0007]], grad_fn=<StdBackward0>)

nn.Embedding is a matrix of shape (vocabulary, $d$) indexed by token id; that is the "embed" box in the model map from the first chapter, and it is trained like any other weight. nn.LayerNorm normalizes each token's vector to mean 0 and variance 1 across its features and then applies a learned per-feature scale and shift; it keeps activations at a sane scale through dozens of layers. Both get their own sections in the embedding and layers chapters; here you only need to know their shapes.

Optimizers: torch.optim.AdamW

In the manual loop, the update was w -= lr * w.grad for each parameter. An optimizer object does that for all parameters at once, and the good ones do it smarter. The one used for essentially every language model is AdamW: it keeps a running average of each parameter's gradient and of its square, scales each step by their ratio (so parameters with noisy gradients take smaller steps), and applies weight decay directly to the weights.

opt = torch.optim.AdamW(m.parameters(), lr=3e-4, betas=(0.9, 0.95), weight_decay=0.1)

# the three calls you make every step:
opt.zero_grad()        # clear .grad on every parameter
loss = ...             # forward + loss
loss.backward()        # fill .grad
opt.step()             # update every parameter from its .grad

Those hyperparameters (lr=3e-4, betas=(0.9, 0.95), weight_decay=0.1) are the GPT-style defaults and are what code/lumen/train.py uses. The optimizers chapter builds AdamW from scratch; for now, treat it as "gradient descent with per-parameter step sizes".

The canonical training loop

Every training script in this course, from linear regression to the capstone, is this loop. Learn the five moves in order.

Symbols
$\theta$ = model.parameters()
$L$ = loss
$\nabla_\theta L$ = p.grad
$\eta$ = lr
STEP 1
zero_grad
opt.zero_grad(). Gradients accumulate, so clear last step's.
STEP 2
forward
logits = model(x). Builds the computation graph as a side effect.
STEP 3
loss
loss = F.cross_entropy(logits, y). One scalar.
STEP 4
backward
loss.backward(). Chain rule; every p.grad is filled.
STEP 5
step
opt.step(). $\theta \leftarrow \theta - \eta\,\cdot$(scaled grad).
import torch.nn.functional as F

model = TinyMLP(16, 64, 4)
opt = torch.optim.AdamW(model.parameters(), lr=1e-3)

for step in range(200):
    x = torch.randn(32, 16)                 # a batch of 32 inputs
    y = (x[:, 0] > 0).long() * 2 + (x[:, 1] > 0).long()   # a made-up 4-class label
    opt.zero_grad()
    logits = model(x)                       # (32, 4) raw scores
    loss = F.cross_entropy(logits, y)       # softmax + -log p(true), averaged
    loss.backward()
    opt.step()
    if step % 50 == 0:
        print(step, round(loss.item(), 3))
0 1.41 50 0.62 100 0.31 150 0.19

F.cross_entropy takes raw scores (logits) and integer class ids, applies log-softmax and picks out $-\log p$ of the true class, averaged over the batch. It is the loss from the math chapter, in nats. You never apply softmax yourself before it; doing so is a classic bug that trains badly without erroring. For a language model the "classes" are vocabulary entries and the logits have shape $(B, L, V)$, which is flattened to $(B \cdot L, V)$ before the call.

Common confusion

Forgetting opt.zero_grad() is the most common silent bug in a first training loop. Gradients from every previous step keep adding up, the effective step size grows, and the loss goes up and down for no visible reason. If your loss curve looks like a heartbeat, check this first.

DataLoader basics

Where do the batches come from? For toy problems, from torch.randn. For real data you want shuffling, batching and (on a GPU) background loading. Dataset is anything with __len__ and __getitem__; DataLoader turns it into an iterator over batches.

from torch.utils.data import Dataset, DataLoader

class TokenWindows(Dataset):
    """Every item is (context of L tokens, the next L tokens shifted by one)."""
    def __init__(self, ids, L):
        self.ids, self.L = ids, L
    def __len__(self):
        return len(self.ids) - self.L - 1
    def __getitem__(self, i):
        x = self.ids[i : i + self.L]
        y = self.ids[i + 1 : i + self.L + 1]
        return x, y

ids = torch.randint(0, 100, (10_000,))          # pretend these are token ids
dl = DataLoader(TokenWindows(ids, L=8), batch_size=4, shuffle=True)
x, y = next(iter(dl))
print(x.shape, y.shape)
print(x[0]); print(y[0])
torch.Size([4, 8]) torch.Size([4, 8]) tensor([57, 12, 88, 3, 41, 41, 96, 20]) tensor([12, 88, 3, 41, 41, 96, 20, 75])

Look at x[0] and y[0]: the target is the input shifted one position left. That shift is next-token prediction, and the same eight tokens give eight training examples at once. code/lumen/data.py is this class plus a tiny corpus.

Saving and loading: state_dict

A trained model is its parameter values. model.state_dict() is an ordered dictionary from parameter name to tensor, and it is the thing you save. The architecture (the Python class) is not saved; you rebuild it and load the values in.

torch.save(model.state_dict(), "tiny.pt")

model2 = TinyMLP(16, 64, 4)                          # same architecture
model2.load_state_dict(torch.load("tiny.pt", map_location="cpu"))
print(list(model2.state_dict().keys())[:2])
['fc1.weight', 'fc1.bias']

This is exactly how you will load OpenAI's GPT-2 weights in month 3: build your own GPT2 class in code/lumen/gpt2.py, then copy their tensors into your state_dict name by name, with a few transposes where their layout convention differs from nn.Linear's. Also save the optimizer's state_dict() if you want to resume a training run; AdamW's running averages are state too.

GPU and MPS: moving things

Everything so far ran on the CPU. To use a GPU, pick a device string and move both the model and every batch to it. Tensors on different devices cannot interact, so the pattern is always "model once, batches every step".

if torch.cuda.is_available():
    device = "cuda"
elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
    device = "mps"             # Apple silicon
else:
    device = "cpu"

model = TinyMLP(16, 64, 4).to(device)      # moves all parameters in place
x = torch.randn(32, 16).to(device)          # returns a copy on the device
out = model(x)
print(out.device)
print(out.cpu().shape)                      # bring back for numpy / plotting
cuda:0 torch.Size([32, 4])

Two things to remember. model.to(device) modifies the module in place, but tensor.to(device) returns a new tensor, so you must assign it. And .item() or .cpu() on a GPU tensor forces the GPU to finish and copy back, which is slow if done every step; log the loss every 50 steps, not every one.

Common shape bugs and how to read the error

Most of your PyTorch time in month 2 will be spent on shape errors. They are not mysterious once you know the three messages and what each is telling you.

Error message (abridged)What happenedFix
mat1 and mat2 shapes cannot be multiplied (32x16 and 64x16)@ with mismatched inner dims: 16 vs 64.Transpose one operand, or you built nn.Linear with the arguments swapped.
The size of tensor a (8) must match the size of tensor b (16) at non-singleton dimension 1Broadcasting failed on an elementwise op.Print both shapes, align on the right, insert a 1 with unsqueeze/[:, None] where you meant to stretch.
view size is not compatible with input tensor's size and stride.view() after a transpose/slice made memory non-contiguous.Use .reshape() or call .contiguous() first.
Expected input batch_size (256) to match target batch_size (32)cross_entropy got logits flattened to $(B\cdot L, V)$ but targets still $(B, L)$.Flatten both: logits.view(-1, V), y.view(-1).
Expected all tensors to be on the same deviceModel on GPU, batch on CPU (or the reverse).Move the batch with .to(device) and assign the result.
grad can be implicitly created only for scalar outputsCalled .backward() on a non-scalar.Reduce first: loss.mean().backward().

The debugging move that works: put print(name, t.shape) after every line in forward, run one batch, and compare against the shapes you expected. Write the expected shape as a comment on each line, the way the code in code/lumen/ does. Those comments are not decoration; they are the spec.

torch.no_grad and eval mode

When you generate text, you do not need gradients. Recording the graph costs memory (it keeps every intermediate activation, as the math chapter explained) and time. Wrap inference in torch.no_grad(), and it records nothing.

model.eval()                     # switch off dropout etc. (mode flag on the module)
with torch.no_grad():            # do not build a graph
    logits = model(x)
    probs = torch.softmax(logits, dim=-1)
    next_id = torch.multinomial(probs, num_samples=1)   # sample one per row
model.train()                    # back to training mode before the next step

These are two different switches, and you need both. model.eval() changes the behavior of certain layers: dropout stops dropping and batch-norm uses its running statistics. torch.no_grad() changes the bookkeeping: no graph is recorded. Forgetting eval() makes generation noisy; forgetting no_grad() makes it slow and eventually runs out of memory. The sampling helpers in code/lumen/sampling.py use both.

The 20 tensor operations this course uses

You will see these again and again. Each line is a one-sentence spec; the shapes in comments are the point.

OperationWhat it doesUsed for
torch.tensor, zeros, ones, randn, arangeCreate tensors from data, or filled, or random.Everywhere.
x.shape, x.view(...), x.reshape(...)Read shape; relabel the same memory into a new shape.Splitting heads: (B,L,d) → (B,L,H,d/H).
x.transpose(a, b), x.TSwap two axes.k.transpose(-2,-1) in attention; heads to (B,H,L,d/H).
x.unsqueeze(i), x[:, None], x.squeeze(i)Insert or remove a size-1 axis.Setting up broadcasting.
x.contiguous(), x.clone()Force a contiguous copy; force any copy.After transpose, before view.
a @ b, torch.matmulMatrix multiply with batch broadcasting.Every linear map, every attention score.
a * b, +, -, /Elementwise with broadcasting.Gating, scaling, residual adds.
x.sum(dim), x.mean(dim), x.var(dim)Reduce an axis.LayerNorm, loss averaging.
x.max(dim), x.argmax(dim)Largest value (and its index) along an axis.Greedy decoding, accuracy.
torch.softmax(x, dim), torch.log_softmaxScores to probabilities (or log-probabilities) along an axis.Attention weights; sampling.
F.cross_entropy(logits, targets)Log-softmax plus negative log-likelihood, averaged.The training loss.
torch.tril(ones(L, L))Lower-triangular matrix of ones.The causal mask.
x.masked_fill(mask, value)Replace entries where mask is True.Setting future scores to $-\infty$.
torch.cat([a, b], dim), torch.stackJoin along an existing axis; join along a new axis.Appending to the KV cache; assembling heads.
x.split(n, dim), x.chunk(k, dim)Cut along an axis into pieces.One projection into q, k, v.
x[idx], torch.gatherPick rows by integer index; pick per-row entries.Embedding lookup; log-prob of the chosen token.
torch.multinomial(p, 1), torch.topkSample from a distribution; take the k largest.Sampling, top-k decoding.
x.exp(), log(), sqrt(), tanh(), torch.sigmoid, F.geluElementwise nonlinearities.MLP activations, gates.
x.to(device), .to(dtype), .float(), .half()Move between devices or number formats.GPU training, mixed precision.
x.detach(), .item(), .cpu(), .numpy()Leave the graph; get a Python number; go back to CPU / NumPy.Logging, plotting, DPO's reference model.
Where it came from

PyTorch was released by Facebook AI Research in 2017 (Paszke et al., 2019, "PyTorch: An Imperative Style, High-Performance Deep Learning Library"), built on the Torch library's tensor code with a Python-first, define-by-run autograd inspired by Chainer. Its bet, that researchers wanted plain Python loops rather than compiled graphs, is why the training loop above looks like ordinary code.

Practice

Exercise 1 — softmax and cross-entropy from tensors

Without using torch.softmax or F.cross_entropy, implement my_softmax(z) (subtract the row max first, then exponentiate and normalize along dim=-1) and my_cross_entropy(logits, targets) using torch.gather to pick out each row's true-class log-probability. Check both against the built-ins on a random $(32, 10)$ batch to within $10^{-6}$. Then call .backward() on both losses and compare the gradients on logits.

Solution sketch

z = z - z.max(-1, keepdim=True).values; e = z.exp(); p = e / e.sum(-1, keepdim=True). For the loss: logp = z - z.exp().sum(-1, keepdim=True).log() (a numerically safe log-softmax), then -logp.gather(1, targets[:, None]).mean(). The gradient of cross-entropy with respect to logits is (p - onehot(targets)) / B; you can verify that closed form against autograd too.

Exercise 2 — a linear regression Module

Rewrite the manual linear regression as an nn.Module with one nn.Linear(1, 1), train it with torch.optim.SGD(lr=0.1) for 100 steps using the canonical loop, and confirm it reaches the same $w$ and $b$. Then swap in AdamW(lr=0.05) and compare the loss curves. Finally save the state_dict, rebuild the module, load it, and check the prediction at $x = 1$ is unchanged.

Solution sketch

Input must be shape $(N, 1)$, so use x[:, None]. model.weight is $(1, 1)$ and model.bias is $(1,)$. SGD reproduces the manual loop exactly (it is the same update). AdamW converges in fewer steps here because it normalizes step sizes, though it can oscillate near the end at a fixed learning rate; that is what learning-rate decay in code/lumen/train.py is for.

Exercise 3 — break the shapes on purpose

Take the attention score line scores = q @ k.transpose(-2, -1) with $q, k$ of shape $(2, 5, 8)$. Deliberately (a) drop the transpose, (b) transpose the wrong axes with .transpose(0, 1), (c) add a $(5,)$ bias to scores and then a $(2,)$ one. Predict each error message before running it, then run it. Two of the four are errors; two silently succeed. Which are the dangerous ones?

Solution sketch

(a) errors: inner dims 8 vs 5. (b) silently gives shape $(5, 2, 8)$ @ … which errors on the batch dimension mismatch (2 vs 5), or worse, succeeds with strange shapes if the numbers happen to line up. (c) $(5,)$ broadcasts across the last axis of $(2,5,5)$ silently, adding the bias to every row, which may or may not be what you meant; $(2,)$ errors. The silent ones are the dangerous ones: write the expected shape as a comment and assert it.

Check yourself
What is the result shape of adding a $(3, 1)$ tensor to a $(4,)$ tensor?
Align on the right: $(3,1)$ against $(1,4)$ after padding. Each position has a 1 to stretch, so the result is $(3,4)$.
You call loss.backward() twice without zero_grad() between. What is in w.grad?
Gradients accumulate into .grad. That is why every training step starts with opt.zero_grad(). (Calling backward twice on the same graph does raise an error unless retain_graph=True, but on two separate forward passes it simply sums.)
Which pair is needed for correct, efficient text generation from a trained model?
eval() changes layer behavior (dropout off); no_grad() stops recording the graph, saving memory and time. They are independent switches and you want both.
v = x.view(2, 3); v[0, 0] = 9. What happened to x?
.view() returns a view over the same memory, so writes through it change the original. x keeps its own shape. Use .clone() when you need independence.

Key takeaways

  • A tensor is numbers plus shape, dtype and device; print .shape constantly and read it outermost-first.
  • Broadcasting aligns shapes on the right and stretches size-1 axes; insert a 1 with unsqueeze to control it.
  • Slices, view and transpose are views over shared memory; clone() to copy, contiguous() before view after a transpose.
  • @ is matrix multiplication with batch broadcasting; * is elementwise.
  • Autograd records a graph during forward and replays the chain rule in backward(); gradients accumulate in .grad.
  • The loop is always zero_grad → forward → loss → backward → step; F.cross_entropy takes raw logits.
  • state_dict is the model; eval() and no_grad() are separate switches for inference.

Further reading

  • PyTorch documentation. Broadcasting semantics. The rule from this chapter, stated formally, in one page.
  • PyTorch documentation. Autograd mechanics. What is recorded, when, and how in-place operations interact with it.
  • PyTorch tutorials. Learn the Basics. Official walkthrough covering tensors, datasets, modules and the training loop with runnable notebooks.
  • Andrej Karpathy. micrograd. A 100-line scalar autograd engine; reading it demystifies backward() completely.
  • Loshchilov & Hutter (2019). Decoupled Weight Decay Regularization. The AdamW paper: why weight decay is applied to the weights rather than folded into the gradient.