Week 5
Week 5 — Tensors and PyTorch: let the computer do the calculations
Key questionHow can a computer automate larger forward and backward calculations?
Learning objectives
- Understand and apply Week 5 — Tensors and PyTorch: Hand the Arithmetic to the Computer
45 min estimated reading time
This week, keep the data, loss, and learning rate unchanged and hand the calculations to PyTorch. Use Week 2's four-sample baseline: x=[1,2,3,4], y=[3,5,7,9], w=b=0, mean MSE, and learning_rate=0.01.
Remember the checkable first step: loss=41, dw=−35, db=−12; after updating, w=0.35 and b=0.12, and a new forward pass gives loss=28.45315. If a framework result differs, first check initialization, shapes, reduction, and data. Automatic differentiation is not using different mathematics.
Scroll horizontally to view all columns.
| Study sessions | What you hand to PyTorch |
|---|---|
| 1: Tensors and interfaces | Identify axes, dtype, and device; distinguish elementwise multiplication from matrix multiplication. |
| 2: Autograd | Compare with hand calculations and identify the roles of requires_grad, the graph, and .grad. |
| 3: One complete step | Clear gradients, run forward, run backward, and update; compare the state at each stage. |
| 4: Modules and diagnosis | Run course_examples/week05_three_ways.py, then create and explain an unintended broadcasting example. |
CPU is enough for these small examples. See the download README for setup. By the end, identify the exact line that changes parameters and explain why eval() and no_grad() are different controls. Next week's inputs become token IDs, but the training sequence remains the same.
Week 5 learning goals
Tensor → store numbers and compute on groups of them
Autograd → apply the chain rule automatically
nn.Module → organize parameters and forward computation
Optimizer → update parameters
Focus on four things: tensors, shapes, Autograd, and the training loop.
1. What is a tensor?
For this course, think of a tensor as:
A multidimensional numerical container with shape, dtype, device, and related metadata.
Each part has a practical purpose:
- The values are the data an operation computes with;
- Shape describes their organization;
- Dtype describes numerical storage and affects supported operations;
- Device identifies where the data and compatible computation reside, such as CPU or an accelerator;
- Autograd metadata relates the tensor to gradient tracking and previous operations.
import torch
scalar = torch.tensor(3.0)
vector = torch.tensor([1.0, 2.0, 3.0])
matrix = torch.tensor([
[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0],
])Corresponding shapes:
scalar: []
vector: [3]
matrix: [2, 3]
A tensor is not a new set of arithmetic rules. It places Week 1's scalars, vectors, and matrices behind a common interface.
Why not just use Python lists?
Lists can store numbers, but a Python loop over millions of elements incurs interpreter overhead. Lists also do not provide a shared interface for tensor shapes, accelerator devices, and automatic differentiation.
A dense tensor gives numerical data a structured layout and passes whole operations to optimized lower-level kernels:
Python requests a matrix multiplication
A lower-level kernel performs many multiply-and-add calculations
Autograd records the dependency when gradient tracking is enabled and needed
Tensors are the common data representation linking the mathematics, accelerated operations, and backpropagation.
2. Dimensions and shapes
Suppose a batch contains four houses, each with three features:
x = torch.tensor([
[0.8, 0.6, 0.2],
[0.5, 0.4, 0.9],
[0.9, 0.7, 0.1],
[0.6, 0.8, 0.4],
])
print(x.shape) # torch.Size([4, 3])dimension 0 = batch
dimension 1 = features
When working with text later, a common arrangement is:
[B, T, C]
- B:Batch size;
- T:Sequence length;
- C:Channel / Embedding dimension。
When reading PyTorch code, trace shapes before interpreting the formulas.
Do not guess what an axis means
B, T, and C in [B,T,C] are project conventions, not names enforced by PyTorch. A tensor with shape [4,8,32] can be misinterpreted if one part of the code treats axis 1 as token position while another treats it as a channel. An operation may run despite the semantic mistake.
Add a shape trace beside complex code:
# x: [batch, sequence, embedding]
x = token_embedding(token_ids)It serves as a readable annotation for dynamically shaped tensors.
3. Dtype and device
Dtype
x = torch.tensor([1.0, 2.0], dtype=torch.float32)
token_ids = torch.tensor([3, 8, 2], dtype=torch.long)Neural-network parameters and activations commonly use floating-point numbers. This course uses integer torch.long token IDs for embedding lookup.
Why integer token IDs? They select rows of an embedding matrix, so they are indices. Why floating-point weights? Gradient-based training makes small numerical parameter changes that integer storage cannot represent.
Device
device = "cuda" if torch.cuda.is_available() else "cpu"
x = x.to(device)Tensors participating in an operation must satisfy its device requirements. A common mismatch is a model on GPU with its input still on CPU.
Moving data from CPU to GPU can require copying it into different memory. Repeated transfers have a cost. A common approach keeps the model on the chosen device, transfers each input/target batch there, and brings back only the values needed for reporting. The entire dataset need not fit on the accelerator.
Scroll horizontally to view all columns.
| Code notation | Numerical value / meaning |
|---|---|
| 1e-3 | 0.001; e in scientific notation means a power of 10 |
| 1e-8 | 0.00000001 |
| float32 | Each element is approximately represented in a 32-bit floating-point format |
| torch.long | Used here for integer class IDs, not for more precise fractional values |
| NaN / Inf | Non-finite values, which may result from invalid operations or overflow |
1e-3 and math.exp(-3) are different: the first is 10⁻³; the second is the natural exponential e⁻³. Compare floating-point results with suitable tolerances, such as assert_close. Do not require bitwise equality across different orders of calculation.
4. Compute a layer with tensors
Formula:
Suppose:
X: [4,3], four examples with three features each
W: [3,2], connecting three inputs to two neurons
b: [2]
One bias per neuron
Output:
Z: [4, 2]
Code:
X = torch.tensor([
[0.8, 0.6, 0.2],
[0.5, 0.4, 0.9],
[0.9, 0.7, 0.1],
[0.6, 0.8, 0.4],
])
W = torch.tensor([
[0.7, 0.2],
[0.5, -0.1],
[-0.4, 0.8],
])
b = torch.tensor([0.1, -0.2])
Z = X @ W + b
print(Z.shape) # torch.Size([4, 2])@ performs matrix multiplication.
How much arithmetic is in this line?
X @ W applies two output neurons to each of four examples: 4×2=8 dot products. The bias is added to every row. A single line describes the batched forward calculation of the whole layer.
This is the value of the tensor interface: high-level code specifies a data transformation, while the kernel arranges the repeated arithmetic.
5. Broadcasting: why can [2] be added to [4,2]?
Z has shape [4,2], while b has shape [2].
PyTorch applies the same bias vector to every batch row:
[4, 2]
+ [2]
-------
[4, 2]
This is broadcasting.
Align shapes from the last axis. Aligned sizes must match or one must be 1; missing leading axes act as size 1 for this comparison.
Why broadcasting exists
The danger of broadcasting
Broadcastable does not mean semantically correct. For example, [B,T,C]+[T,1] can run even if the intended addition was a [C] bias. When a result is surprising, align shapes from the right and explain why each repeated axis is intended.
Knowledge check
Why does prediction:[4,1] minus target:[4] not give the four errors you intended?
6. Rewrite Week 2 with tensors
Return to Section 17 of Week 2: x=[1,2,3,4], y=[3,5,7,9], w=b=0, mean MSE, and η=0.01. Keeping these conditions should reproduce the same first step: loss=41, dw=−35, db=−12, and updated w=0.35, b=0.12. Tensors calculate the four items together; they do not change the learning rule.
Data:
x = torch.tensor([[1.0], [2.0], [3.0], [4.0]])
y = torch.tensor([[3.0], [5.0], [7.0], [9.0]])The data follow:
Initialize parameters:
w = torch.tensor([[0.0]], requires_grad=True)
b = torch.tensor([0.0], requires_grad=True)Forward:
prediction = x @ w + b
loss = ((prediction - y) ** 2).mean()The mathematics remains:
7. requires_grad=True
w = torch.tensor([[0.0]], requires_grad=True)This means:
Track differentiable operations involving w so that we can later calculate the gradient of loss with respect to w.
In ordinary eager execution with gradient tracking enabled, PyTorch dynamically records the computational graph during forward.
w, b
↓
prediction
↓
error
↓
square
↓
mean
↓
loss
Which tensors need gradients?
For these examples, set requires_grad=True on the floating-point parameters to learn. Inputs and targets need not have their own gradients computed. Other tasks can require input gradients, so this is a choice for our training setup rather than a universal restriction.
requires_grad=True does not mean a gradient has already been calculated. It enables tracking for later differentiation. For a leaf parameter used by the loss, backward then accumulates the numerical result into .grad.
Leaf tensors and intermediate tensors
Directly created w and b used as parameters here are leaf tensors. Predictions, errors, and loss produced from them are intermediate tensors with grad_fn describing their differentiation history.
An optimizer normally holds references to persistent leaf parameters. Intermediate tensors connect the current computation and are not themselves the learned model state.
8. .backward()
loss.backward()This line starts Week 4's backward calculation from the scalar loss.
Results are stored in:
print(w.grad)
print(b.grad)These correspond to:
PyTorch does not guess the gradients. It follows the recorded dependencies and combines the operations' local derivatives using the chain rule.
Why usually call backward on a scalar loss?
The implicit starting seed for loss.backward() is:
For a multi-element output, backward needs a supplied gradient vector specifying how the output components contribute. A scalar mean loss defines one objective and allows an implicit seed of 1. For squared-error regression, average the per-example losses—not the signed errors that could cancel.
What .grad means
w.grad is neither the new weight nor a quantity to add directly to w. It is the current:
The optimizer uses it with the learning rate and any other update rules, such as momentum, to determine the actual parameter change.
Without an explicit gradient argument, loss.backward() normally requires a single-element output. If you have a vector of per-sample losses, first use .mean() or .sum() to define the objective. Vector outputs can also be differentiated, but the backward seed must be specified. Differentiation is not restricted to scalar-valued functions.
9. Why clear gradients?
PyTorch accumulates gradients into .grad by default:
new grad = old grad + current grad
This is useful in some workflows, but a standard independent training step normally clears previous gradients:
w.grad.zero_()
b.grad.zero_()When using an optimizer:
optimizer.zero_grad()Otherwise, the next update includes contributions left over from previous backward calls.
Accumulation across backward calls supports deliberate schemes such as combining several small batches before one update. The training loop chooses when to clear that stored accumulation. This is distinct from summing multiple dependency paths within one backward calculation, which is mathematically required.
A clear standard sequence puts zero_grad() before forward. Clearing at the end of the previous completed update can also work, provided gradients are cleared at the intended boundary. Putting it at the start makes that boundary easy to see.
Scroll horizontally to view all columns.
| Two kinds of addition | Why it happens | What to do |
|---|---|---|
| Multiple paths within one computation graph | The same parameter affects several parts of the objective | Backward must combine these contributions; do not omit paths |
| Separate backward calls | .grad accumulates additional contributions by default | Clear it before an independent step, or explicitly define an accumulation window |
Before the first backward call, parameter.grad is usually None, so calling parameter.grad.zero_() unconditionally can fail. optimizer.zero_grad(set_to_none=True) clears stored gradients by setting them to None. No stored gradient and a stored all-zero tensor are different states. Week 11 discusses intentional accumulation.
10. Update a parameter manually
Our update should not become another differentiable operation linking the training iterations:
with torch.no_grad():
w -= learning_rate * w.grad
b -= learning_rate * b.gradThe formula is unchanged:
Here w is a leaf tensor with requires_grad=True. An in-place update such as w -= ... in normal grad mode is rejected with a RuntimeError. Replacing it with the out-of-place w = w - ... can instead create a non-leaf tensor carrying history and connect iterations. Put the manual parameter update inside torch.no_grad(), or use the standard optimizer.step() shown here.
11. A complete tensor training loop
import torch
x = torch.tensor([[1.0], [2.0], [3.0], [4.0]])
y = torch.tensor([[3.0], [5.0], [7.0], [9.0]])
w = torch.tensor([[0.0]], requires_grad=True)
b = torch.tensor([0.0], requires_grad=True)
learning_rate = 0.01
for step in range(1000):
# Forward
prediction = x @ w + b
loss = ((prediction - y) ** 2).mean()
# Backward
loss.backward()
# Update
with torch.no_grad():
w -= learning_rate * w.grad
b -= learning_rate * b.grad
# Reset gradients
w.grad.zero_()
b.grad.zero_()
print(w.item()) # approximately 2
print(b.item()) # approximately 1This implements the calculations from Weeks 2 and 4.
12. nn.Module: organize parameters and forward computation
Larger models need a systematic way to manage parameters.
import torch.nn as nn
class LinearModel(nn.Module):
def __init__(self):
super().__init__()
self.linear = nn.Linear(1, 1)
def forward(self, x):
return self.linear(x)nn.Linear(1,1) already contains:
weight
bias
Call model(x), which invokes forward through the module's call machinery and preserves the expected hook behavior. Differentiable tensor operations inside forward are recorded when grad mode requires it.
Why nn.Module is more than a wrapper
A plain class can define forward too. nn.Module additionally maintains a registered model structure. Assigning an nn.Parameter or child module to self allows the framework to:
Collect registered parameters through model.parameters(); requires_grad determines whether each needs gradients
model.to(device)
Move registered parameters and buffers recursively between devices
Export parameters and persistent buffers through model.state_dict()
Set the training/evaluation mode of registered submodules
Registration lets the framework consistently manage the model's components.
nn.Linear(1,1) initializes parameters randomly, so its default first step will not match our hand calculation. For a fair Week 2 comparison, zero both weight and bias inside no_grad first. Its weight shape is [1,1]; the general storage rule is [outputs,inputs]. For row-wise samples, forward is X @ weight.T + bias. See course_examples/week05_three_ways.py for all three implementations.
13. Loss function and optimizer
model = LinearModel()
loss_function = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)Their responsibilities:
Model → produce predictions
Loss function → score predictions against targets
Autograd → calculate gradients
Optimizer → update parameters
The loss defines the objective; the optimizer defines how to use gradients to change parameters. A model can be trained with different objectives, and a loss can be paired with different optimizers. Backpropagation supplies the parameter gradients connecting them.
14. A standard training loop: what does each line change?
Now combine tensors, automatic differentiation, a module, and an optimizer. The following program is standalone. Because Linear normally initializes randomly, explicitly zero its parameters for comparison with Week 2.
import torch
X = torch.tensor([[1.0], [2.0], [3.0], [4.0]])
y = torch.tensor([[3.0], [5.0], [7.0], [9.0]])
model = torch.nn.Linear(1, 1)
with torch.no_grad():
model.weight.zero_()
model.bias.zero_()
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
optimizer.zero_grad(set_to_none=True)
prediction = model(X)
if prediction.shape != y.shape:
raise ValueError("prediction and target must describe the same pairs")
loss = torch.nn.functional.mse_loss(prediction, y, reduction="mean")
loss.backward()
print("before step", loss.item(), model.weight.grad, model.bias.grad)
optimizer.step()
with torch.no_grad():
new_loss = torch.nn.functional.mse_loss(model(X), y)
print("after step", model.weight.item(), model.bias.item(), new_loss.item())Scroll horizontally to view all columns.
| Stage | w / b | loss | grad(w) / grad(b) |
|---|---|---|---|
| After clearing gradients | 0 / 0 | Not calculated in this step yet | None / None |
| After forward | 0 / 0 | 41 | None / None |
| After backward | 0 / 0 | 41 from the old forward pass | −35 / −12 |
| After step | 0.35 / 0.12 | The old loss variable still holds 41 | Still holds −35 / −12 |
| After recomputing forward | 0.35 / 0.12 | new_loss≈28.45315 | no_grad does not clear already stored gradients |
The table's approximate values follow from the same fixed inputs. Printed floating-point values may differ in their final digits. In a loop, clear old gradients at the start of each independent step. zero_grad does not zero the learned parameters, and optimizer.step does not automatically clear .grad.
Running without an exception does not prove correct pairing. Predictions have shape [4,1]; targets accidentally stored as [4] can broadcast to [4,4], producing sixteen cross-comparisons instead of four matched pairs. Explicit shape checks are more informative than merely checking that loss is a scalar.
Next week, predictions are logits with shape [B,T,Vocab] and targets have shape [B,T]. The classification interface intentionally uses different shapes. Do not copy this regression equality check into it; each check must match the axis meanings and loss-function contract.
Knowledge check
EX05: Predict the effects of three changes: A, remove step; B, do not clear .grad in the second iteration; C, call eval but still calculate loss.backward normally. Which parameters or gradients change?
15. A systematic approach to shape debugging
When matmul, reshape, or loss calculations fail, do not guess blindly.
Print at key boundaries:
print("x:", x.shape)
print("weight:", model.linear.weight.shape)
print("prediction:", prediction.shape)
print("target:", y.shape)Check each item:
- What each axis represents;
- Whether matrix multiplication's contracted dimensions match;
- Whether predictions and targets are correctly aligned;
- Whether the batch axis was accidentally removed;
- Whether dtype and device satisfy the operation's requirements.
In AI code, shape is part of the interface contract and data schema.
Scroll horizontally to view all columns.
| Object you see | First question to ask | Typical shape later |
|---|---|---|
| Integer inputs | How many sequences, and how many token positions in each? | [B,T] |
| Floating-point representations | How many features describe each token position? | [B,T,C] |
| Candidate scores | How many possible answers are scored at each position? | [B,T,V] |
B, T, C, and V name axis sizes; they are not four new kinds of mathematics. First read [2,3,4] as “two sequences, three positions per sequence, four numbers per position,” then inspect which dimensions the next operation changes.
Knowledge check
Why does looking up token IDs of shape [2,3] in a [5,4] table produce [2,3,4]?
16. train() and eval(): understand their roles
model.train()
model.eval()They change the behavior of layers such as Dropout and BatchNorm, rather than enabling or disabling Autograd.
A common validation pattern:
model.eval()
with torch.no_grad():
prediction = model(x)We will use this in more detail when training GPT.
Three controls that are easy to confuse:
model.train() → set registered layers to training behavior
model.eval() → set registered layers to evaluation behavior
torch.no_grad() → disable recording for backward within the context
eval() does not automatically disable gradient tracking, and no_grad() does not automatically turn off Dropout. Validation commonly uses both eval() and no_grad() because they address different requirements.
17. Six essential Week 5 ideas
- A tensor is a numerical container with shape, dtype, and device information.
- X @ W + b expresses the layer computation for a whole batch.
- Shape is one of the most useful debugging clues.
- requires_grad=True enables tracking of relevant differentiable dependencies in grad mode.
- loss.backward() applies the chain rule and accumulates gradients into the relevant leaf tensors' .grad fields.
- A standard training sequence is clear gradients → forward → loss → backward → step.
18. Week 5 → Week 6
So far, the model receives numerical features.
GPT starts with text.
The next question:
How do we turn text into tensors, and how do those tensors support next-token prediction?
Week 6 builds this path:
Text
↓
Token IDs
↓
Embeddings
↓
Logits
↓
Cross Entropy Loss