01

PyTorch 101

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.

Tensors

The core data structure

Autograd

Automatic differentiation

nn.Module

Reusable layers

Layers & F.*

Linear, Embedding, ops

Tensor Ops

view, transpose, @, …

Training Loop

The 4-line pattern

Inference

no_grad, eval mode

Save / Load

Checkpoints & devices

Start with a concrete question

Can gradients learn the unknown slope and intercept of a line?

Your goal

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.

Vocabulary you need before the algorithm

Tensor shape

The axes and counts expected by an operation.

Parameter

A tensor the optimizer is allowed to update.

Gradient

Loss sensitivity to a parameter.

Evaluation

Measure predictions without weight updates.

Work one example before writing code

Our data use x in [−1,1] and y=2x+1. Training compares model(x) and y, clears old gradients, backpropagates MSE, and updates one weight and one bias.

Trace each step and name what changed. Ask which fact stayed true and what should happen on an empty, invalid, or boundary input.

02 / Tensors

Tensors — The Core Data Structure EVERYTHING IS A TENSOR

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.

Creating Tensors

Teaching excerpt · complete runnable lab available from “Code”
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)

Inspecting Tensors

Teaching excerpt · complete runnable lab available from “Code”
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

Visualizing Shape

A tensor of shape (2, 3):

1 2 3 4 5 6 2 rows 3 cols

A 4-D tensor in deep learning is typically (batch, time, heads, dim). You'll see this constantly in attention.

The Two Superpowers

  • Device-aware: x.to("cuda") moves data to GPU. Operations on GPU tensors run on GPU.
  • Autograd-aware: if a tensor has requires_grad=True, every op on it is recorded so we can compute derivatives later.
03 / Autograd — Forward

The Computation Graph BUILT AS YOU GO

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).

A Tiny Example

Teaching excerpt · complete runnable lab available from “Code”
# 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.

Three Kinds of Tensors in the Graph

KindExample
Leaf w/ gradw, b — params we'll update
Leaf w/o gradx — input data, frozen
Intermediatea, z, y — built by ops, hold a grad_fn

The Graph It Builds

w 2.0 leaf · requires_grad x 3.0 leaf · no grad b 1.0 leaf · requires_grad × MulBackward + AddBackward x² PowBackward a = 6 z = 7 y = 49 FORWARD: leaves → ops → output

Orange-rimmed = leaf with requires_grad. Purple = op node (knows how to compute its derivative). Green = output we'll backprop from.

04 / Autograd — Backward

The Backward Pass — Chain Rule, Mechanized loss.backward()

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.

The Chain Rule (5-Second Refresher)

Teaching excerpt · complete runnable lab available from “Code”
If  y = f(g(x))   then   dy/dx = f′(g(x)) · g′(x)

For a longer chain y = f(g(h(x))):

Teaching excerpt · complete runnable lab available from “Code”
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.

Local Derivative for Each Op

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

Chain Them at Each Leaf

Teaching excerpt · complete runnable lab available from “Code”
# 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).

The Same Graph, Backward

w w.grad = 42 x no grad needed b b.grad = 14 × ·14·3 → 42 + pass-through x² 2z = 14 y seed: 1 ×3 ×1 ×1 ×1 ← BACKWARD: gradient × local derivative at each edge

The Code That Triggers All This

Teaching excerpt · complete runnable lab available from “Code”
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.])

Three Things That Surprise People

  • Gradients accumulate. Calling backward() twice adds to .grad. That's why we call optimizer.zero_grad() every step.
  • Only leaves get .grad. Intermediate tensors like a, z have a grad_fn but no .grad by default.
  • Backward is one-shot. The graph is freed after backward() to save memory. Pass retain_graph=True if you really need to do it twice.
05 / Worked Example — Fit a Line

Worked Example — Fit a Line with Raw Autograd NO nn, NO optim

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.

The Code

