The Mathematics of Large Language Models

Step 2 — Wire a Network by Hand, Then Train One

Released with Lecture 2 · Starts from: Part A is self-contained; Part B needs your Step 1 ids (or solutions/step-01.ipynb) · Solution: notebook · read online

Goal

Two constructions, in order:

  1. Part A — wire, then train. Construct a one-hidden-layer neural network by hand, with four hidden ReLU units, that classifies points as inside or outside the diamond

    ∣x1∣+∣x2∣=1.|x_1|+|x_2| = 1.

    Then erase those weights and use back propagation and gradient descent to learn the same classifier from labelled examples. The comparison separates what the architecture can represent from what optimization actually finds.

  2. Part B — train. Rebuild Step 1’s bigram model, but as a differentiable parametric family trained by gradient descent — with a gradient you derive by hand, no autograd allowed yet. This is the conceptual pivot of the whole course: from estimating a table to optimizing a function. You will prove, and then watch numerically, that the two coincide.

Part A — Wire, then train, a diamond classifier

The Neural Function Playground shows every neuron as a function of (x1,x2)(x_1,x_2) while you change its weights and bias. You will design the network there first, then transcribe your construction into PyTorch. Do not begin by searching for finished matrices: the point is to discover what each unit has to do.

2.1 Classify by hand in the playground

Open the playground, click Reset, and use the targets across the top as exercises. Zero all parameters before each new attempt, then change weights and biases by dragging, with the arrow keys, or by double-clicking for exact entry. The target is only an overlay; nothing is trained for you.

Try as many of these as time permits:

  1. Half-plane (warm-up). Delete the hidden layer. Can one identity output unit classify the target?
  2. Diamond. Click Reset again to restore the default architecture: four ReLU hidden units, one identity output, and threshold τ=0\tau=0. Zero its parameters. First try to classify part of the target region. Then improve it until the solid decision boundary follows all four sides of the dashed diamond.
  3. XOR. The score counts the four marked examples, so partial success is visible. Can two ReLU units classify all four?
  4. Three sectors. This requires three softmax outputs. Think of each output logit as favouring one direction.

Look and ask. A hidden unit with weight vector (a,b)(a,b) and bias cc has preactivation ax1+bx2+cax_1+bx_2+c. Where is its ReLU active? What geometric feature in its heatmap changes when you alter the weight vector, and what changes when you alter the bias?

2.2 Explain the diamond you found

Let x=(x1,x2)⊤\mathbf{x}=(x_1,x_2)^\top. Your diamond network should produce a signed scalar score ss: positive inside, negative outside, and zero on the boundary. Explain your construction on paper before translating it.

  1. Verify

    ReLU⁡(t)+ReLU⁡(−t)=∣t∣.\operatorname{ReLU}(t)+\operatorname{ReLU}(-t)=|t|.

    Use the identity to explain the four hidden heatmaps. Which input half-plane makes each unit active?

  2. Write the playground’s two layers as

    z1=W1x+b1,a1=ReLU⁡(z1),s=W2a1+b2.z_1=W_1\mathbf{x}+b_1, \qquad a_1=\operatorname{ReLU}(z_1), \qquad s=W_2a_1+b_2.

    Give every matrix and vector dimension. Show that your particular values produce

    s=1−∣x1∣−∣x2∣.s=1-|x_1|-|x_2|.
  3. How many hidden units can be active at one point? Show that the hidden layer still determines the input, but the scalar score does not. Give two very different points with the same score.

2.3 Translate the playground into one forward pass

Begin a notebook with PyTorch:

import torch

Now package the whole forward pass. The playground prints an executable W1 = torch.tensor(...) or W2 = torch.tensor(...) beneath each bundle of wires, and it prints the corresponding two lines of layer computation beneath each destination column. Copy those pieces into the scaffold below. The biases are the b values printed above the neuron tiles; transcribe them into b1 and b2 in the same top-to-bottom order as the matrix rows. We use the playground’s names here: z1 is the hidden layer before ReLU, and a1 is its activation after ReLU. The latter is the vector often called hh in formulas for a neural network.

Note that you will have to enter b1 = torch.tensor([...]) and b2 = torch.tensor([...]).


def diamond_net(X):
    # Copy the playground's two layer-1 computation lines.

    # The playground calls this layer's preactivation z2 and activation a2.
    # Copy W2 and the two computation lines, then enter its bias.

    return a2

