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

    Nothing is trained; the point is to see every number in a forward pass.

  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 a diamond classifier by hand

On paper first

Let x=(x1,x2)R2\mathbf{x}=(x_1,x_2)^\top\in\mathbb R^2.

  1. Verify

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

    Use it to design four hidden units whose activations sum to r=x1+x2r=|x_1|+|x_2|.

  2. Write those four units in the matrix form

    a1=W1x+b1,h=ReLU(a1),a_1=W_1\mathbf{x}+b_1, \qquad h=\operatorname{ReLU}(a_1),

    including the dimensions of W1W_1, b1b_1, a1a_1, and hh.

  3. We want two output logits, ordered as outside and inside:

    zout=γ(r1),zin=γ(1r),γ>0.z_{\mathrm{out}}=\gamma(r-1), \qquad z_{\mathrm{in}}=\gamma(1-r), \qquad \gamma>0.

    Find W2R2×4W_2\in\mathbb R^{2\times4} and b2R2b_2\in\mathbb R^2 such that z=W2h+b2z=W_2h+b_2.

  4. Use the two-class identity from Lecture 2, Aside 3.1a to show

    p(inx)=σ(2γ(1x1x2)).p(\mathrm{in}\mid\mathbf{x}) = \sigma\bigl(2\gamma(1-|x_1|-|x_2|)\bigr).

    Deduce the decision boundary and explain why changing γ\gamma cannot move it.

Do this derivation before writing code.

2.1 Write the hidden units separately

Begin with five points:

import torch

X = torch.tensor([
    [ 0.0,  0.0],   # inside
    [ 0.5,  0.25],  # inside
    [ 1.0,  0.0],   # boundary
    [ 0.8,  0.5],   # outside
    [-0.4, -0.2],   # inside, negative coordinates
])
print(X.shape)      # (5, 2): five points, two features per point

Each row of XX is one feature vector. Compute the four hidden activations one at a time:

h_units = torch.stack([
    torch.relu( X[:, 0]),
    torch.relu(-X[:, 0]),
    torch.relu( X[:, 1]),
    torch.relu(-X[:, 1]),
], dim=1)

print(h_units)
print(h_units.shape)                 # (5, 4)
r_units = h_units.sum(dim=1)
print(r_units)                       # |x1| + |x2| for each point

For each of the four columns, say in words which input half-plane makes that unit active. Then trace the row for (0.4,0.2)(-0.4,-0.2) by hand before looking at the printed result.

Look and ask. Before moving on, interrogate h_units rather than just checking it runs.

  1. Count the nonzero entries in each row. What is the largest number of the four units that can be active at one point, and why can no point ever activate three?
  2. The map xh\mathbf{x}\mapsto h is nonlinear, yet nothing is lost: explain how to recover x\mathbf{x} from hh. Then say what is lost when the four activations are summed to rr. Name two very different points the classifier cannot tell apart.
Solution: what the hidden layer knows
print((h_units > 0).sum(dim=1))          # tensor([0, 2, 1, 2, 2])
print(h_units[:, 0] - h_units[:, 1])     # recovers x1 for every point
print(h_units[:, 2] - h_units[:, 3])     # recovers x2
  • At most two units fire. Units 1 and 2 are ReLU(x1)\operatorname{ReLU}(x_1) and ReLU(x1)\operatorname{ReLU}(-x_1): one is active when x1>0x_1>0, the other when x1<0x_1<0, never both. Units 3 and 4 split x2x_2 the same way. So each coordinate lights at most one of its pair: the origin fires none, a point on an axis fires one, everything else fires exactly two.
  • Nothing is lost by the hidden layer. x1=h1h2x_1 = h_1 - h_2 and x2=h3h4x_2 = h_3 - h_4, so hh determines x\mathbf{x}. The nonlinearity has unfolded each coordinate into a positive part and a negative part.
  • Everything but rr is lost by the sum. The output layer sees only r=x1+x2r=|x_1|+|x_2|, so all points on one diamond x1+x2=c|x_1|+|x_2|=c are identical to it: (0.5,0.25)(0.5,0.25), (0.75,0)(0.75,0), and (0.25,0.5)(-0.25,-0.5) get the same probability. That is the design, and it is also the limitation: this network can only ever draw diamonds.
