Notebook 03 · Your first model

The simplest language model -- and the whole training loop.

A real model, trained on the full 1 MB of Shakespeare, right here in the page -- with the gradient derived by hand and checked numerically. The skeleton you build here never changes again.

It is time to actually train something. The bigram model is the simplest language model there is: to predict the next character, it looks at the current character and nothing before it. (Imagine trying to guess the next word in a sentence when you are allowed to see only the single word immediately before it. That is the handicap it works under.)

It is a weak model, but building it lays down the entire skeleton we reuse for the GPT: a forward pass, then the loss, then the backward pass (the gradients), then the update, then generation. We will even work out the gradient by hand and then check it against a brute-force numerical estimate.

python · runnable
# Colab setup -- fetch the files this notebook needs.
# (Does nothing when run locally in the course folder.)
import os, urllib.request
BASE = ("https://raw.githubusercontent.com/waze"
        "emlabs/llm-book-code/main/")
for f in ['data/input.txt']:
    if not os.path.exists(f):
        d = os.path.dirname(f)
        if d: os.makedirs(d, exist_ok=True)
        urllib.request.urlretrieve(BASE + f, f)
        print("downloaded", f)
Line by line: what each line does
  • import os, urllib.request: two standard-library toolboxes: checking files on disk, and downloading from the web.
  • BASE = (...): the web address of this course's folder on GitHub, split over two lines (Python glues adjacent strings together).
  • for f in [...]: loop over the file names this notebook needs.
  • if not os.path.exists(f): only download what is missing -- running locally, everything already exists, so nothing happens.
  • os.makedirs(d, exist_ok=True): create the folder for the file if it has one (exist_ok means don't complain if it's already there).
  • urllib.request.urlretrieve(...): download the file and save it under the same name here.
python · runnable
import numpy as np
from pathlib import Path
import matplotlib.pyplot as plt
np.random.seed(1337)

# --- setup from notebook 02 (tokenizer + data) ---
text = Path("data/input.txt").read_text()
chars = sorted(set(text)); vocab_size = len(chars)
stoi = {c: i for i, c in enumerate(chars)}; itos = {i: c for c, i in stoi.items()}
encode = lambda s: [stoi[c] for c in s]; decode = lambda ids: "".join(itos[i] for i in ids)
data = np.array(encode(text), dtype=np.int64)
def softmax(x, axis=-1):
    x = x - x.max(axis=axis, keepdims=True); e = np.exp(x); return e / e.sum(axis=axis, keepdims=True)
print("vocab:", vocab_size, " dataset:", len(data))
Line by line: what each line does
  • import numpy as np ...: load the toolboxes (NumPy, Path for files, matplotlib for plots) and seed the randomness so your run matches the saved outputs.
  • text = Path("data/input.txt").read_text(): read the whole Shakespeare file into one string.
  • chars = sorted(set(text)); vocab_size = len(chars): the distinct characters, in order, and how many there are (65) -- the vocabulary, exactly as in notebook 02.
  • stoi = {...}; itos = {...}: the character↔id lookup tables.
  • encode = lambda s: ...; decode = lambda ids: ...: one-line functions to turn text into ids and back.
  • data = np.array(encode(text), dtype=np.int64): the entire book as a long array of integer ids.
  • def softmax(x, axis=-1): ...: the scores-to-probabilities helper from notebook 01, squeezed onto two lines (the semicolons just chain statements).
  • Nothing new here -- this cell just replays notebooks 01-02 so this notebook stands on its own.
saved output · press Run to reproduce livevocab: 65 dataset: 1115394

The model

The entire model is a single table of numbers called W, with a size of (vocab, vocab), here 65 by 65. You can read it like a reference table: row c holds the scores for "which character tends to come after character c." Those raw scores are the logits, and softmax later turns a row into probabilities over the next character.

The forward pass is therefore almost nothing: to predict what follows character x, you simply take row x of W. In short, logits = W[x], then softmax, gives probabilities over the next character.

(The code comment "selecting rows IS the matmul with one-hots" points to a neat fact. Picking row x gives the same answer as multiplying W by a list that is all 0s except for a single 1 in position x, which is called a "one-hot" list. Taking the row directly is just the fast version of that multiplication.)