The lecture writes one input as a column and uses W1xW_1\mathbf{x}. The playground’s copied code stores a batch with one sample per row, so it uses X @ W1.T. For a batch of nn points, the dimensions are

(n×2)(2×4)+(4)=(n×4),(n\times2)(2\times4)+(4)=(n\times4),

then

(n×4)(4×1)+(1)=(n×1).(n\times4)(4\times1)+(1)=(n\times1).

The bias vector is added to every row; this reuse is called broadcasting.

Python background: matrix multiplication, transpose, and broadcasting

The operator @ performs matrix multiplication, while .T transposes a two-dimensional array. Adding a length-mm vector to an n×mn\times m matrix adds that vector to every row.

import numpy as np
X = np.array([[1., 2.],
              [3., 4.],
              [5., 6.]])
W = np.array([[10., 0.],
              [0., 10.]])
b = np.array([1., -1.])
print(X @ W.T)
print(X @ W.T + b)
print((X @ W.T + b).shape)

Test diamond_net on the two points (0.7,0)(0.7,0) and (0.5,0.5)(0.5,0.5). Predict both outputs before running the code. How do the signs of the outputs determine the classifications?

2.4 Draw the decision surface

Evaluate the network on a fine grid, then plot its signed output:

import matplotlib.pyplot as plt

axis = torch.linspace(-1.6, 1.6, 201)
gx, gy = torch.meshgrid(axis, axis, indexing='xy')
grid = torch.stack([gx.reshape(-1), gy.reshape(-1)], dim=1)

surface = diamond_net(grid).reshape(gx.shape)

plt.figure(figsize=(6, 5))
contour_plot = plt.contourf(gx.numpy(), gy.numpy(), surface.numpy(),
                            levels=30, cmap='Purples')
plt.contour(gx.numpy(), gy.numpy(), surface.numpy(),
            levels=[0.0], colors='black', linewidths=2)
plt.xlabel('x1')
plt.ylabel('x2')
plt.axis('equal')
plt.colorbar(contour_plot, label='network output a2')
plt.show()

The black a2=0a_2=0 contour should be a diamond with vertices at (±1,0)(\pm1,0) and (0,±1)(0,\pm1).

Look and ask. The picture has more in it than the black line.

  1. Before you plot, predict where the a2=0.3a_2=0.3 and a2=−0.3a_2=-0.3 contours lie. Then add levels=[-0.3, 0.0, 0.3] to the plt.contour call and check.
  2. The colour bands run parallel to the diamond’s edges instead of forming concentric circles. Why? What happens to the bands at the four corners?
  3. Give all four hidden units the same bias, b1 = torch.full((4,), -0.3), and predict the shape of the a2=0a_2=0 contour before plotting it. Then try +0.3. Explain both pictures.
Solution: contours, bands, and what a bias does to the shape
  • The contours are concentric diamonds. Since a2=1−ra_2=1-r, the a2=0.3a_2=0.3 contour is r=0.7r=0.7, and the a2=−0.3a_2=-0.3 contour is r=1.3r=1.3.
  • Bands follow the edges because a2a_2 depends on x\mathbf{x} only through r=∣x1∣+∣x2∣r=|x_1|+|x_2|, and the level sets of rr are diamonds. Inside each open quadrant rr is linear with gradient (±1,±1)(\pm1,\pm1), so a2a_2 changes at the same rate everywhere along an edge. On the axes rr has a kink, so the bands bend sharply at the corners.
  • A shared bias of −0.3-0.3 makes an octagon. Each unit is now dead until its coordinate exceeds 0.30.3 in absolute value, so the hidden sum is r=max⁡(∣x1∣−0.3,0)+max⁡(∣x2∣−0.3,0)r=\max(|x_1|-0.3,0)+\max(|x_2|-0.3,0). The boundary r=1r=1 has vertical and horizontal sides where one coordinate sits in the dead zone (∣x1∣=1.3|x_1|=1.3 when ∣x2∣≤0.3|x_2|\le0.3), and diagonal sides ∣x1∣+∣x2∣=1.6|x_1|+|x_2|=1.6 elsewhere: an octagon with vertices (±1.3,±0.3)(\pm1.3,\pm0.3) and (±0.3,±1.3)(\pm0.3,\pm1.3).
  • A shared bias of +0.3+0.3 empties the inside. Now ReLU⁡(x+0.3)+ReLU⁡(−x+0.3)=max⁡(∣x∣,0.3)+0.3\operatorname{ReLU}(x+0.3)+\operatorname{ReLU}(-x+0.3)=\max(|x|,0.3)+0.3, so r≥1.2r\ge1.2 everywhere and nothing is inside; the largest output is a2=−0.2a_2=-0.2, at the origin. Biases move the kinks of the ReLUs, and the kinks are where the shape lives.