Python background: tensor slices, torch.stack, and dim

For a two-dimensional array, A[:, 0] means “every row, column 0.” Stacking several one-dimensional arrays with dim=1 makes them columns of a new matrix. NumPy has the same operations, so the browser example below uses NumPy.

import numpy as np
A = np.array([[10, 11],
              [20, 21],
              [30, 31]])
first_column = A[:, 0]
second_column = A[:, 1]
print(first_column)
print(np.stack([first_column, second_column], axis=1))
print(np.stack([first_column, second_column], axis=0))

Compare the last two shapes. In PyTorch the argument is named dim; in NumPy it is named axis.

2.2 Assemble the first layer as a matrix

Now encode exactly the same four units in one weight matrix and one bias vector:

W1 = torch.tensor([
    [ 1.0,  0.0],
    [-1.0,  0.0],
    [ 0.0,  1.0],
    [ 0.0, -1.0],
])
b1 = torch.zeros(4)

a1 = X @ W1.T + b1
h = torch.relu(a1)

print(a1.shape, h.shape)             # both (5, 4)
assert torch.allclose(h, h_units)

The lecture writes one input as a column and uses W1xW_1\mathbf{x}. Code conventionally stores a batch with one sample per row, so the same multiplication is X @ W1.T. Check the dimensions on paper:

(5×2)(2×4)+(4)=(5×4).(5\times2)(2\times4)+(4) = (5\times4).

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)

Matrix dimensions are not decoration: the inner dimensions must agree, and the remaining dimensions give the output shape.

2.3 Wire the output layer and softmax

Use γ=4\gamma=4 first:

gamma = 4.0
W2 = gamma * torch.tensor([
    [ 1.0,  1.0,  1.0,  1.0],   # outside logit
    [-1.0, -1.0, -1.0, -1.0],   # inside logit
])
b2 = gamma * torch.tensor([-1.0, 1.0])

logits = h @ W2.T + b2            # (5, 2)
shifted = logits - logits.max(dim=1, keepdim=True).values
weights = shifted.exp()
probs = weights / weights.sum(dim=1, keepdim=True)

print(logits)
print(probs)
print(probs.sum(dim=1))

Explain every dimension in

(5×4)(4×2)+(2)=(5×2).(5\times4)(4\times2)+(2)=(5\times2).

Then explain why subtracting the largest logit in each row changes no softmax probability. Your row sums should equal 11 up to floating-point roundoff.

The output columns are outside and inside, in that order. On the boundary the logits tie, so both probabilities are 1/21/2. If you use argmax, PyTorch breaks that tie by returning the first maximizing index; that software convention is separate from the mathematical classifier.

Look and ask.

  1. The points (0.7,0)(0.7,0) and (0.5,0.5)(0.5,0.5) are at essentially the same Euclidean distance from the origin (0.7000.700 against 0.7070.707). Predict their inside probabilities, then run them through the network. What notion of distance is this network measuring?
  2. Take the five probabilities you just printed at γ=4\gamma=4 and predict, before running, what they become at γ=1\gamma=1 and at γ=20\gamma=20. Which entries move, which cannot, and which way?