Teaching excerpt · complete runnable lab available from “Code”
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

Why Each Line Is There

  • no_grad(): the update itself must not become part of the graph. Without it you get "a leaf Variable that requires grad is being used in an in-place operation".
  • zero_(): grads add up. Later, optimizer.step() and zero_grad() do steps 4 and 5 for you.

What It Prints

Teaching excerpt · complete runnable lab available from “Code”
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.

Check Step 0 By Hand

  • All preds are 0, errors are −3, −5, −7, −9, −11. Mean of squares = 285 / 5 = 57.
  • dL/dw = (2/5)·Σ(pred−y)·x = (2/5)·(−125) = −50
  • dL/db = (2/5)·Σ(pred−y) = (2/5)·(−35) = −14
  • Update: w = 0 − 0.05·(−50) = 2.5, b = 0 − 0.05·(−14) = 0.7. Matches step 1.
06 / nn.Module

nn.Module — Reusable Computation Blocks THE BASE CLASS

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).

The Pattern

Teaching excerpt · complete runnable lab available from “Code”
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

What Module Does For You

  • Auto-registers sub-modules and parameters — anything assigned to 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).

One Catch — Lists

A regular Python list of modules is invisible to parameters(). Use nn.ModuleList:

Teaching excerpt · complete runnable lab available from “Code”
# ❌ Wrong — params hidden from optimizer
self.blocks = [Block(cfg) for _ in range(N)]

# ✅ Right
self.blocks = nn.ModuleList([Block(cfg) for _ in range(N)])
07 / Built-in Layers

The Layers You'll Use 95% of the Time nn.* GALLERY

PyTorch ships with hundreds of layers. For an LLM, you only need a handful.

LayerWhat it doesCode 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

The Pattern

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.

08 / Functional API

torch.nn.functional (F) — Stateless Operations NO PARAMS, JUST MATH

If a "layer" has no learnable parameters, use F.* instead of nn.*. Cleaner code, no module declaration needed.

Side by Side

Two ways to apply GeLU. Both produce the same output:

As a module (nn.GELU)
Teaching excerpt · complete runnable lab available from “Code”
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))
As a function (F.gelu)
Teaching excerpt · complete runnable lab available from “Code”
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

The Functional Ops You'll Use

OpWhat 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.

Rule of Thumb

  • Has learnable params (Linear, Embedding, LayerNorm) → use nn.*
  • Has stateful behavior (Dropout, BatchNorm — they care about train/eval mode) → use nn.*
  • Pure math (gelu, softmax, cross_entropy, matmul) → use F.*
09 / Tensor Ops — Shapes & Math

Shape Manipulation & Math WITH WORKED EXAMPLES

Most "deep learning code" is shape gymnastics. Each op below shows the input shape → output shape and a concrete value.

Shape Manipulation

view — reshape without copying memory
Teaching excerpt · complete runnable lab available from “Code”
x = 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 dims
Teaching excerpt · complete runnable lab available from “Code”
x = 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 once
Teaching excerpt · complete runnable lab available from “Code”
x = 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 dims
Teaching excerpt · complete runnable lab available from “Code”
x = 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])
torch.chunk attempts the requested split and can return unequal sizes or fewer chunks. Use split sizes when exact partitioning is required.
Teaching excerpt · complete runnable lab available from “Code”
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 stack
Teaching excerpt · complete runnable lab available from “Code”
a = 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 view
Teaching excerpt · complete runnable lab available from “Code”
x = 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

Math & Reductions

Element-wise math (broadcasts!)
Teaching excerpt · complete runnable lab available from “Code”
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)
Teaching excerpt · complete runnable lab available from “Code”
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 dim
Teaching excerpt · complete runnable lab available from “Code”
x = 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 values
Teaching excerpt · complete runnable lab available from “Code”
logits = 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
10 / Tensor Ops — Indexing & Broadcasting