Python background: grids, reshape, and contour plots

A grid begins with two one-dimensional coordinate arrays. meshgrid repeats them into matrices of all coordinate pairs. Flattening those matrices and stacking their entries produces the usual “one point per row” batch. After evaluating the batch, reshape restores the grid so a plotting function knows where each value belongs.

import numpy as np
axis = np.linspace(-1, 1, 3)
gx, gy = np.meshgrid(axis, axis, indexing='xy')
points = np.stack([gx.reshape(-1), gy.reshape(-1)], axis=1)
print(gx)
print(gy)
print(points)
print(points.shape)

A filled contour plot colors regions according to a scalar value. A single contour at level 00 draws this classifier’s decision boundary.

2.5 Learn the same classifier from data

You chose the preceding weights by reasoning about the target. Now erase that construction and ask gradient descent to discover a classifier from labelled examples.

A. Set up a trainable diamond network

  1. Save a share link for your hand-built diamond if you want to compare with it later. Then click Reset and select the Diamond target.
  2. In Visualizers, open Training and close Output and Inspector. Leave the other cards closed for the moment.
  3. Change the final activation from identity to sigmoid. Its output zz is now a number between 00 and 11, interpreted as the predicted probability that the point is inside. The target yy supplied to the loss function is 11 inside and 00 outside.

First click Zero all parameters. Before training, predict what will happen. All four hidden neurons now have the same weights and bias. Will back propagation give them different gradients, or will they remain identical after every update? Why does this prevent them from learning the four different sides of the diamond? Try one update if you are unsure.

Now click Randomize parameters. Random initialization breaks the symmetry between the hidden neurons.

B. Write the loss

For one example, negative log likelihood is the negative logarithm of the probability assigned to the correct class:

