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:
-
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
Nothing is trained; the point is to see every number in a forward pass.
-
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 .
-
Verify
Use it to design four hidden units whose activations sum to .
-
Write those four units in the matrix form
including the dimensions of , , , and .
-
We want two output logits, ordered as outside and inside:
Find and such that .
-
Use the two-class identity from Lecture 2, Aside 3.1a to show
Deduce the decision boundary and explain why changing 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 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 by hand before looking at the printed result.
Look and ask. Before moving on, interrogate h_units rather than
just checking it runs.
- 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?
- The map is nonlinear, yet nothing is lost: explain how to recover from . Then say what is lost when the four activations are summed to . 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 and : one is active when , the other when , never both. Units 3 and 4 split 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. and , so determines . The nonlinearity has unfolded each coordinate into a positive part and a negative part.
- Everything but is lost by the sum. The output layer sees only , so all points on one diamond are identical to it: , , and 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
. Code conventionally stores a batch with one sample per
row, so the same multiplication is X @ W1.T. Check the
dimensions on paper:
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-
vector to an 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 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
Then explain why subtracting the largest logit in each row changes no softmax probability. Your row sums should equal 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 . 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.
- The points and are at essentially the same Euclidean distance from the origin ( against ). Predict their inside probabilities, then run them through the network. What notion of distance is this network measuring?
- Take the five probabilities you just printed at and predict, before running, what they become at and at . 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. has and is confidently inside (); has and sits exactly on the boundary (). The network measures distance by , the taxicab or norm, whose unit ball is the diamond. Circles are invisible to it.
- Only the boundary point is pinned. Since , the point with gives for every ; every other probability is pushed toward or as grows and toward as shrinks. At the five inside probabilities are , , , , ; at they are , , , , . Confidence is a separate dial from geometry, which is exactly why 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:
- verify that the hidden layer adds to ;
- the inside probabilities for the points each equal .
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 contour should be a diamond with vertices at and .
Look and ask. The picture has more in it than the black line.
- Before you plot, predict where the and contours lie at
(solve for ). Then add
levels=[0.1, 0.5, 0.9]to theplt.contourcall and check. - 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?
- Give all four hidden units the same bias,
b1 = torch.full((4,), -0.3), and predict the shape of the 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. means , so at the contour is and the contour is . Halving doubles the width of that band.
- Bands follow the edges because depends on only through , and the level sets of are diamonds. Inside each open quadrant is linear with gradient , so changes at the same rate everywhere along an edge. On the axes has a kink, so the bands bend sharply at the corners.
- A shared bias of makes an octagon. Each unit is now dead until its coordinate exceeds in absolute value, so the hidden sum is . The boundary has vertical and horizontal sides where one coordinate sits in the dead zone ( when ), and diagonal sides elsewhere: an octagon with vertices and .
- A shared bias of empties the inside. Now , so everywhere and nothing is inside; the largest inside probability on the grid is , 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 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 -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.
-
The model. One-hot encode the current character: . Set logits for a parameter matrix , and model
Convince yourself this is the fully general row-stochastic matrix, reparametrized: softmax maps onto the interior of the simplex, with kernel the constants (so is identifiable only up to a constant per row). This is exactly Lecture 2’s multinomial logistic regression with one-hot features.
-
The gradient. For one observed pair the loss is . Show:
where and 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.
-
Sanity structure. Note has components summing to zero. Why must it, given the kernel observation in 1?
-
Convexity (this model only!). is convex in (log-sum-exp is convex; linear terms don’t spoil it), and the total loss is convex in . 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 count matrix ?
- Write the training loss as a formula in alone, and explain why the million-row tensor cannot contain anything the counts do not.
- 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 contributes , and identical pairs contribute identical terms, so
Check it: computing this from N gives at , 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 (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.
- .
- , with Step 1’s add-one transition matrix.
- 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
- is the uniform model, every row of softmax equal to , so the loss is exactly: the perplexity-65 baseline of Step 1.
- is Step 1’s model. Softmax of returns , so the loss is Step 1’s 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 -dimensional parameter space are invisible to the loss; a row-stochastic matrix has only free entries.
- The random start scores , a hair below : weights of size leave the softmax rows within a percent of uniform.
2.8 Backward pass — by hand
Using your derivation (, averaged over the batch, routed back to the rows of ):
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 to the same row 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 and compare
to grad_fn(W)[i, j]. Adopt this habit permanently: never trust a
gradient you haven’t finite-differenced.
⚠️ Do this in
float64— castWwith.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 from the Taylor remainder, and roundoff from cancelling two nearly equal numbers. Here while an individual (the loss is a mean over examples, so each of the 4,225 entries gets a small share), and the numerator sits below float32’s resolution of . The signal is entirely lost in the subtraction. Measured relative error, same code and same , only the dtype changing:
dtype float32 5.9 ❌ float64 ✅ Note the columns move in opposite directions: raising helps float32 (less cancellation) and hurts float64 (more truncation) — the classic U-shaped error curve, with optimum near . In float64 at you get the ~6 significant figures the check is supposed to give.
Once in float64, agreement to ~6 significant figures (relative error ) 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.
- Print
dW.sum(dim=1). Predict the answer from the sanity-structure item of the derivation. Then deduce something aboutW.sum(dim=1)during training, and check it after 300 steps. - Rank the rows of
dWby 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 foreonly about times larger than the row forQ, wheneoccurs 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
does and row of is a sum of such
vectors. So gradient descent never moves along the invisible
directions of Task 2.7:
W.sum(dim=1)is a conserved quantity. In float32 it drifts by about over 300 steps (row sums start near ); redo the run in.double()and the drift is . The invariance is exact; the drift is roundoff. - Gradient size tracks frequency. Near the softmax rows are
uniform, so row of is
, where is the uniform vector
and the empirical distribution of what follows . The
correlation of row norm with count is : space,
e,tat the top,&and$(three and one occurrences) at the bottom. - The factor is why the ratio is not .
Qis followed byUof the time, so is far from uniform and is large, while the successors ofeare spread out. The count ratio is cut to . 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:
| lr | 0.1 | 1 | 50 | 500 |
|---|---|---|---|---|
| loss @ 30 | 4.158 | 4.024 | 2.742 | 5.401 |
Two lessons in one table. The tiny rates are not wrong — they are converging, just impractically slowly from . 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 ? Think about the scale of : it is a mean over a million examples spread across 4,225 entries, hence tiny (of order ), 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 beats , , and . It does not say is best.
- Sweep the learning rate over from the same initialization and record the loss after one step and after thirty. Which rate wins each contest? Why are they different?
- Plot the gap between your loss and Step 1’s 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:
| lr | 50 | 100 | 150 | 200 | 250 | 300 | 400 | 500 |
|---|---|---|---|---|---|---|---|---|
| loss after 1 step | 3.924 | 3.701 | 3.525 | 3.421 | 3.402 | 3.454 | 3.712 | 4.095 |
| loss after 30 steps | 2.742 | 2.626 | 2.646 | 2.848 | 3.178 | 3.452 | 4.607 | 5.401 |
- One step likes ; thirty steps like . 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 the run stalls (loss after one step, after thirty); at it climbs.
- The gap is not a straight line. At steps the gap to the count model is : each fifty steps removes a smaller fraction than the last (, then , , , 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:
- final training loss vs the count model’s cross-entropy from Step 1;
softmax(W, dim=1)rows vsP’s rows (plot a few, or(softmax_W - P).abs().max());- samples from the trained model, using your Step 1 sampler.
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 for the character
Zreceives gradient contributions only from the handful of timesZoccurs. 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 smoothing here? (Hint: what does initializing mean in probability space, and does 300 steps fully erase it?)
Look and ask. Put numbers on both questions.
- Every 50 steps, record
for the rows space,
e,t,Q,Z,Xand plot the six curves together. Predict the ordering before you look. - After 300 steps, find the never-seen pair (a cell with ) 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 0 | 50 | 100 | 150 | 200 | 250 | 300 |
|---|---|---|---|---|---|---|---|
| space (169,892) | 0.125 | 0.0014 | 0.0007 | 0.0005 | 0.0004 | 0.0003 | 0.0003 |
e (94,611) | 0.277 | 0.0033 | 0.0015 | 0.0010 | 0.0007 | 0.0006 | 0.0005 |
t (67,009) | 0.324 | 0.0056 | 0.0028 | 0.0018 | 0.0014 | 0.0011 | 0.0009 |
Q (231) | 0.718 | 0.709 | 0.694 | 0.673 | 0.641 | 0.598 | 0.543 |
Z (198) | 0.529 | 0.523 | 0.516 | 0.506 | 0.493 | 0.477 | 0.456 |
X (112) | 0.386 | 0.384 | 0.381 | 0.377 | 0.373 | 0.369 | 0.364 |
- Two regimes, not one curve. The three common rows are within
of 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
Qis followed byUwith probability (add-one: ), and after 300 steps the trained model says , up from at the start. The row is converging; it is just receiving a gradient a few hundred times smaller thane’s. (About of theQgap is add-one smoothing itself, since is the smoothed matrix; measured against the unsmoothed counts the row is even further away, .) - The most-favoured never-seen pair is
&followed by;, at probability , which is to three decimals. The character&occurs three times in the corpus, so its row of has received almost no gradient and is still the uniform initialization. For comparison, the never-seen successors ofehave been pushed down to at most (add-one would give them ). - So the smoothing is the initialization. Starting at 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:
-
h_unitsand the matrix-layer activationhagree exactly up to floating-point arithmetic. -
The five hidden rows are
-
At , the inside probabilities for the first four points are approximately , , , and . The fifth is approximately .
-
Every probability row sums to .
-
The contour is the diamond for every positive .
-
Look-and-ask answers: at most two hidden units are ever active (active counts for the five points: ); and score and inside; at the and contours are the diamonds and ; a shared hidden bias of turns the boundary into an octagon with vertices and , and leaves nothing inside (largest inside probability ).
Part B:
-
Finite-difference check in float64 passes: relative error at . (In float32 it “fails” at — see the warning above. If you see that, your gradient is probably fine and your dtype is not.)
-
Loss decreases monotonically (full-batch, sane learning rate), approaching Step 1’s 2.4549 from above but not reaching it in 300 steps. Measured trajectory at lr 50:
step 0 50 100 150 200 250 300 loss 4.1731 2.6530 2.5690 2.5352 2.5168 2.5052 2.4973 Still 0.042 nats above the count model, and closing slowly — the tail of this curve is the ill-conditioning discussed above.
-
Agreement between and after 300 steps is strongly row-dependent, exactly as the conditioning argument predicts:
quantity value (all rows) 0.542 mean over all entries 0.0052 over the 53 rows with counts 0.070 So the typical entry is already accurate to three decimals, the well-populated rows to about one, and the single worst entry — in the row for a rare character — is still off by half a unit of probability. (Measured: the worst row belongs to
Q, which occurs 231 times againste’s 94,611 — rare enough that 300 steps barely move it.) Identify that row yourself and the answer should feel inevitable. -
Look-and-ask answers: the loss is at , at , and unchanged by per-row constants; gradient rows sum to zero and
W.sum(dim=1)is conserved (float64 drift ); gradient row norms correlate with character counts, with space,e,tlargest and&,$smallest; over 30 steps the best learning rate is about , and stalls; the most-favoured never-seen pair after 300 steps is&→;at .
Troubleshooting
Part A:
X @ W1has a shape error: the lecture uses column vectors, while the batch stores samples as rows. UseX @ W1.T.- The hidden sum is rather than : inspect the two negative-coordinate units and their signs.
- Inside and outside are reversed: inspect the order of the two rows of and the two entries of .
- Probabilities do not sum to : normalize across
dim=1, the class dimension, and retain it withkeepdim=True. - The plot is transposed or rotated: use the same
indexing=‘xy’convention inmeshgridas in the supplied code.
Part B:
- Loss stuck at : your gradient is zero or you’re not
actually updating
W(in-place vs reassignment). - Loss
nan: learning rate too large, or you removed the max-subtraction. index_add_unfamiliar: do it with a loop over a 10,000-pair subset first; then read its docs. It is exactly “accumulate into row .”
Going further (optional)
Part A:
-
Change confidence without changing geometry. Run the grid of Task 2.5 with and put the four probability plots side by side with the same color scale. Which set of points has probability exactly in every plot? What happens to probabilities away from the boundary as grows, and as approaches zero? Why is changing equivalent to changing softmax temperature?
-
Move and stretch the diamond. Choose a center and positive radii . Modify the first layer so that its hidden activations sum to
keeping the output layer’s rule “inside when the sum is below .” Derive your new and on paper, implement them, and plot the result. The boundary should have vertices and .
-
A square needs another composition. Use to build , then classify . Draw the extra layer and give every matrix dimension.
-
Rotate the boundary. Replace the coordinate directions in by two nonparallel direction vectors. Predict the four edge normals before plotting.
-
Make a dataset. Sample random points in , label them from the exact inequality, and measure classification accuracy away from the boundary. Explain why this measures your implementation, not generalization.
-
Count the wiring. Count all scalar entries in the two weight matrices and two bias vectors. Then count how many are nonzero and explain what each nonzero group does.
Part B:
- Add an MLP: with a width-100 hidden layer. Derive the gradient by the chain rule (two more lines of the same style) and train it. It can’t beat the bigram table (why not? — the input is still only one character), which makes it the perfect controlled introduction to hidden layers. This is also a preview of Step 3’s pain: by the time you have three layers, hand gradients stop scaling — hence autodiff.
- Regularize: add to the loss, sweep , and show (paper + plot) it interpolates toward the uniform model, playing exactly the role of Laplace smoothing.
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.