A fresh W is filled with tiny random numbers, so every row is nearly flat and the model has no opinions yet. That is why the starting loss lands right at ln(65) = 4.17, which is the cross-entropy score for guessing all 65 characters as equally likely (notebook 01).

x = id of the current characterone integer, e.g. 43 for 'e'
one_hot(x) = [0, 0, …, 1, …, 0]all zeros except a single 1 at position x
logits = one_hot(x) · W = W[x]the single 1 selects exactly row x — so we skip the matmul and just take the row
python · runnable
W = np.random.randn(vocab_size, vocab_size) * 0.01    # the only parameters

def forward(x):                 # x: (B,) current-char ids
    logits = W[x]               # (B, vocab) - selecting rows IS the matmul with one-hots
    return logits

def loss_fn(logits, y):         # cross-entropy of the true next chars y
    p = softmax(logits)
    return -np.mean(np.log(p[np.arange(len(y)), y] + 1e-9))

# sanity: an untrained model should have loss ~ ln(vocab_size)
xb = data[:1000]; yb = data[1:1001]
print("initial loss:", round(loss_fn(forward(xb), yb), 4), " | ln(vocab) =", round(np.log(vocab_size), 4))
Line by line: what each line does
  • W = np.random.randn(vocab_size, vocab_size) * 0.01: a 65×65 grid of tiny random numbers. Row c will hold the scores for "what comes after character c." Multiplying by 0.01 keeps it near zero so the model starts with no opinions. This grid is the entire model.
  • def forward(x):: the prediction step; x is a whole batch of current-character ids at once.
  • logits = W[x]: fancy indexing: for each id in the batch, grab that row of W. In goes a list of ids; out comes a grid of scores, one row of 65 per example.
  • def loss_fn(logits, y):: measures how wrong the predictions are against the true next characters y.
  • p = softmax(logits): turn each row of scores into probabilities.
  • p[np.arange(len(y)), y]: pairwise pick: for row 0 take column y[0], for row 1 column y[1], and so on -- "the probability each example gave its own correct answer." np.arange(n) is just 0,1,...,n-1.
  • + 1e-9: add a microscopic amount so we never take log of exactly 0 (which would blow up to infinity).
  • -np.mean(np.log(...)): the average surprise across the batch: cross-entropy from notebook 01, as one number.
  • xb = data[:1000]; yb = data[1:1001]: the first 1000 characters and the same span shifted by one: each character paired with the one that follows.
  • The print shows the untrained loss landing on ln(65) = 4.17: exactly the score for "no idea, all 65 equally likely."
saved output · press Run to reproduce liveinitial loss: 4.1738 | ln(vocab) = 4.1744

Actually look at W

The paragraphs above keep calling W "a 65x65 table of tiny numbers with no opinions yet." Rather than take that on trust, let's print it and check every word of that claim by eye.

python · runnable
# The entire model, made visible.
print("W.shape:", W.shape, " -> rows = current char, cols = next char\n")

print("top-left corner W[:4, :6] (raw logits -- all tiny, near zero):")
print(np.round(W[:4, :6], 4))

# One row is the model's whole opinion about "what follows this character".
# Fresh, that row is near-flat, so softmax turns it into a near-uniform guess.
row = softmax(W[stoi["q"]])
best = np.argsort(row)[::-1][:3]
print("\nafter 'q', the untrained model's top-3 next characters:")
for j in best:
    print(f"   {itos[j]!r:>4}  p = {row[j]:.4f}")
print(f"a flat, no-opinion guess is 1/65 = {1/vocab_size:.4f}  <- essentially the same")
Line by line: what each line does
  • print("W.shape:", ...): confirm the table is 65×65. The row index is the character you are standing on now; the column index is a character that might come next.
  • print(np.round(W[:4, :6], 4)): peek at one small corner of W. Every number is a tiny random value near zero -- this is what "starts with no opinions" literally looks like.
  • row = softmax(W[stoi["q"]]): take the row for 'q' and turn its raw scores into next-character probabilities.
  • np.argsort(row)[::-1][:3]: pull out the three characters the model currently rates highest after 'q'.
  • The print shows those top-3 probabilities sitting right on top of 1/65 = 0.0154: no real preference yet, which is exactly why the starting loss equals ln(65).