Indexing, Sampling, & Broadcasting THE OTHER HALF

Selecting subsets, drawing random tensors, and the rule that lets (B, T, D) + (D,) just work.

Indexing & Masking

Basic slicing — same as NumPy
Teaching excerpt · complete runnable lab available from “Code”
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)
Fancy indexing — index with another tensor
Teaching excerpt · complete runnable lab available from “Code”
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 condition
Teaching excerpt · complete runnable lab available from “Code”
x = 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

Sampling & Random

randint — random integers
Teaching excerpt · complete runnable lab available from “Code”
torch.randint(0, 100, (2, 3))
# tensor([[42,  7, 91],
#         [ 3, 55, 28]])   shape (2, 3)  — used in get_batch()
randn — Gaussian noise
Teaching excerpt · complete runnable lab available from “Code”
torch.randn(3)
# tensor([0.21, -1.45, 0.83])  — mean 0, std 1 (used to init weights)
multinomial — sample from a distribution
Teaching excerpt · complete runnable lab available from “Code”
probs = 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 masks
Teaching excerpt · complete runnable lab available from “Code”
torch.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

Broadcasting — In Detail

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.

The Three Rules

  1. Align from the right. Pad missing leading dims with size-1.
  2. For each dim, sizes must match OR one must be 1. The size-1 one gets virtually replicated.
  3. Otherwise → error.
A.shape: 8 3 4 B.shape: (1) 1 4 ↓ broadcast result: 8 3 4 prepended & expanded ↑ expanded 1 → 3 ↑ match already ↑

Worked Examples

✓ Adding a bias vector to every row
Teaching excerpt · complete runnable lab available from “Code”
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
✓ Outer-product style: column + row vector
Teaching excerpt · complete runnable lab available from “Code”
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)
✓ Adding token + positional embedding (from the LLM!)
Teaching excerpt · complete runnable lab available from “Code”
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
✗ Shapes that DON'T broadcast
Teaching excerpt · complete runnable lab available from “Code”
a = torch.randn(3, 4)
b = torch.randn(3, 5)
a + b   # RuntimeError: 4 ≠ 5 and neither is 1
11 / Shape Tracing

Shape Tracing Through a Tiny GPT FOLLOW THE DIMS

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.

One Forward Pass, Line by Line

StepCodeShapeExample
token idsidx(B, T)(4, 8)
embedtok_emb(idx) + pos_emb(pos)(B, T, D)(4, 8, 64)
projectqkv = c_attn(x)(B, T, 3D)(4, 8, 192)
splitq, k, v = qkv.split(D, dim=-1)(B, T, D) ×3(4, 8, 64)
headsq.view(B,T,H,hd).transpose(1,2)(B, H, T, hd)(4, 4, 8, 16)
scoresq @ k.transpose(-2,-1)(B, H, T, T)(4, 4, 8, 8)
mixatt @ v(B, H, T, hd)(4, 4, 8, 16)
merge.transpose(1,2).contiguous().view(B,T,D)(B, T, D)(4, 8, 64)
logitslm_head(x)(B, T, V)(4, 8, 65)
lossF.cross_entropy(logits.view(B*T, V), y.view(B*T))(B·T, V) vs (B·T,)(32, 65) vs (32,)

Rules of Thumb

  • Linear layers only touch the last dim. (B,T,64) → (B,T,192).
  • @ multiplies the last two dims and broadcasts the rest.
  • transpose makes a tensor non-contiguous. Call .contiguous() before .view(), or use .reshape().
  • cross_entropy wants classes on dim 1. Flatten (B,T,V) to (B·T,V).

Debug Habit

Teaching excerpt · complete runnable lab available from “Code”
# 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.

12 / Training Loop

The Training Loop FOUR LINES, INFINITE TIMES

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.

The Full Pseudo-Loop

Teaching excerpt · complete runnable lab available from “Code”
# ─── 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}")

