Current: Week 5

0%

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

Week progress: 0 of 19 sections (0%)Course progress: 0 of 321 sections (0%)

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.

Course data table
Study sessionsWhat you hand to PyTorch
1: Tensors and interfacesIdentify axes, dtype, and device; distinguish elementwise multiplication from matrix multiplication.
2: AutogradCompare with hand calculations and identify the roles of requires_grad, the graph, and .grad.
3: One complete stepClear gradients, run forward, run backward, and update; compare the state at each stage.
4: Modules and diagnosisRun 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.
python
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:

python
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:

python
# x: [batch, sequence, embedding]
x = token_embedding(token_ids)

It serves as a readable annotation for dynamically shaped tensors.

3. Dtype and device

Dtype

python
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

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

Course data table
Code notationNumerical value / meaning
1e-30.001; e in scientific notation means a power of 10
1e-80.00000001
float32Each element is approximately represented in a 32-bit floating-point format
torch.longUsed here for integer class IDs, not for more precise fractional values
NaN / InfNon-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:

Z=XW+bZ=XW+b

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:

python
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:

python
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:

y=2x+1y=2x+1

Initialize parameters:

python
w = torch.tensor([[0.0]], requires_grad=True)
b = torch.tensor([0.0], requires_grad=True)

Forward:

python
prediction = x @ w + b
loss = ((prediction - y) ** 2).mean()

The mathematics remains:

y^=Xw+b\hat y=Xw+b
MSE=1n(y^y)2\mathrm{MSE}=\frac{1}{n}\sum(\hat y-y)^2

7. requires_grad=True

python
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()

python
loss.backward()

This line starts Week 4's backward calculation from the scalar loss.

Results are stored in:

python
print(w.grad)
print(b.grad)

These correspond to:

LosswLossb\frac{\partial\mathit{Loss}}{\partial w}\qquad\frac{\partial\mathit{Loss}}{\partial b}

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:

LossLoss=1\frac{\partial\mathit{Loss}}{\partial\mathit{Loss}}=1

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:

Lossw\frac{\partial\mathit{Loss}}{\partial w}

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:

python
w.grad.zero_()
b.grad.zero_()

When using an optimizer:

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

Course data table
Two kinds of additionWhy it happensWhat to do
Multiple paths within one computation graphThe same parameter affects several parts of the objectiveBackward must combine these contributions; do not omit paths
Separate backward calls.grad accumulates additional contributions by defaultClear 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:

python
with torch.no_grad():
    w -= learning_rate * w.grad
    b -= learning_rate * b.grad

The formula is unchanged:

parameter:=parameterlearningRate×gradient\mathit{parameter}:=\mathit{parameter}-\mathit{learningRate}\times\mathit{gradient}

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

python
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 1

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

python
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

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

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

Course data table
Stagew / blossgrad(w) / grad(b)
After clearing gradients0 / 0Not calculated in this step yetNone / None
After forward0 / 041None / None
After backward0 / 041 from the old forward pass−35 / −12
After step0.35 / 0.12The old loss variable still holds 41Still holds −35 / −12
After recomputing forward0.35 / 0.12new_loss≈28.45315no_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:

python
print("x:", x.shape)
print("weight:", model.linear.weight.shape)
print("prediction:", prediction.shape)
print("target:", y.shape)

Check each item:

  1. What each axis represents;
  2. Whether matrix multiplication's contracted dimensions match;
  3. Whether predictions and targets are correctly aligned;
  4. Whether the batch axis was accidentally removed;
  5. 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.

Course data table
Object you seeFirst question to askTypical shape later
Integer inputsHow many sequences, and how many token positions in each?[B,T]
Floating-point representationsHow many features describe each token position?[B,T,C]
Candidate scoresHow 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

python
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:

python
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

  1. A tensor is a numerical container with shape, dtype, and device information.
  2. X @ W + b expresses the layer computation for a whole batch.
  3. Shape is one of the most useful debugging clues.
  4. requires_grad=True enables tracking of relevant differentiable dependencies in grad mode.
  5. loss.backward() applies the chain rule and accumulates gradients into the relevant leaf tensors' .grad fields.
  6. 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