saved output · press Run to reproduce liveW.shape: (65, 65) -> rows = current char, cols = next char top-left corner W[:4, :6] (raw logits -- all tiny, near zero): [[-0.007 -0.0049 -0.0032 -0.0176 0.0021 -0.0201] [ 0.0076 -0.0158 0.002 0.0009 0.0064 -0.0079] [ 0.0056 -0.0118 -0.0047 -0.0174 -0. -0.0217] [-0.0239 -0.0044 0.0122 0.0037 -0.0138 -0.0068]] after 'q', the untrained model's top-3 next characters: '?' p = 0.0158 'Z' p = 0.0157 'w' p = 0.0157 a flat, no-opinion guess is 1/65 = 0.0154 <- essentially the same

The gradient, by hand

We need to know which way to nudge every number in W to lower the loss. The combination of softmax and cross-entropy has a famously clean answer: the gradient at the logits is simply the probabilities minus the one-hot target.

In plain terms: take the probabilities the model produced, then subtract 1 from the single position that was actually correct. Suppose the model predicted [0.6, 0.3, 0.1] and the true next character was at index 1. The gradient is [0.6, 0.3 - 1, 0.1], which is [0.6, -0.7, 0.1]. A positive number means "this logit is too high, push it down," and a negative number means "push it up." So the rule automatically pushes the two wrong characters down and the correct one up, and the size of each push grows with how confidently wrong the model was. That is the whole learning signal.

Because logits = W[x] simply selected row x, that gradient flows straight back into row x of W. The same character appears many times in a batch, so the function np.add.at adds up every occurrence's nudge into the right row.