Common Optimizers — When to Use What

OptimizerTypical LRWhen 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.

Quick Code

Teaching excerpt · complete runnable lab available from “Code”
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.

Order Matters — Why?

CallWhen & 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.

Common Bugs (and what they look like)

MistakeSymptom
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

Bonus: Gradient Accumulation

Want a bigger effective batch than fits in memory? Skip zero_grad and step for K steps:

Teaching excerpt · complete runnable lab available from “Code”
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.

13 / Common Mistakes

Common Beginner Mistakes AND HOW TO SPOT THEM

Each of these has bitten almost everyone once. Several give no error at all, just a model that trains badly.

MistakeWhat you seeFix
softmax before F.cross_entropyNo error. Loss drops slowly or stalls.Pass raw logits. CE does log_softmax inside.
Incorrect target dtype/shapeindex targets need Long; probability targets need float and class dimensionsUse 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_grada leaf Variable that requires grad is being used in an in-place operationWrap manual updates in torch.no_grad().
total += loss for loggingMemory grows every step until OOM.total += loss.item() drops the graph.
NumPy float64 inputmat1 and mat2 must have the same dtypetorch.from_numpy(a).float()
Model on GPU, data on CPUExpected all tensors to be on the same deviceMove each batch: x.to(device).

The Silent Broadcasting Bug

Teaching excerpt · complete runnable lab available from “Code”
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.

A 3-Step Sanity Check

  • Overfit one batch. Loss should go near 0. If not, the bug is in the model or loop, not the data.
  • Check the first loss. With V classes and random init it should be about ln(V). For V=65, about 4.17.
  • Print shapes once at the top of forward().
14 / Inference Mode

Inference — Turning Off Autograd FASTER & LIGHTER

During inference you don't need gradients. Telling PyTorch lets it skip building the graph — saving memory and speeding things up significantly.

Two Things to Toggle

1. Disable autograd
Teaching excerpt · complete runnable lab available from “Code”
# 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
2. Switch the model into eval mode
Teaching excerpt · complete runnable lab available from “Code”
model.eval()      # dropout off, batchnorm uses running stats
# …generate / predict…
model.train()     # back to training mode

Why Bother?

  • Memory: autograd stores intermediate activations for the backward pass. no_grad skips this — often a big memory saving (how much depends on the model).
  • Speed: no graph construction, no .grad bookkeeping → usually noticeably faster.
  • Correctness: Dropout layers behave wildly differently in train vs eval mode. Forgetting model.eval() is a classic bug.

Modern Alternative: torch.inference_mode()

Teaching excerpt · complete runnable lab available from “Code”
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.

The Three-Step Inference Recipe

Teaching excerpt · complete runnable lab available from “Code”
model.eval()
with torch.no_grad():
    output = model(x)

Run this anytime you're predicting/generating, not training.

15 / Checkpoints

Saving & Loading Models CHECKPOINTS

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.

Saving

Teaching excerpt · complete runnable lab available from “Code”
# 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")

Loading

Teaching excerpt · complete runnable lab available from “Code”
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"])

What's a state_dict?

An ordered dict mapping every parameter (and buffer) to its tensor. For our tiny GPT it looks roughly like:

Teaching excerpt · complete runnable lab available from “Code”
{
  "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),
  ...
}

Why Not Save the Whole Module?

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.

16 / Devices

Device Management — CPU, CUDA, MPS WHERE TENSORS LIVE

Every tensor lives on one device. Operations require all operands to be on the same device. The model and the inputs must agree.

Picking the Best Available Device

Teaching excerpt · complete runnable lab available from “Code”
device = (
    "cuda"  if torch.cuda.is_available()
    else "mps"   if torch.backends.mps.is_available()
    else "cpu"
)
print(f"running on {device}")
DeviceWhen 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.

Moving Things Around

Teaching excerpt · complete runnable lab available from “Code”
# 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)

The Classic Bug