Solution: the network's geometry, and what $\gamma$ does
pair = torch.tensor([[0.7, 0.0], [0.5, 0.5]])
r = pair.abs().sum(dim=1)                     # tensor([0.7000, 1.0000])
print(torch.sigmoid(2 * gamma * (1 - r)))     # inside: 0.9168 and 0.5000
# (or, once Task 2.4 is done: diamond_net(pair)[3][:, 1])
  • Same Euclidean distance, very different verdicts. (0.7,0)(0.7,0) has r=0.7r=0.7 and is confidently inside (p=0.9168p=0.9168); (0.5,0.5)(0.5,0.5) has r=1r=1 and sits exactly on the boundary (p=0.5p=0.5). The network measures distance by x1+x2|x_1|+|x_2|, the taxicab or 1\ell^1 norm, whose unit ball is the diamond. Circles are invisible to it.
  • Only the boundary point is pinned. Since p(inx)=σ(2γ(1r))p(\mathrm{in}\mid\mathbf{x})=\sigma(2\gamma(1-r)), the point with r=1r=1 gives σ(0)=1/2\sigma(0)=1/2 for every γ\gamma; every other probability is pushed toward 00 or 11 as γ\gamma grows and toward 1/21/2 as γ\gamma shrinks. At γ=1\gamma=1 the five inside probabilities are 0.88080.8808, 0.62250.6225, 0.50.5, 0.35430.3543, 0.69000.6900; at γ=20\gamma=20 they are 1.00001.0000, 0.999950.99995, 0.50.5, 0.0000060.000006, 1.00001.0000. Confidence is a separate dial from geometry, which is exactly why γ\gamma cannot move the decision boundary.

2.4 Package one forward pass

Turn the two matrix layers into a function. Return the intermediate values, not only the final probabilities: this step is about seeing the computation.

def diamond_net(X, gamma=4.0):
    W1 = torch.tensor([
        [ 1.0,  0.0],
        [-1.0,  0.0],
        [ 0.0,  1.0],
        [ 0.0, -1.0],
    ], dtype=X.dtype)
    b1 = torch.zeros(4, dtype=X.dtype)

    W2 = gamma * torch.tensor([
        [ 1.0,  1.0,  1.0,  1.0],
        [-1.0, -1.0, -1.0, -1.0],
    ], dtype=X.dtype)
    b2 = gamma * torch.tensor([-1.0, 1.0], dtype=X.dtype)

    a1 = X @ W1.T + b1
    h = torch.relu(a1)
    logits = h @ W2.T + b2

    shifted = logits - logits.max(dim=1, keepdim=True).values
    weights = shifted.exp()
    probs = weights / weights.sum(dim=1, keepdim=True)
    return a1, h, logits, probs

a1, h, logits, probs = diamond_net(X)
assert a1.shape == (5, 4)
assert h.shape == (5, 4)
assert logits.shape == (5, 2)
assert probs.shape == (5, 2)

Add two assertions of your own:

2.5 Draw the decision surface

Evaluate the network on a fine grid, then plot its inside probability:

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)

_, _, _, grid_probs = diamond_net(grid, gamma=4.0)
p_inside = grid_probs[:, 1].reshape(gx.shape)

plt.figure(figsize=(6, 5))
contour_plot = plt.contourf(gx.numpy(), gy.numpy(), p_inside.numpy(),
                            levels=30, cmap='Purples')
plt.contour(gx.numpy(), gy.numpy(), p_inside.numpy(),
            levels=[0.5], colors='black', linewidths=2)
plt.scatter(X[:, 0], X[:, 1], c='red', s=30)
plt.xlabel('x1')
plt.ylabel('x2')
plt.axis('equal')
plt.colorbar(contour_plot, label='p(inside | x)')
plt.show()