ℓ(y,z)={−log⁡z,y=1,−log⁡(1−z),y=0.\ell(y,z)= \begin{cases} -\log z,&y=1,\\ -\log(1-z),&y=0. \end{cases}

The playground loss editor has no if statement. Find one expression for the probability assigned to the correct class that works for both y=0y=0 and y=1y=1. Hint: use abs, and remember that yy is exactly 00 or 11. Replace the default squared-error loss with the corresponding negative log loss. Click away from the editor or press Ctrl/⌘ Enter to check that it is a valid scalar loss.

Test your expression mentally with y=0y=0 and y=1y=1, for both a good prediction and a bad one. Which case should have the larger loss?

C. Inspect one gradient step

  1. Open Back propagation.
  2. Click a point in the Training window. The click adds that labelled point to the batch and selects it in the composition tree.
  3. Read the computation from input to loss. Red numbers are forward values; blue numbers are derivatives of the loss with respect to that node. Locate the blue derivatives at several weights and biases.
  4. Before pressing Train, decide which direction each displayed parameter should move. If a parameter is θ\theta and the blue number is ∂ℓ/∂θ\partial\ell/\partial\theta, write the update using the learning rate η\eta. Then press Train once and compare the new parameter value with your prediction.
  5. Try an inside point and an outside point. Which parts of the network get no gradient when a ReLU is inactive?

D. Train continuously

Open Batch. Generate a random batch, train once, and confirm that its average loss usually decreases on that batch. Then choose a larger number of data and click Train continuously. Each step replaces the batch with new random points before updating the parameters.

This experiment may need many samples and some adjustment of the learning rate. If the loss jumps or the picture becomes unstable, lower η\eta. If almost nothing changes, raise it. Stop training periodically, reopen Output, and compare the learned decision boundary with the target and with your hand-built network.

E. Repeat with the other targets

  1. XOR. Select the XOR target, use one sigmoid output, randomize, and reuse the binary negative log loss. Can continuous training classify all four marked points? Try different hidden widths and depths.
  2. Three sectors. Select the target, use exactly three outputs, and set the final activation to softmax. Now yy is a one-hot vector and zz is a vector of three probabilities. Construct a scalar negative log loss from the probability assigned to the correct class. You may use abs, sum, and mean; the scalar 1 can be broadcast as a vector of ones. Train, tune the batch size and learning rate, and compare the learned sector boundaries with the target.
Solution: initialization, losses, and the gradient update

At exact zero initialization every hidden activation is zero and every output weight is zero. The output-weight gradients contain the hidden activations, while the hidden-layer gradients contain the output weights (and, here, the derivative of ReLU at zero is also zero). Those gradients therefore vanish; only the output bias can move. More generally, hidden units initialized identically receive identical gradients and remain identical because permuting them does not change the network. Random initialization breaks this symmetry and lets different units learn different features.

For a binary target, the probability assigned to the correct class is

∣1−y−z∣={1−z,y=0,z,y=1.|1-y-z|= \begin{cases} 1-z,&y=0,\\ z,&y=1. \end{cases}

Thus the playground expression is

return -log(abs(1 - y - z))

Gradient descent updates every weight and bias by

θ←θ−η∂ℓ∂θ.\theta\leftarrow\theta-\eta\frac{\partial\ell}{\partial\theta}.

For three-sector softmax, the standard categorical cross-entropy can use the one-hot target to select the predicted probability directly:

return -log(sum(y * z))

There is also a three-class version of the absolute-value trick. Since the entries of zz sum to 11,

∑i∣1−yi−zi∣−12=ztarget,\frac{\sum_i |1-y_i-z_i|-1}{2}=z_{\mathrm{target}},

so this is an equivalent expression:

return -log((sum(abs(1 - y - z)) - 1) / 2)

Part B — From counts to parameters

You have now wired a network whose weights were chosen. The rest of the course is about weights that are learned, and this part is the first training run: the same bigram model as Step 1, rebuilt as softmax of a linear map and trained by gradient descent. To be clear about what is and is not here: the model is multinomial logistic regression, with no hidden layer and no embedding, so it is not yet the neural nn-gram of Lecture 2, Example 4.7. Gradient descent is here in full; what is missing is a gradient through a hidden layer, and that is Step 3.

On paper first

This derivation is the mathematical core of the step; the code is 30 lines.

  1. The model. One-hot encode the current character: ea∈RVe_a \in \mathbb{R}^V. Set logits z=W⊤ea=Wa,:z = W^{\top} e_a = W_{a,:} for a parameter matrix W∈RV×VW \in \mathbb{R}^{V\times V}, and model

    pθ(b∣a)=softmax⁡(z)b=ezb∑cezc.p_\theta(b \mid a) = \operatorname{softmax}(z)_b = \frac{e^{z_b}}{\sum_c e^{z_c}}.

    Convince yourself this is the fully general row-stochastic matrix, reparametrized: softmax maps RV\mathbb{R}^V onto the interior of the simplex, with kernel the constants (so WW is identifiable only up to a constant per row). This is exactly Lecture 2’s multinomial logistic regression with one-hot features.

  2. The gradient. For one observed pair (a,b)(a,b) the loss is ℓ=−log⁡pθ(b∣a)=−zb+log⁡∑cezc\ell = -\log p_\theta(b\mid a) = -z_b + \log\sum_c e^{z_c}. Show:

    ∂ℓ∂zj=pj−1j=b,i.e.∇zℓ=p−y\frac{\partial \ell}{\partial z_j} = p_j - \mathbf 1_{j=b}, \qquad\text{i.e.}\qquad \nabla_z \ell = p - y

    where p=softmax⁡(z)p = \operatorname{softmax}(z) and y=eby = e_b is the one-hot target — this is Proposition 3.4. Probabilities minus target — memorize this; it is the last gradient anyone ever computes by hand, and in Step 3 it becomes the seed of backpropagation.

  3. Sanity structure. Note ∇zℓ\nabla_z \ell has components summing to zero. Why must it, given the kernel observation in 1?

  4. Convexity (this model only!). ℓ\ell is convex in zz (log-sum-exp is convex; linear terms don’t spoil it), and the total loss is convex in WW. So for this one week gradient descent is guaranteed to find the global optimum — which by Lecture 1’s MLE computation is the count model. From Step 3 onward, convexity is gone forever.

2.6 Data as tensors

From Step 1’s ids, build training pairs:

xs = torch.tensor(ids[:-1])   # list slicing; current char, shape (n,)
ys = torch.tensor(ids[1:])    # next char,    shape (n,)
n = len(xs)                   # 1,115,393

Look and ask. A million training pairs is a lot of tensor. Is it more information than Step 1’s 65×6565\times65 count matrix NN?

  1. Write the training loss L(W)\mathcal L(W) as a formula in NN alone, and explain why the million-row tensor cannot contain anything the counts do not.
  2. Then say why we build it anyway. (What will be different about the input in Step 3?)
Solution: the loss depends on the data only through the counts

Every pair (a,b)(a,b) contributes −log⁡softmax⁡(Wa,:)b-\log\operatorname{softmax}(W_{a,:})_b, and identical pairs contribute identical terms, so

L(W)=−1n∑a,bNab log⁡softmax⁡(Wa,:)b.\mathcal L(W) = -\frac{1}{n}\sum_{a,b} N_{ab}\,\log \operatorname{softmax}(W_{a,:})_b .

Check it: computing this from N gives 2.45492.4549 at W=log⁡PW=\log P, the same number the pair-by-pair loss_fn returns. Sufficient statistics for a bigram model are the bigram counts; the corpus order is irrelevant.

We build the pair tensor anyway because this is the last model whose input is a bare character id. From Step 3 the input is a vector of learned features, the loss is no longer a sum over a small table, and the counts stop summarizing anything. The pipeline is being set up for models that need it.

2.7 Forward pass

Implement the loss as a function of WW (a (V, V) float tensor, initialized torch.randn(V, V) * 0.01):

def loss_fn(W):                                     # function definition (def)
    logits = W[xs]                                  # indexing by a tensor; (n, V): row a of W is z for input a
    logits = logits - logits.max(dim=1, keepdim=True).values   # stability: softmax is shift-invariant
    p = logits.exp()
    p = p / p.sum(dim=1, keepdim=True)              # (n, V) softmax rows
    return -p[torch.arange(n), ys].log().mean(), p  # method chaining; returns a tuple

Why do we subtract the maximum in the second line? Prove that this has no effect on the return values, mathematically. What purpose does it serve computationally?

Look and ask. Before training, evaluate the loss at three weight matrices you choose by hand, predicting each number before you run it.

  1. W=0W=0.
  2. W=log⁡PW=\log P, with PP Step 1’s add-one transition matrix.
  3. W=log⁡PW=\log P plus a different constant on every row, say + 3 * torch.randn(V, 1).

Then: what is the loss at your random initialization, and why is it so close to the first number?

Solution: three landmarks in parameter space
print(loss_fn(torch.zeros(V, V))[0])                          # 4.1744
print(loss_fn(P.log())[0])                                    # 2.4549
print(loss_fn(P.log() + 3 * torch.randn(V, 1))[0])            # 2.4549
  • W=0W=0 is the uniform model, every row of softmax equal to 1/651/65, so the loss is ln⁡65=4.1744\ln 65 = 4.1744 exactly: the perplexity-65 baseline of Step 1.
  • W=log⁡PW=\log P is Step 1’s model. Softmax of log⁡Pa,:\log P_{a,:} returns Pa,:P_{a,:}, so the loss is Step 1’s 2.45492.4549 to every digit. The count model is a point in this parameter family, which is what makes the punchline of Task 2.10 possible.
  • The row constants change nothing, because softmax is invariant to adding a constant to its input. Sixty-five directions in the 4,2254{,}225-dimensional parameter space are invisible to the loss; a row-stochastic matrix has only 65×64=4,16065\times64=4{,}160 free entries.
  • The random start scores 4.17314.1731, a hair below ln⁡65\ln 65: weights of size 0.010.01 leave the softmax rows within a percent of uniform.

2.8 Backward pass — by hand

Using your derivation (∇zℓ=p−y\nabla_z \ell = p - y, averaged over the batch, routed back to the rows of WW):

def grad_fn(W):
    _, p = loss_fn(W)                      # tuple unpacking; _ = throwaway name
    dlogits = p.clone()
    dlogits[torch.arange(n), ys] -= 1.0    # p - y
    dlogits /= n                           # in-place division (/=)
    dW = torch.zeros_like(W)
    dW.index_add_(0, xs, dlogits)          # trailing _ = in-place torch method; rows of W accumulate over their occurrences
    return dW
Python background: in-place operations — mutating vs. rebinding

Two names can refer to the same object. An in-place operation like b += [4] (or dlogits /= n) modifies that shared object; a rebinding like b = b + [5] builds a brand-new object and points the name at it, leaving the original alone. The difference is invisible until two names share an object — then it is everything.

a = [1, 2, 3]
b = a              # b is another name for the SAME list, not a copy
b += [4]           # in-place: modifies the one shared list
print(a)           # a sees the change
b = b + [5]        # rebinding: builds a NEW list, b points at it
print(a)           # a is unchanged this time
print(b)

PyTorch marks its in-place methods with a trailing underscore — index_add_ above modifies dW’s memory directly, where index_add would return a modified copy. The “in-place vs reassignment” bug in the Troubleshooting list is exactly this distinction applied to W.

PyTorch background: index_add_

dW.index_add_(0, xs, dlogits) walks the rows of dlogits and adds row t into row xs[t] of dW — accumulating, so a row named several times receives the sum of its contributions. It is exactly this loop, vectorized (and, per the previous foldout, the trailing underscore says it modifies dW in place):

for t in range(len(xs)):
    dW[xs[t]] += dlogits[t]

Why it is the right tool here: an input character a may occur tens of thousands of times in the corpus, and every occurrence contributes its own p−yp - y to the same row aa of the gradient. If you want to experiment in a live box, the NumPy spelling is np.add.at(dW, xs, dlogits).

Validate before training. Check against a central finite difference: pick a few random entries (i,j)(i,j) and compare (L(W+h Eij)−L(W−h Eij))/2h\bigl(\mathcal{L}(W + h\,E_{ij}) - \mathcal{L}(W - h\,E_{ij})\bigr)/2h to grad_fn(W)[i, j]. Adopt this habit permanently: never trust a gradient you haven’t finite-differenced.

⚠️ Do this in float64 — cast W with .double() first. In float32 the check fails on a perfectly correct gradient, and it is worth understanding why before you waste an afternoon on it. A central difference carries two competing errors: truncation O(h2)O(h^2) from the Taylor remainder, and roundoff O(ε∣L∣/h∣g∣)O(\varepsilon |\mathcal L| / h|g|) from cancelling two nearly equal numbers. Here L≈2.5\mathcal L \approx 2.5 while an individual ∂L/∂Wij≈2×10−5\partial\mathcal L/\partial W_{ij} \approx 2\times 10^{-5} (the loss is a mean over nn examples, so each of the 4,225 entries gets a small share), and the numerator L(W+hE)−L(W−hE)≈2hg≈4×10−8\mathcal L(W+hE)-\mathcal L(W-hE) \approx 2hg \approx 4\times10^{-8} sits below float32’s resolution of ε∣L∣≈3×10−7\varepsilon|\mathcal L| \approx 3\times10^{-7}. The signal is entirely lost in the subtraction. Measured relative error, same code and same hh, only the dtype changing:

dtypeh=10−3h=10^{-3}h=10−1h=10^{-1}
float325.9 ❌8.9×10−28.9\times10^{-2}
float643.3×10−73.3\times10^{-7} ✅3.2×10−33.2\times10^{-3}

Note the columns move in opposite directions: raising hh helps float32 (less cancellation) and hurts float64 (more truncation) — the classic U-shaped error curve, with optimum near h∼ε1/3h \sim \varepsilon^{1/3}. In float64 at h=10−3h=10^{-3} you get the ~6 significant figures the check is supposed to give.

Once in float64, agreement to ~6 significant figures (relative error ≲10−6\lesssim 10^{-6}) or your gradient is wrong.

Look and ask. Compute dW = grad_fn(W) at the initialization and study it before you take a single step with it.

  1. Print dW.sum(dim=1). Predict the answer from the sanity-structure item of the derivation. Then deduce something about W.sum(dim=1) during training, and check it after 300 steps.
  2. Rank the rows of dW by norm (dW.norm(dim=1).argsort()). Predict which characters are at the top and at the bottom before looking, and compare with how often each character occurs. Why is the row for e only about 150150 times larger than the row for Q, when e occurs 410410 times more often?
Solution: reading the gradient before using it
dW = grad_fn(W)
print(dW.sum(dim=1).abs().max())            # ~ 4e-5: zero, to float32 roundoff
norms = dW.norm(dim=1)
order = norms.argsort(descending=True)
print([vocab[i] for i in order[:5]])        # [' ', 'e', 't', 'h', 'o']
print([vocab[i] for i in order[-5:]])       # ['Z', 'X', '3', '&', '$']
counts = N.sum(dim=1).float()
print(torch.corrcoef(torch.stack([norms, counts]))[0, 1])   # 0.94
  • Every row of the gradient sums to zero, because each ∇zℓ=p−y\nabla_z\ell = p - y does and row aa of dWdW is a sum of such vectors. So gradient descent never moves WW along the invisible directions of Task 2.7: W.sum(dim=1) is a conserved quantity. In float32 it drifts by about 0.0060.006 over 300 steps (row sums start near 0.070.07); redo the run in .double() and the drift is 4×10−124\times10^{-12}. The invariance is exact; the drift is roundoff.
  • Gradient size tracks frequency. Near W=0W=0 the softmax rows are uniform, so row aa of dWdW is countan (u−qa)\frac{\text{count}_a}{n}\,(u - q_a), where uu is the uniform vector and qaq_a the empirical distribution of what follows aa. The correlation of row norm with count is 0.940.94: space, e, t at the top, & and $ (three and one occurrences) at the bottom.
  • The factor u−qau-q_a is why the ratio is not 410410. Q is followed by U 93%93\% of the time, so qQq_Q is far from uniform and ∥u−qQ∥\lVert u-q_Q\rVert is large, while the successors of e are spread out. The count ratio 410410 is cut to 146146. Either way the rare rows receive tiny gradients, which is the seed of the punchline in Task 2.10.

2.9 Gradient descent

W = torch.randn(V, V) * 0.01
for step in range(300):             # for loop over range
    L, _ = loss_fn(W)
    W -= 50.0 * grad_fn(W)          # yes, learning rate 50 — see checkpoint discussion
    if step % 20 == 0:              # % = modulo (remainder)
        print(step, L.item())       # .item(): one-element tensor -> Python number
Python background: for loops over range, if, and %

range(n) produces the integers 0, 1, ..., n-1, so for step in range(300): runs its indented block 300 times with step counting up. An if condition: runs its block only when the condition holds; == tests equality (one = assigns, two compare). The operator % gives the remainder after division, so step % 20 == 0 is true every 20th step — the standard “print occasionally” idiom.

for step in range(5):          # 0, 1, 2, 3, 4 — stops before 5
    print(step)
print(list(range(2, 20, 5)))   # optional start and stride
print(17 % 5)                  # remainder of 17 divided by 5
total = 0
for step in range(100):
    if step % 20 == 0:         # true for step = 0, 20, 40, 60, 80
        total += 1
print(total)

Then experiment with the learning rate: 0.1, 1, 50, 500. Record what you see. (With the full dataset as one batch the loss is smooth and convex, so this is the cleanest look at step-size behaviour you’ll ever get.)

Measured, loss after just 30 steps from the same initialization:

lr0.1150500
loss @ 304.1584.0242.7425.401

Two lessons in one table. The tiny rates are not wrong — they are converging, just impractically slowly from ln⁡65=4.174\ln 65 = 4.174. And lr 500 has gone above where it started: it overshoots the valley each step. Why does this well-behaved convex problem tolerate a learning rate of 50 when Step 3’s network will need ≈0.1\approx 0.1? Think about the scale of ∇W\nabla_W: it is a mean over a million examples spread across 4,225 entries, hence tiny (of order 10−510^{-5}), and the step size has to compensate. Learning rates are not transferable constants; they are inverse to the curvature and gradient scale of the specific problem.

Look and ask. The table says 5050 beats 0.10.1, 11, and 500500. It does not say 5050 is best.

  1. Sweep the learning rate over 50,100,150,200,250,300,400,50050, 100, 150, 200, 250, 300, 400, 500 from the same initialization and record the loss after one step and after thirty. Which rate wins each contest? Why are they different?
  2. Plot the gap between your loss and Step 1’s 2.45492.4549 against the step number on a log scale. Is it a straight line? What would a straight line have meant?
Solution: the best step size depends on how far you are going

Measured from the seed-0 initialization:

lr50100150200250300400500
loss after 1 step3.9243.7013.5253.4213.4023.4543.7124.095
loss after 30 steps2.7422.6262.6462.8483.1783.4524.6075.401
  • One step likes 250250; thirty steps like 100100. A single step only asks how far to slide along one direction. Over many steps a large rate overshoots in the stiff, high-curvature directions of the loss, which then oscillate and grow, while a small rate leaves the soft directions crawling. The usable rate over a run is capped by the stiffest direction, a constraint that does not bite on step one. At 300300 the run stalls (loss 3.4543.454 after one step, 3.4523.452 after thirty); at 400400 it climbs.
  • The gap is not a straight line. At steps 50,100,…,30050,100,\dots,300 the gap to the count model is 0.198,0.114,0.080,0.062,0.050,0.0420.198, 0.114, 0.080, 0.062, 0.050, 0.042: each fifty steps removes a smaller fraction than the last (58%58\%, then 70%70\%, 77%77\%, 81%81\%, 84%84\% of the previous gap remains). A straight line on a log scale would mean a single geometric rate. What you see is a sum of modes with different rates: the frequent rows are finished early, and the tail is the rare rows converging slowly. Task 2.10 puts names to those rows.

2.10 The punchline

Compare your trained model to Step 1’s count model:

You have two proofs that they must agree in the limit. But look carefully at what 300 steps actually delivers — the numbers are in the checkpoints, and the honest answer is “close, but visibly not there yet.” That gap is the point of the following question, which is really a numerical-analysis question rather than a machine-learning one:

Gradient descent on a convex problem converges at a rate governed by the condition number of the Hessian. What is the Hessian here, and why is it badly conditioned? (Hint: the row of WW for the character Z receives gradient contributions only from the handful of times Z occurs. Rows whose characters are common get large, informative gradients; rows for rare characters get almost nothing. Now look at the per-row agreement in the checkpoints and see whether it tracks that prediction.)

This is the first appearance of a theme that runs through Lecture 6: a convergence guarantee and a convergence rate are very different things, and essentially all of the practical craft of training lives in the gap.

Finally: which of Step 1’s ingredients plays the role of the +1+1 smoothing here? (Hint: what does initializing W≈0W \approx 0 mean in probability space, and does 300 steps fully erase it?)

Look and ask. Put numbers on both questions.

  1. Every 50 steps, record max⁡b∣softmax⁡(W)ab−Pab∣\max_b\lvert\operatorname{softmax}(W)_{ab}-P_{ab}\rvert for the rows a∈{a\in\{space, e, t, Q, Z, X}\} and plot the six curves together. Predict the ordering before you look.
  2. After 300 steps, find the never-seen pair (a cell with Nab=0N_{ab}=0) to which the model assigns the most probability. Which pair is it, what probability does it get, and why that pair? Compare with what add-one smoothing would give it.
Solution: watching the rows converge, and finding the smoothing

Measured at learning rate 50 from the seed-0 initialization:

row (count)step 050100150200250300
space (169,892)0.1250.00140.00070.00050.00040.00030.0003
e (94,611)0.2770.00330.00150.00100.00070.00060.0005
t (67,009)0.3240.00560.00280.00180.00140.00110.0009
Q (231)0.7180.7090.6940.6730.6410.5980.543
Z (198)0.5290.5230.5160.5060.4930.4770.456
X (112)0.3860.3840.3810.3770.3730.3690.364
  • Two regimes, not one curve. The three common rows are within 0.0060.006 of PP after fifty steps and keep tightening. The three rare rows have barely moved after three hundred. Look at the worst cell: the count model says Q is followed by U with probability 0.9350.935 (add-one: 0.7330.733), and after 300 steps the trained model says 0.1900.190, up from 1/65=0.0151/65=0.015 at the start. The row is converging; it is just receiving a gradient a few hundred times smaller than e’s. (About 0.20.2 of the Q gap is add-one smoothing itself, since PP is the smoothed matrix; measured against the unsmoothed counts the row is even further away, 0.7450.745.)
  • The most-favoured never-seen pair is & followed by ;, at probability 0.01570.0157, which is 1/651/65 to three decimals. The character & occurs three times in the corpus, so its row of WW has received almost no gradient and is still the uniform initialization. For comparison, the never-seen successors of e have been pushed down to at most 0.00040.0004 (add-one would give them 0.000010.00001).
  • So the smoothing is the initialization. Starting at W≈0W\approx0 starts every row at the uniform distribution, and 300 steps erase that prior only in proportion to how much data a row has seen. Rare rows keep it. Add-one smoothing adds a fixed pseudo-count; early stopping from a uniform start does something similar with no pseudo-count at all, and later in the course you will meet it under the name regularization.

Checkpoints

Part A:

Part B:

Troubleshooting

Part A:

Part B:

Going further (optional)

Part A:

Part B:

Catch-up

Joining now? Part A is self-contained: skim Lecture 2, Section 4 and Section 7, then complete the playground diamond and translate it with the scaffold in Task 2.3. Part B depends only on Lecture 2, Section 3 and the shape of Step 1’s data: run solutions/step-01.ipynb (5 min), skim its prose, then do Tasks 2.6–2.10 in full. You do not need anything else from the Shakespeare corpus until Step 3.