Teaching excerpt · complete runnable lab available from “Code”
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.

Speed Comparison (rough)

DeviceTiny 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.

Bonus: map_location

Teaching excerpt · complete runnable lab available from “Code”
ckpt = 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.

17 / Deployment

From .pt to Production: GGUF & Ollama Modelfiles SHIP IT LOCALLY

GGUF 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.

What is GGUF?

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.

What's Inside

  • Model weights — usually quantized to 2–8 bits
  • Tokenizer — vocab + merges, embedded in the file
  • Architecture metadata — layer count, head count, context length
  • Chat template — how to wrap conversations

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.

Quantization Levels — Pick Your Trade-off

LevelBits/weightNotes
F3232Full precision. Training default. Huge.
F1616Half precision. Near-lossless for inference.
Q8_0~8.58-bit (plus a scale per 32 weights). Near-lossless, about ½ memory of F16.
Q5_K_M~5.5Excellent quality, big size win.
Q4_K_M~4.5The sweet spot. About ¼ to ⅓ the size of F16, small quality drop. Default for most users.
Q2_K~2.5Tiny but quality degrades visibly. Last resort.

A 7B Llama at Q4_K_M weighs ~4 GB and runs comfortably on a 16 GB laptop.

The Ollama Modelfile

Like a Dockerfile for AI models. Declares: base weights, system prompt, sampling defaults, chat template. Build it once → run it anywhere Ollama runs.

Basic Modelfile Syntax

Teaching excerpt · complete runnable lab available from “Code”
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

Example: A Custom Python Tutor

Teaching excerpt · complete runnable lab available from “Code”
# 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|>"

Build & Run

1. Build from a Modelfile
Teaching excerpt · complete runnable lab available from “Code”
$ ollama create py-tutor -f Modelfile
# transferring model data 100%
# creating model layer
# writing manifest
# success
2. Inspect & manage models
Teaching excerpt · complete runnable lab available from “Code”
$ 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
3. Run interactively (REPL)
Teaching excerpt · complete runnable lab available from “Code”
$ 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
4. Run one-shot (scripting / pipes)
Teaching excerpt · complete runnable lab available from “Code”
$ ollama run py-tutor "Why is Python slow?"

$ echo "Summarize this:" | ollama run py-tutor
cat log.txt | ollama run py-tutor "Find errors:"
5. Serve as a REST API
Teaching excerpt · complete runnable lab available from “Code”
$ 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"}
    ]
  }'
6. Call from Python
Teaching excerpt · complete runnable lab available from “Code”
# 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)

Loading Your Own GGUF

Teaching excerpt · complete runnable lab available from “Code”
# 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.

The Full Pipeline: PyTorch → GGUF → Ollama

PyTorch checkpoint model.pt · 28 MB · F32 convert Hugging Face format safetensors + config.json convert_hf_to_gguf.py GGUF (F16) model.gguf · ~14 MB quantize Q4_K_M GGUF (Q4_K_M) ~4 MB · ready to ship ollama create Ollama model ollama run my-model

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.

18 / Check Yourself

Check Yourself CLICK TO REVEAL

Try each one in your head first. Then click to see the answer.

Autograd

x = torch.ones(3, requires_grad=True), y = (x * x).sum(), y.backward(). What is x.grad?
tensor([2., 2., 2.]). d(x²)/dx = 2x, and x is all ones.
You rebuild y and call backward() again with no zero_grad. Now what is x.grad?
tensor([4., 4., 4.]). Grads add up, they are not replaced.
Why does a = w * x have a grad_fn but no .grad after backward?
It is not a leaf. Only leaves you created with requires_grad=True keep .grad. Use a.retain_grad() to keep it.

Layers

How many parameters in nn.Linear(128, 512)? What is weight.shape?
66,048 = 128·512 weights + 512 biases. weight.shape is (512, 128), which is (out, in).
A list of blocks is stored as self.blocks = [Block() ...]. What goes wrong?
The optimizer never sees them. Plain lists are not registered. Use nn.ModuleList.