The black p=1/2p=1/2 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 p=0.9p=0.9 and p=0.1p=0.1 contours lie at γ=4\gamma=4 (solve σ(2γ(1r))=0.9\sigma(2\gamma(1-r))=0.9 for rr). Then add levels=[0.1, 0.5, 0.9] 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 p=1/2p=1/2 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. σ(2γ(1r))=0.9\sigma(2\gamma(1-r))=0.9 means 2γ(1r)=ln92\gamma(1-r)=\ln 9, so at γ=4\gamma=4 the p=0.9p=0.9 contour is r=1ln9/8=0.725r=1-\ln 9/8=0.725 and the p=0.1p=0.1 contour is r=1.275r=1.275. Halving γ\gamma doubles the width of that band.
  • Bands follow the edges because pp depends on x\mathbf{x} only through r=x1+x2r=|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 pp 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(x10.3,0)+max(x20.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 x20.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 r1.2r\ge1.2 everywhere and nothing is inside; the largest inside probability on the grid is 0.1680.168, 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 1/21/2 draws the classifier’s decision boundary.

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: eaRVe_a \in \mathbb{R}^V. Set logits z=Wea=Wa,:z = W^{\top} e_a = W_{a,:} for a parameter matrix WRV×VW \in \mathbb{R}^{V\times V}, and model

    pθ(ba)=softmax(z)b=ezbcezc.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 =logpθ(ba)=zb+logcezc\ell = -\log p_\theta(b\mid a) = -z_b + \log\sum_c e^{z_c}. Show:

    zj=pj1j=b,i.e.z=py\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 logsoftmax(Wa,:)b-\log\operatorname{softmax}(W_{a,:})_b, and identical pairs contribute identical terms, so

L(W)=1na,bNablogsoftmax(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=logPW=\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
Python background: tuples and multiple return values

A tuple is a fixed sequence of values, written with commas — the parentheses are usually optional, so return a, b returns one tuple holding two results. The caller can unpack it into separate names in one step, and the conventional name _ marks a slot you don’t need.

def divide(a, b):
    return a // b, a % b       # the comma builds a tuple: two results at once
q, r = divide(17, 5)           # unpack into two names
print(q, r)
both = divide(17, 5)           # or keep the pair as a single tuple
print(both)
print(both[0])                 # tuples index and slice like lists
_, r = divide(17, 5)           # _ = a name for "I don't need this one"
print(r)

That is why this step’s code says L, _ = loss_fn(W) when it only wants the loss, and _, p = loss_fn(W) when it only wants the probabilities.

Python background: indexing with an integer array (row gather)

Step 1’s fancy-indexing foldout used a pair of index arrays to pick single entries. A single integer array picks whole rows: W[xs] stacks up row xs[0], row xs[1], … — one row per training example, repeats allowed. As before, the box uses NumPy; PyTorch tensors behave identically.

import numpy as np
W = np.array([[10., 11.],
              [20., 21.],
              [30., 31.]])
idx = np.array([2, 0, 0, 1])
print(W[idx])                  # one row per index — shape (4, 2)
print(W[idx].shape)

In the model, W[xs] gathers the logit row for each input character in the whole corpus at once — a million rows, no loop.

The subtraction of the row max is your first encounter with the difference between mathematics and floating point: prove (one line) that it changes nothing, then try removing it with * 10 larger initial weights and watch the infs.

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=logPW=\log P, with PP Step 1’s add-one transition matrix.
  3. W=logPW=\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 ln65=4.1744\ln 65 = 4.1744 exactly: the perplexity-65 baseline of Step 1.
  • W=logPW=\log P is Step 1’s model. Softmax of logPa,:\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 ln65\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=py\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 pyp - 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+hEij)L(WhEij))/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/hg)O(\varepsilon |\mathcal L| / h|g|) from cancelling two nearly equal numbers. Here L2.5\mathcal L \approx 2.5 while an individual L/Wij2×105\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(WhE)2hg4×108\mathcal L(W+hE)-\mathcal L(W-hE) \approx 2hg \approx 4\times10^{-8} sits below float32’s resolution of εL3×107\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=103h=10^{-3}h=101h=10^{-1}
float325.9 ❌8.9×1028.9\times10^{-2}
float643.3×1073.3\times10^{-7}3.2×1033.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=103h=10^{-3} you get the ~6 significant figures the check is supposed to give.

Once in float64, agreement to ~6 significant figures (relative error 106\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=py\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×10124\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(uqa)\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 uqau-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 uqQ\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 ln65=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 10510^{-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 W0W \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 maxbsoftmax(W)abPab\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 W0W\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 run the supplied blocks in a fresh notebook. 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.