The cell below also runs a gradient check: it compares our formula against the brute-force numerical slope (nudge one number, watch how the loss moves) from notebook 01. The two agree to six decimal places, which is how you know the hand-derived gradient is correct. (The zeros are honest, and they come in whole rows: id 5 is the apostrophe and id 40 is the lowercase b, and neither character appears in this batch as an input, so np.add.at routes no gradient into row 5 or row 40 at all. A pair that merely never occurred, like W[10,20], still gets a small positive gradient — "push down" — whenever its row's character shows up as an input.)

python · runnable
def backward(x, y):
    B = len(x)
    p = softmax(W[x])                 # (B, vocab)
    dlogits = p.copy()
    dlogits[np.arange(B), y] -= 1     # probs - one_hot(target)
    dlogits /= B                      # average over the batch
    dW = np.zeros_like(W)
    np.add.at(dW, x, dlogits)         # route each row's gradient back to W[x]
    return dW

# gradient check: compare our analytic dW to a numerical estimate on a few entries
xb, yb = data[:256], data[1:257]
dW = backward(xb, yb)
def num_grad(i, j, h=1e-4):
    global W
    W[i, j] += h; lp = loss_fn(forward(xb), yb)
    W[i, j] -= 2*h; lm = loss_fn(forward(xb), yb)
    W[i, j] += h
    return (lp - lm) / (2*h)
for (i, j) in [(10, 20), (5, 5), (40, 1)]:
    print(f"  W[{i},{j}]  analytic={dW[i,j]: .6f}  numerical={num_grad(i,j): .6f}")
print("they match -> our hand-derived gradient is correct")
Line by line: what each line does
  • def backward(x, y):: computes the gradient: which way to nudge every number in W to lower the loss.
  • p = softmax(W[x]): the model's current probabilities for this batch.
  • dlogits = p.copy(): start the gradient as a copy of those probabilities (.copy() so we don't accidentally change p itself).
  • dlogits[np.arange(B), y] -= 1: subtract 1 at each example's correct answer. This is the clean probabilities minus one-hot rule from the text, applied to the whole batch at once: it pushes wrong characters down and the right one up.
  • dlogits /= B: divide by the batch size, because the loss was an average over B examples.
  • dW = np.zeros_like(W): an all-zeros grid the same shape as W, to collect the gradient.
  • np.add.at(dW, x, dlogits): route each example's gradient back into row x of dW, adding up when the same character appears several times. (Plain dW[x] += ... would silently drop repeats -- this is the safe version.)
  • def num_grad(i, j, h=1e-4):: the brute-force check: nudge one entry of W up then down (the += h ... -= 2*h ... += h dance restores it afterward) and measure how the loss moves -- rise over run.
  • for (i, j) in [(10, 20), ...]:: spot-check three entries; the hand-derived gradient and the brute force agree to six decimals, so the formula is correct.
saved output · press Run to reproduce live W[10,20] analytic= 0.000359 numerical= 0.000359 W[5,5] analytic= 0.000000 numerical= 0.000000 W[40,1] analytic= 0.000000 numerical= 0.000000 they match -> our hand-derived gradient is correct

See the learning signal for a single example

"Probabilities minus the one-hot target" is a tidy phrase. Here it is as an actual picture, for one real pair from the batch: every wrong character gets a small positive nudge (meaning "push this logit down"), and the single correct character gets one big negative nudge ("push this one up"). That whole shape is the gradient.

python · runnable
# Rebuild the gradient for ONE example and look at the signal, number by number.
xi, yi = int(xb[0]), int(yb[0])            # current char, true next char
probs = softmax(W[xi])                     # the model's guess for this row
g = probs.copy(); g[yi] -= 1               # probs - one_hot(target): the whole signal

print(f"current char {itos[xi]!r}  ->  true next char {itos[yi]!r}")
print(f"prob the model put on the correct answer : {probs[yi]:.4f}   (we want this near 1)")
print(f"gradient at the correct column           : {g[yi]:+.4f}   (negative -> push that logit UP)")
other = g[np.arange(vocab_size) != yi]
print(f"gradient at each of the 64 other columns : ~{other.mean():+.4f}    (positive -> push them DOWN)")

plt.figure(figsize=(6, 2.4))
colors = np.where(np.arange(vocab_size) == yi, "#e5484d", "#5b5bd6")
plt.bar(range(vocab_size), g, color=colors)
plt.axhline(0, color="k", lw=.5)
plt.title(f"gradient on the logits for one '{itos[xi]}' -> '{itos[yi]}' example")
plt.xlabel("next-char id"); plt.ylabel("d loss / d logit"); plt.show()
Line by line: what each line does
  • xi, yi = int(xb[0]), int(yb[0]): take the first pair in the batch -- one current character and the character that truly followed it.
  • probs = softmax(W[xi]): the untrained model's probabilities for what comes after xi (all near 1/65).
  • g = probs.copy(); g[yi] -= 1: the exact rule from the text -- start from the probabilities, then subtract 1 at the one correct answer.
  • The prints spell the signal out: the correct column becomes a big negative number (about -0.98, "push up hard"), while all 64 others stay small and positive ("push down gently").
  • plt.bar(...): draw it. The lone red bar diving toward -1 is the correct character; the flat blue line just above zero is every wrong character. Training is just doing this for a whole batch and stepping W the opposite way.
saved output · press Run to reproduce livecurrent char 'F' -> true next char 'i' prob the model put on the correct answer : 0.0153 (we want this near 1) gradient at the correct column : -0.9847 (negative -> push that logit UP) gradient at each of the 64 other columns : ~+0.0154 (positive -> push them DOWN)output figure

The training loop

Here is the loop that appears in every notebook from this point on. It is worth committing to memory, because only the model in the middle ever changes:

  1. sample a batch of (current character, next character) pairs -- a fresh random handful each step (that randomness is what puts the "stochastic" in stochastic gradient descent, the name we meet in notebook 07),
  2. forward: compute the logits,
  3. loss: measure how surprised the model was, using cross-entropy,
  4. backward: compute the gradient, the downhill direction for every number in W,
  5. update: step W a little way downhill, written W -= lr * gradient,
  6. repeat thousands of times.

Watch the loss fall from about 4.17 (random guessing) towards about 2.45, which is the best any one-character-of-memory model can do on this text. We will actually compute that ceiling in a moment. (The learning rate here is a large 30, which looks surprising after notebook 01's 0.1. It is fine because this model is so simple and its gradients are very small. The right step size is always specific to the problem; you tune it until training is both fast and stable.)

1 · sample a batch of (current, next) pairsx, y · shape (B,)
2 · forward: logits = W[x](B, 65) · a score for every next char
3 · loss: cross-entropy(logits, y)one number · how surprised the model was
4 · backward: dW = probs − one_hot(y)(65, 65) · the downhill direction
5 · update: W −= lr × dWnudge every weight a little downhill
6 · repeat thousands of times ↻
Interactive · one step at a time

One update, in slow motion

Before running it millions of times, watch a single update and see exactly which numbers move. We train on one pair — after q comes u — starting from a blank model.

1 · forward2 · gradient3 · update
the 65×65 table W — only row q (outlined) will change
row q: the model’s scores for what follows ‘q’, as probabilities

a deliberately big step, so the change is visible
Interactive · trains live

Watch a bigram actually learn

A real 65×65 W, trained on the full Shakespeare text right here in your browser — the exact six-step loop above. Press Play: the loss falls from ln 65 = 4.17 toward the 2.45 ceiling, and the sample sharpens from noise into almost-words.

loading Shakespeare…
a real (current → next) pair from the text
forward: the model’s probabilities for the next char (✓ marks the true one)
loss over time, falling toward the ceiling
a fresh sample drawn from W right now

Each step samples real (current, next) pairs, reads row W[current], softmaxes it to probabilities, and nudges W so the true next character gets more weight — then repeats.

One full step of the loop, unrolled

The loop below runs six lines thousands of times. Before trusting it, let's execute a single pass and print what each variable holds, so none of it stays abstract. Every symbol the step touches:

symbol shape what it holds
x (B,) the current-character ids of the batch
y (B,) the id that actually follows each one
logits = W[x] (B, 65) a raw score for every possible next char
dW (65, 65) how to nudge every weight to lower the loss

(B is the batch size, 1024 here -- the same batch the loop below draws.)

python · runnable
# One real pass of the loop, narrated. We draw a batch the same way the loop does
# (a private RNG, so the seeded loop below still reproduces its numbers), then
# read off what each step produces.
rng = np.random.default_rng(0)
B = 1024
ix = rng.integers(0, len(data) - 1, size=B)
xb1, yb1 = data[ix], data[ix + 1]              # step 1: B (current -> next) pairs

print(f"step 1  a batch of {B} (current -> next) pairs; the first 6:")
for cx, cy in zip(xb1[:6], yb1[:6]):
    print(f"          id {cx:>2} {itos[cx]!r:>4}   ->   id {cy:>2} {itos[cy]!r:>4}")

logits = forward(xb1)                          # step 2: forward
L = loss_fn(logits, yb1)                       # step 3: loss
dW = backward(xb1, yb1)                         # step 4: backward
rows_touched = int((dW != 0).any(axis=1).sum())
print(f"\nstep 2  forward : logits.shape = {logits.shape}  ({B} rows, a 65-long score vector each)")
print(f"step 3  loss    : {L:.4f} nats   (near ln 65 = 4.17 -- the model is still untrained)")
print(f"step 4  backward: dW.shape = {dW.shape}; {rows_touched} of 65 rows are nonzero")
print(f"                  -> a character only gets a nudge if it appeared in the batch")

i, jt = int(xb1[0]), int(yb1[0])               # step 5: the update rule, on one weight
print(f"step 5  update  : W -= lr * dW.  dW[{i},{jt}] = {dW[i, jt]:+.5f} for the seen "
      f"'{itos[i]}'->'{itos[jt]}' pair,")
print(f"                  so W[{i},{jt}] steps {W[i, jt]:+.4f} -> {W[i, jt] - 30.0 * dW[i, jt]:+.4f}   (lr=30)")
print("step 6  repeat this thousands of times -- that is the whole cell below.")
Line by line: what each line does
  • rng = np.random.default_rng(0); ix = rng.integers(...): draw 1024 random positions, exactly as the training loop does (a private RNG so the seeded loop below still reproduces its numbers).
  • xb1, yb1 = data[ix], data[ix + 1]: turn those positions into (current char, next char) pairs -- the first six are printed so you see what feeds the step.
  • forward / loss_fn / backward: run the three core functions once and report the shape or value each produces -- a (1024, 65) grid of logits, one loss number, a (65, 65) gradient.
  • rows_touched: counts the nonzero rows of dW. A character only earns a gradient if it appeared in the batch; with 1024 samples that is nearly all of them.
  • The step-5 line shows the real update on one weight -- the gradient, and where W -= lr * dW moves it -- and step 6 is just "do it again," which is the loop in the next cell.
saved output · press Run to reproduce livestep 1 a batch of 1024 (current -> next) pairs; the first 6: id 52 'n' -> id 57 's' id 51 'm' -> id 47 'i' id 46 'h' -> id 43 'e' id 58 't' -> id 46 'h' id 53 'o' -> id 59 'u' id 51 'm' -> id 6 ',' step 2 forward : logits.shape = (1024, 65) (1024 rows, a 65-long score vector each) step 3 loss : 4.1737 nats (near ln 65 = 4.17 -- the model is still untrained) step 4 backward: dW.shape = (65, 65); 56 of 65 rows are nonzero -> a character only gets a nudge if it appeared in the batch step 5 update : W -= lr * dW. dW[52,57] = -0.00019 for the seen 'n'->'s' pair, so W[52,57] steps +0.0103 -> +0.0159 (lr=30) step 6 repeat this thousands of times -- that is the whole cell below.
python · runnable
W = np.random.randn(vocab_size, vocab_size) * 0.01   # fresh start
lr, steps, batch_size = 30.0, 6000, 1024
losses = []
for step in range(steps):
    ix = np.random.randint(0, len(data) - 1, size=batch_size)
    x, y = data[ix], data[ix + 1]
    logits = forward(x)
    losses.append(loss_fn(logits, y))
    W -= lr * backward(x, y)

# one batch is a noisy reading (easy and hard stretches of text exist),
# so report an average over the last 200 steps
print("final train loss (avg of last 200 steps):", round(float(np.mean(losses[-200:])), 4))

plt.figure(figsize=(6, 3))
plt.plot(losses); plt.title("bigram training loss"); plt.xlabel("step"); plt.ylabel("loss"); plt.show()
Line by line: what each line does
  • W = np.random.randn(...) * 0.01: a fresh random start, so training begins from scratch.
  • lr, steps, batch_size = 30.0, 6000, 1024: three settings on one line: step size, number of updates, examples per update.
  • for step in range(steps):: repeat the update 6000 times.
  • ix = np.random.randint(0, len(data) - 1, size=1024): 1024 random positions in the book.
  • x, y = data[ix], data[ix + 1]: the characters at those positions and the characters right after: 1024 (current → next) pairs.
  • losses.append(loss_fn(logits, y)): record the loss each step so we can plot the learning curve.
  • W -= lr * backward(x, y): the whole learning rule in one line: step the table downhill along its gradient.
  • np.mean(losses[-200:]): average of the last 200 entries (negative indexing counts from the end) -- a steadier reading than one noisy step.
saved output · press Run to reproduce livefinal train loss (avg of last 200 steps): 2.4613output figure

What did W actually learn?

Fresh, W was flat noise. After 6000 steps it has opinions -- so let's look at the whole table again, this time as a heatmap. Each row is a current character, each column a possible next character, and brighter means higher probability. Every bright cell is a real regularity the model dug out of Shakespeare on its own.

python · runnable
# Same table as before, now trained. Turn each row into next-char probabilities and look.
P = softmax(W, axis=1)

plt.figure(figsize=(7, 6))
plt.imshow(P, cmap="magma", aspect="auto")
plt.colorbar(label="P(next char | current char)")
plt.xticks(range(vocab_size), [itos[i] for i in range(vocab_size)], fontsize=5)
plt.yticks(range(vocab_size), [itos[i] for i in range(vocab_size)], fontsize=5)
plt.xlabel("next character"); plt.ylabel("current character")
plt.title("what the trained bigram learned"); plt.show()

# Read a few rows back in plain English -- the model was never told any of this.
for c in ["q", ".", " "]:
    r = P[stoi[c]]; j = int(r.argmax())
    name = "space" if c == " " else repr(c)
    print(f"after {name:>7}: most likely next char is {itos[j]!r:>4}  (p = {r[j]:.2f})")
Line by line: what each line does
  • P = softmax(W, axis=1): turn every row of the trained table into a clean probability distribution over the next character (axis=1 normalises along each row).
  • plt.imshow(P, cmap="magma"): draw the 65×65 table as an image -- dark = unlikely, bright = likely. The single brightest cell in the whole grid is row 'q', column 'u'.
  • plt.xticks / plt.yticks: label the axes with the actual characters instead of raw ids, so you can read the structure straight off the picture.
  • The loop prints the top prediction for three rows: 'q' -> 'u' almost certainly, '.' -> a newline (sentences end lines), and a space -> a common word-starting letter. Compare that to the flat 'q' row we printed before training.
saved output · press Run to reproduce liveafter 'q': most likely next char is 'u' (p = 0.99) after '.': most likely next char is '\n' (p = 0.87) after space: most likely next char is 't' (p = 0.15)output figure

The ceiling: how good can a bigram ever get?

Here is a question worth pausing on: if we trained forever, how low could this loss go? Not to zero. Even a perfect bigram is stuck guessing from a single character, and one character genuinely does not determine the next one, since after a space almost anything can follow. That leftover, unavoidable uncertainty sets a hard floor under the loss.

We can compute that floor directly, with no training at all: count every neighbouring pair of characters in the text, turn the counts into exact probabilities, and measure their average surprise (the cross-entropy from notebook 01). The answer comes out at about 2.45 nats, which is exactly where the training curve flattened out. (A nat is just the unit this kind of loss is measured in when the natural logarithm is used; the number itself is what matters.) The little model is not being lazy; it has learned everything that one character of context can teach.

This idea, that a model's loss floor is set by how much context it can see, is the strongest single argument for attention, and it is where the course is heading next.

python · runnable
# Count every adjacent pair of characters in the data, turn each row of counts into
# probabilities, and measure the loss of those PERFECT bigram probabilities.
# No model that sees only one character of context can ever beat this number --
# it is the built-in uncertainty of "next char given current char" on this text.
counts = np.zeros((vocab_size, vocab_size))
np.add.at(counts, (data[:-1], data[1:]), 1)
p = counts / np.maximum(counts.sum(axis=1, keepdims=True), 1)
row_weight = counts.sum(axis=1) / counts.sum()            # how often each char occurs
with np.errstate(divide="ignore", invalid="ignore"):
    row_entropy = -np.nansum(np.where(p > 0, p * np.log(p), 0.0), axis=1)
optimum = float(np.nansum(row_weight * row_entropy))
print(f"bigram optimum on this text: {optimum:.4f} nats")
print("our trained model sits essentially at this ceiling -- the only way past it is more context")
Line by line: what each line does
  • counts = np.zeros((65, 65)): a grid of zeros; counts[a, b] will tally "how many times did character b follow character a?"
  • np.add.at(counts, (data[:-1], data[1:]), 1): walk every adjacent pair in the book in one shot (all-but-last paired with all-but-first) and add 1 in the matching slot.
  • p = counts / np.maximum(counts.sum(axis=1, keepdims=True), 1): turn each row of tallies into fractions that sum to 1: the true next-character probabilities. np.maximum(..., 1) avoids dividing by zero for a character that never has a follower.
  • row_weight = counts.sum(axis=1) / counts.sum(): how often each character actually occurs, so common characters count more in the average.
  • row_entropy = -np.nansum(np.where(p > 0, p * np.log(p), 0.0), axis=1): the average surprise for each current-character (notebook 01's cross-entropy), with zero-probability slots safely treated as zero surprise.
  • optimum = np.nansum(row_weight * row_entropy): the weighted average: 2.4526. No one-character model, trained or not, can beat this floor.
  • np.errstate(...): housekeeping that silences harmless "log of zero" warnings.
saved output · press Run to reproduce livebigram optimum on this text: 2.4526 nats our trained model sits essentially at this ceiling -- the only way past it is more context

The whole story in one plot

We now have three numbers on the same scale: the random starting loss ln(65) = 4.17, our trained bigram's 2.46, and the perfect bigram ceiling 2.45. Drawing all three on the training curve shows the entire arc at a glance -- the loss dives off the random line and then settles right onto the ceiling, with essentially no gap left to close.

python · runnable
# Put the training curve and the two reference levels on one axis.
plt.figure(figsize=(6.5, 3.2))
plt.plot(losses, color="#5b5bd6", label="training loss")
plt.axhline(np.log(vocab_size), color="#999", ls=":", label=f"random start = {np.log(vocab_size):.2f}")
plt.axhline(optimum, color="#e5484d", ls="--", label=f"bigram ceiling = {optimum:.2f}")
plt.xlabel("step"); plt.ylabel("loss"); plt.legend()
plt.title("the loss falls from random and settles on the ceiling"); plt.show()

gap = float(np.mean(losses[-200:])) - optimum
print(f"gap left between our model and the perfect bigram: {gap:+.4f} nats")
print("essentially zero -- one character of context has nothing left to give")
Line by line: what each line does
  • plt.plot(losses): the same training curve as before -- loss on the y-axis, step on the x-axis.
  • plt.axhline(np.log(vocab_size), ls=":"): a dotted line at 4.17, where an untrained model starts (pure guessing over 65 characters).
  • plt.axhline(optimum, ls="--"): a dashed line at 2.45, the perfect-bigram ceiling we just computed from the raw counts.
  • The curve dives off the dotted line and then rides along the dashed one: the model learned everything a single character of context allows, then stopped.
  • The final print measures the leftover gap between our trained model and that ceiling -- it comes out essentially zero.
saved output · press Run to reproduce livegap left between our model and the perfect bigram: +0.0087 nats essentially zero -- one character of context has nothing left to giveoutput figure

Generate text

Generating text is a loop of "predict, draw at random, feed the result back in":

  1. start from a character and read off its next-character probabilities,
  2. draw a character from that set of probabilities. We sample (in effect rolling a weighted die) rather than always taking the single most likely character, so the text does not get stuck repeating itself,
  3. feed that new character back in as the context, and repeat.

This style, in which each output is fed back in as the next input, is called autoregressive generation, and it is exactly how ChatGPT writes as well, one token at a time. The result here is gibberish with some structure: the model has learned which characters tend to follow which (a q pulls in a u, capital letters follow newlines, vowels and consonants alternate), but with only one character of memory it cannot assemble real words. That precise limitation is what attention fixes in notebook 05.

python · runnable
def generate(start, n=400, seed=0):
    rng = np.random.default_rng(seed)
    idx = stoi[start]; out = [idx]
    for _ in range(n):
        p = softmax(W[idx])
        idx = int(rng.choice(vocab_size, p=p))
        out.append(idx)
    return decode(out)

print(generate("T", 400))
Line by line: what each line does
  • def generate(start, n=400, seed=0):: write text starting from a character; seed makes the dice rolls repeatable.
  • idx = stoi[start]; out = [idx]: look up the starting character's id and begin the output list with it.
  • for _ in range(n):: repeat n times (the _ means "loop counter unused").
  • p = softmax(W[idx]): the model's probabilities for what follows the current character.
  • idx = int(rng.choice(vocab_size, p=p)): the dice roll: pick one of the 65 ids according to those probabilities (likely characters come up often, unlikely ones rarely).
  • out.append(idx): record the pick, then loop -- feeding each new character back in as context. That feed-back-in is autoregeneration.
  • decode(out): ids back to text so you can read what it wrote.
saved output · press Run to reproduce liveTha ETorness V: Whan ashe ysisin se myone omes p, t t hanof il omilte pthund: wend's. Ro'stu twobyoun, wares sh s STo t hethalof firor. Thuirer it ter D r, w's heayimatoray busiuthoifou ithe me y! Tur? PEROr mod Pe ithout TAnghas sano n thy winorit te yordo sprGofane i! t stwndvorounouepo id jor witor serery bend--makneat s bonct m buse n soth d, yockifastalore T: n ld thid me ct tindisthitigo in

Recap

You built and trained a real, if tiny, language model, worked out its gradient by hand, and sampled text from it. The skeleton (forward, loss, backward, update, generate) never changes from here; only the model in the middle grows more capable.

Two obstacles stand in the way next:

  1. Writing gradients by hand will not scale to a deep network, so notebook 04 builds autograd, which works them out for us.
  2. One character of context is hopeless, so notebook 05 introduces attention, which lets a token look back over the whole context.
Download this lesson as a notebook — 03_bigram_model.ipynb