Shapes

a is (4, 1) and b is (3,). What is (a + b).shape?
(4, 3). b is treated as (1, 3). Both size-1 dims stretch.
Logits are (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,).

Training & Inference

What is the right order: step, backward, zero_grad, forward?
zero_grad → forward → backward → step. (Z·F·B·S)
model.eval() and torch.no_grad(): why use both?
They do different jobs. eval() changes layer behaviour (dropout off, BatchNorm uses running stats). no_grad() stops graph building to save memory.
A new model with 65 output classes starts with loss 12. Is that OK?
No. Random guessing gives about ln(65) ≈ 4.17. A much higher start means init or scaling is off.
19 / Recap

The Whole API on One Page CHEATSHEET

The Core Concepts

Tensormulti-dim array, lives on a device
Autogradtracks ops, computes gradients via .backward()
nn.Modulereusable computation block (params + forward)
Optimizerreads .grad, updates weights via .step()
DeviceCPU / CUDA / MPS — all tensors must agree

The Universal Training Pattern

Teaching excerpt · complete runnable lab available from “Code”
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.

What to Read Next

  • Official tutorials: pytorch.org/tutorials — the "Learn the Basics" track
  • Karpathy's nanoGPT: the canonical small GPT in PyTorch
  • The PyTorch source: reading nn/modules/linear.py teaches you how to write your own layer
  • The complete self-contained line-fitting program and tests are embedded in this deck and available under examples/. No external tiny_llm project is required.

You Now Have Enough To…

  • Read and modify the complete included PyTorch example, then extend its data, model, loss, or evaluation.
  • ✓ Modify the architecture (add layers, change dimensions)
  • ✓ Write your own training loop on a different dataset
  • ✓ Save and resume training
  • ✓ Move work to / from GPU
  • ✓ Debug shape and device errors

Questions?

20 / Glossary

Glossary QUICK LOOKUP

Every term this deck uses, in one line each.

TensorAn n-dimensional array with a dtype and a device.
dtypeNumber type: float32, bfloat16, int64…
DeviceWhere a tensor lives: cpu, cuda, mps.
BroadcastingSize-1 or missing dims stretch so shapes match.
view / reshapeNew shape, same data. view needs contiguous memory.
AutogradRecords ops in forward, applies the chain rule in backward.
Leaf tensorA tensor you made yourself, not the result of an op. Gets .grad.
grad_fnThe backward rule attached to a tensor made by an op.
GradientHow much the loss changes per unit change in a weight.
no_gradContext that turns off graph recording.
nn.ModuleBase class for layers and models. Tracks parameters.
ParameterA tensor the optimizer updates. Buffers are saved but not trained.
state_dictDict of name → tensor for all parameters and buffers.
LogitsRaw, unnormalized scores before softmax.
LossOne number saying how wrong the model is.
Cross-entropyLoss for classification: −log p(correct class).
OptimizerUses .grad to update weights (SGD, AdamW…).
Learning rateStep size of each update.
Batch / step / epochExamples per update / one update / one pass over the data.
train() / eval()Mode switch for dropout and BatchNorm.
Quantization / GGUFStoring weights in fewer bits / llama.cpp's file format.

Run a complete program

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.

Connect the concept to the implementation

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 file

Compare with an actual run

Initial/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.

Check the result, then try an exercise

Verified checks

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.

Explain the expected result

The model can learn a different affine rule with the same loop. Matching shapes prevents accidental comparisons between every prediction and every target.

Implement it yourself and validate it

  1. Run the downloadable program and its checks.
  2. Reimplement fit_line from its contract.
  3. Compare outputs with the reference on valid, empty, and invalid cases.
  4. Explain the limitations of the algorithm or experiment.
All coding labs
Decks