80 occupation names · 25 characters · 671 transitions

Predicting the next character

Everything a small language model does can be built twice: once as a table you fill in by counting, and once as a network that learns. This is both, on the same tiny pile of words, with every number on the page computed from the real run.

every pair of adjacent characters in the corpuscounts
Darker means larger. Rows are the current character, columns are the next one. This grid is the entire first model.

A word is a list of characters with a marker at each end

Take one word from the training pile: baker. Five characters. A model that predicts the next character needs to know two more things that the five characters do not say. Where does the word begin, and where does it stop?

Both problems are solved with one extra symbol. Write the word as . b a k e r . and the question what comes first becomes an ordinary next-character question: what follows the dot. The question when do I stop becomes: predict a dot.

The dot is not punctuation. It is a character the model can predict like any other, and it does two jobs with one symbol because start and end never appear in the same position.

figure 1 · one word, six pairsbaker
Click a word, then click any pair below it. A word of five letters gives six pairs, because both dots count.
# the corpus: 80 occupation names, lower case, one word each DEFAULT_WORDS = ["baker", "farmer", "teacher", "nurse", "doctor", ...] # every word becomes dot, characters, dot chs = ["."] + list(word) + ["."]
in plain words

The model never sees a word. It sees a run of characters, and it is asked the same question at every position: given what is here, what character comes next?

The dot turns the two awkward questions into that same one question. Starting a word is predicting what follows a dot. Ending a word is predicting a dot.

Across all 80 words this gives 671 pairs. That number is the whole training set, and it is small enough that you will feel the limits of it later.

Before anything can be counted, the characters need names the computer can use. A grid cannot be indexed by the letter k.

The vocabulary is a numbering you build once and never change

Collect every distinct character in the corpus and sort them. These 80 occupations use 24 letters: no q and no x. Put the dot at the front and you have 25 symbols, numbered 0 to 24.

The dot goes first on purpose. Index 0 is the one you will type most often, since every generation starts there.

Two dictionaries carry the numbering in both directions. stoi takes a character and gives its index, which is what you need when reading text in. itos takes an index and gives the character, which is what you need when writing text out. They are the same information twice, and building both saves you from reversing a dictionary in the middle of a loop.

figure 2 · 25 symbols, 25 numbersclick a character
Click any cell. The panel shows the same fact read in both directions.
# every distinct character, sorted, with the dot pushed to index 0 chars = ["."] + sorted(set("".join(words))) stoi = {ch: i for i, ch in enumerate(chars)} itos = {i: ch for ch, i in stoi.items()}
in plain words

Every character now has a fixed seat number. The number carries no meaning at all: b being 2 and c being 3 does not make them similar, and nothing in either model will treat 2 and 3 as close together.

The numbers are addresses, used to find a row in a table. That is the only job they do.

One thing to watch. The vocabulary is built from the training corpus, so a character that never appeared in it has no seat. Feed this model a name with an x in it and stoi["x"] raises an error rather than doing something sensible. Small corpora make this easy to hit.

With addresses in hand, the first model needs no learning at all. It only needs counting.

Counting every pair fills a grid of 625 boxes

Make a grid with 25 rows and 25 columns, all zeros. Walk through the corpus one pair at a time. For the pair k then e, go to row k, column e, and add one. That is the entire training procedure for the first model, and it takes one pass.

A bigram is just that: two adjacent characters treated as one observation. Row means the character you are standing on, column means the character that came next.

After 671 pairs the grid holds some large numbers and a great many zeros. er appears 44 times, because a large share of these words are people who do a thing: baker, farmer, welder, printer. r followed by a dot appears 56 times, which is the same fact seen from the other side.

figure 3 · the count gridclick a cell
Click any square. The readout names the pair and its count; the row and column light up so you can see which is which.
N = torch.zeros((V, V), dtype=torch.int32) for word in words: chs = ["."] + list(word) + ["."] for ch1, ch2 in zip(chs, chs[1:]): i = stoi[ch1] j = stoi[ch2] N[i, j] += 1

The zip(chs, chs[1:]) line is the whole trick for walking pairs: one copy of the list starting at the beginning, one starting one step in, zipped together so each step hands you a character and its successor.

in plain words

The grid is a tally sheet. Row k is a record of everything that has ever followed a k in this corpus, and nothing else. No learning happened. The counting is the model.

This is worth pausing on, because it sets the bar. Anything a neural network does here has to beat a tally sheet that took one pass and no arithmetic.

Counts are not yet predictions, though. Row k holds ten observations and row e holds a hundred, and a raw count of 5 means something completely different in each.

A row becomes a prediction when you divide it by its own total

Take row e. It says r happened 44 times, n some number of times, and so on. Add the whole row up, then divide every entry by that total. The counts become fractions that sum to exactly one, and now the row answers a question: given an e, how likely is each of the 25 characters to come next?

That set of 25 numbers adding to one is a probability distribution. Every row of the grid becomes one, and the grid becomes 25 separate predictions, one per current character.

The division has to be per row, which is what dim=1 says: sum along the columns, leaving one total for each row. And keepdim=True keeps that total shaped as a column so the division lines up row by row rather than collapsing into a single number.

figure 4 · one row, normalisedrow: .
Pick a row. Left is the raw count, right is the same row divided by its total. The sum underneath is computed from the bars drawn.
P = N.float() P = P / P.sum(dim=1, keepdim=True)
in plain words

Counts say what happened. Probabilities say what to expect. The only difference is the division, and the division is what makes rows comparable to each other.

A row built from four observations and a row built from four hundred both come out summing to one. That is convenient and slightly dangerous: nothing in the row now tells you how much evidence is behind it.

Look at the row for j, which appears in joiner, judge and jeweller. Three observations, and the resulting probabilities look every bit as confident as the row for e, which rests on more than a hundred.

There is a worse problem hiding in the same place, and it is not about confidence. Of the 625 boxes in the grid, 411 are still zero.

Adding one to every box buys insurance against words you have not seen

The pair aa never occurs in these 80 words. Its count is zero, so its probability is zero, so the model says it is impossible. Not unlikely. Impossible.

That is a strong claim to make from 671 observations, and it has a hard consequence. In a moment we will score the model by taking the logarithm of the probability it assigned to each pair that actually happened. The logarithm of zero is negative infinity. One unseen pair anywhere in the test data and the score for the whole corpus is infinite, no matter how well everything else went.

Smoothing is the cheap repair: add one to every box before dividing. Every pair now has a small probability instead of none, and pairs that did occur lose a little to pay for it.

figure 5 · 411 boxes are emptyno smoothing
Switch the smoothing on. The counter and the two example pairs are recomputed from the grid each time.
P = (N + 1).float() P = P / P.sum(dim=1, keepdim=True)
in plain words

Adding one everywhere is a way of saying: I have not seen this, but I am not willing to bet my whole score that it can never happen.

The size of the number you add is how cautious you are. Add a lot and every row drifts toward flat, which means the model has stopped committing to anything the data told it.

The cost is measurable and worth seeing. Scored on the same corpus it was built from, the raw model reaches 1.9436 and the smoothed one 2.2506, so smoothing looks strictly worse. That comparison is rigged: the raw model was scored only on pairs it had already seen, which is the one situation where its zeros can never be reached. The moment a new word arrives, the raw model's score is infinite and the smoothed model's is fine.

With a probability in every box, the table can finally be asked to produce a word.

Generation is one loop: look up a row, draw from it, move

Start at the dot, which is index 0. Read row 0, which is the distribution over first characters. Draw one character using those probabilities as weights. Whatever you drew becomes the new row to read. Keep going until you draw a dot, then stop.

Everything hinges on the word draw. The function torch.multinomial picks an index at random, but not uniformly: a character with probability 0.44 is picked about 44 times in every hundred, and one with 0.01 is picked about once. The high-probability characters are favoured without the low-probability ones being excluded.

The generator with a fixed seed makes the randomness repeatable. Same seed, same word, every run. That is not a detail for tidiness; without it you cannot tell whether a change you made improved anything or you simply got a different roll.

figure 6 · generating one word, seed 42at the dot
Scroll, or click a step. Each draw is the real one this code makes with seed 42.

Standing on the dot. Row 0 says b is the most likely first character, at 0.1143.

The draw comes back c, which had 0.0857. Not the favourite, and not a surprise either: a one in twelve chance came up.

Now standing on c. The favourite here is h. The draw returns l, at 0.0339.

From l, the draw returns j at 0.0213. Two low-probability picks in a row, and the word is already unlike anything in the corpus.

From j, the draw is a dot, so generation stops. The word is clj. Switch to the other button to see what happens if you never gamble at all.

g = torch.Generator().manual_seed(42) ix = stoi["."] out = [] while True: probs = P[ix] ix = torch.multinomial(probs, num_samples=1, replacement=True, generator=g).item() ch = itos[ix] if ch == ".": break out.append(ch)
in plain words

Taking the most likely character every time sounds better and is not. This model, asked to be greedy, produces ban. Then it produces ban again, and again, forever, because nothing in the procedure ever changes.

The randomness is what makes the model a generator rather than a lookup. It is also why the same model can produce a plausible word and a pile of consonants on two consecutive runs.

Real samples from this table: bantontr, glmar, ksmucn, ubaryspofstr. Vowels and consonants alternate roughly the way they should, words end after a plausible number of characters, and er shows up often. None of them is a word.

That is a fair description by eye. Eyes are a poor instrument for comparing two models, so the next thing to build is a number.

One number says how surprised the model was by the truth

Here is the test. Take a real word the model should find plausible, walk its pairs, and at each pair ask what probability the model gave to the character that actually came next. For baker the six answers are 0.1143, 0.0952, 0.0308, 0.1613, 0.4412 and 0.4957.

Multiplying six probabilities gives a tiny number, and multiplying six hundred gives a number too small for a computer to hold. So take the logarithm of each one and add instead. Multiplication becomes addition, and the numbers stay in a comfortable range.

Log probabilities are negative, since probabilities are below one. Flip the sign to get a positive quantity where smaller is better, and divide by the number of pairs so that long words and short words are on the same scale. That is the negative log-likelihood, and when a model is being trained against it, the same quantity is called the loss.

figure 7 · scoring the word baker6 pairs
Add the pairs one at a time. The running total and the final score are computed from the probabilities shown.
log_likelihood = 0.0 n = 0 for word in words: chs = ["."] + list(word) + ["."] for ch1, ch2 in zip(chs, chs[1:]): prob = P[stoi[ch1], stoi[ch2]] log_likelihood += torch.log(prob) n += 1 nll = -log_likelihood / n
in plain words

The score is the average surprise per character. A model that gave every real pair a high probability was rarely surprised, and its number is low.

There is a fixed reference point worth memorising. A model that ignores the data and guesses uniformly among 25 characters scores the natural logarithm of 25, which is 3.219. Anything above that is worse than knowing nothing.

The word baker scores 1.8911, which is better than the corpus average of 2.2506, and that makes sense: baker is short and full of common pairs. A single word's score is not a measurement of the model, only of the fit between that model and that word.

So we have a bar to beat: 2.2506, from a table that took one pass through the data. To beat it, something has to change about what the model is allowed to look at.

Two characters of memory would need 15,625 rows and you have 671

The table's weakness is easy to name. Standing on e, it gives the same answer whether the word so far is bak or arch. One character of memory is all it has.

The obvious repair is a bigger table: index by the last two characters instead of one. That means 25 times 25 rows, each with 25 columns. Three characters of memory means 15,625 rows. The table grows by a factor of 25 for every character of context you add.

The data does not grow at all. There are 671 observations, and there always will be. Spread across 15,625 rows, almost every row is empty, and a row built from zero or one observation predicts nothing useful no matter how you smooth it.

figure 8 · rows needed against rows you can fill1 character of context
Drag from 1 to 4. Rows needed is 25 raised to that power; observations stay at 671, and the average per row is the division of the two.
in plain words

A counting table has to see a situation before it can say anything about it, and the number of possible situations multiplies every time you widen the window.

This is the exact problem the neural model exists to solve. It does not store a row per situation. It stores a few thousand numbers that get reused across every situation, so a context it has never met can still produce a sensible answer.

The gap is not subtle. With three characters of context there are 15,625 possible rows and 671 observations, so on average each row would hold 0.04 observations. Nearly all of them would be empty boxes filled in by smoothing, which is another way of saying the model would be guessing uniformly.

The counting model does not fail because counting is naive. It fails because the table grows faster than any corpus can fill it.

So the second model keeps the wider window and throws away the table.

Every position in every word becomes one training example

Fix a window width. The code uses three, which it calls the block size: the model always sees exactly the last three characters and predicts the fourth.

Start each word with the window full of dots, because at the beginning there are no previous characters and the dot is what stands for nothing-here. For baker the first example is: given . . ., the answer is b. Then slide the window one step, dropping the oldest character and appending the one just used, and the second example is: given . . b, the answer is a.

Keep sliding to the end, including the final dot as an answer so the model learns where words stop. Five letters give six examples, one per position, exactly as the pair-walk did.

figure 9 · the window sliding through baker0 of 6 examples
Slide the window. The left column is X, one row of three indices; the right column is Y, the single index that follows.
for word in words: context = [stoi["."]] * block_size for ch in list(word) + ["."]: ix = stoi[ch] X.append(context.copy()) Y.append(ix) context = context[1:] + [ix] # drop oldest, append newest

Note context.copy(). Without it every row of X would point at the same list, which keeps being modified, and all 671 rows would end up identical. It is a one-word fix for a bug that produces a model that trains happily and learns nothing.

in plain words

X is a table of 671 rows, each holding three numbers. Y is a column of 671 answers. The model's whole job is to turn a row of X into a good guess at the matching entry of Y.

The window never grows and never looks further back than three. A word like photographer is not learned as a word; it is learned as ten separate small questions.

Something to keep in mind about the split that comes next. The script takes the last 15 percent of these rows as held-out data, which cuts through the middle of a word and leaves the validation set made of whichever occupations happen to be at the end of the list. Splitting by whole word instead is a small change and a much more honest test, and it is what the numbers later on this page use.

Now the model has to do something with three indices. Not compare them, not average them. They are addresses.

Each character gets eight numbers that the training is allowed to change

Build a table with 25 rows, one per character, and 8 columns. Fill it with random numbers. Row 5 is now the description of character 5, and it means nothing at all yet.

Writing C[X] takes every index in X and replaces it with that character's row. A batch of 32 examples, each three characters wide, comes out as 32 by 3 by 8 numbers. No arithmetic has happened; this is a lookup.

What makes this different from the counting table is that these numbers are parameters: training adjusts them. If treating a and o similarly makes the model's guesses better, nothing stops their rows from drifting toward each other, and then anything learned about a partly transfers to o. That transfer is how the model handles contexts it has never seen.

figure 10 · the embedding table after trainingclick a character
Click a character. Its eight real numbers are drawn as cells; the three below are the characters whose rows point most nearly the same way.
C = torch.randn((vocab_size, embed_dim)) # 25 x 8, random to begin emb = C[X] # (32, 3, 8): three lookups per example
in plain words

The counting table had one number per situation, and a situation it never saw was a blank. Here every character carries eight numbers that are reused in every situation it appears in. Evidence about a character in one context improves its answer in every other context.

That is the entire reason this model can be given a wider window without needing 15,625 rows of data.

Be careful about how much you read into these numbers. After 750 steps on 80 words, a's nearest neighbour is o, which is pleasing and might be chance. Eight columns is a size chosen because it is small enough to print, and the columns have no individual meanings: no column is the vowel column. Only the pattern across all eight is used.

Three lookups per example leaves 24 numbers. Turning those into a guess is the network.

The network is two multiplications with a squash in between

The three embeddings for one example sit as 3 rows of 8. Lay them end to end into one row of 24, which is what emb.view(emb.shape[0], -1) does. The -1 means work out this dimension from the total, so a batch of 32 becomes 32 by 24 without you having to type 24.

Flattening keeps position information: the first eight numbers are always the oldest character in the window, the last eight always the newest. The network can treat them differently because they arrive in different slots.

Then multiply by a 24 by 64 matrix, add a bias of 64 numbers, and pass the result through tanh, which squashes every value into the range from minus one to one. Without that squash the two multiplications would collapse into a single one and the model could only draw straight-line relationships. Then multiply by a 64 by 25 matrix and add 25 more numbers. Twenty five outputs, one per character. These are the logits: raw scores, not yet probabilities.

figure 11 · the shape of one batchinput
Scroll, or click a step. Every shape and count here is from the running model.

In goes a batch of 32 examples, each three indices wide. Just numbers naming characters: 32 by 3.

C[X] replaces each index with its eight numbers. 32 by 3 by 8. Still no arithmetic, only lookups.

The three rows of eight are laid end to end: 32 by 24. Position is preserved, so the oldest character stays in the first eight slots.

Multiply by W1, add b1, squash with tanh. 32 by 64. This is the only place the model mixes the three characters together.

Multiply by W2, add b2. 32 by 25: one score per character in the vocabulary. Five tensors, 3,425 numbers, and that is the whole model.

emb = self.C[X] # (32, 3, 8) x = emb.view(emb.shape[0], -1) # (32, 24) h = torch.tanh(x @ self.W1 + self.b1) # (32, 64) logits = h @ self.W2 + self.b2 # (32, 25)
in plain words

A logit is a score, and only its size relative to the other 24 matters. A logit of 8 next to a logit of 2 is a strong preference; two logits of 8 and 8 is no preference at all, even though the numbers are large.

The network never outputs a character. It outputs 25 scores and hands them on, which is what makes the next step interchangeable: the same scores can be turned into a loss for training or into a draw for generating.

Count the numbers being learned: 200 in the embedding table, 1,536 in W1, 64 in b1, 1,600 in W2, 25 in b2. Total 3,425. The counting table had 625 boxes, so this model is about five times larger, and it is being asked to do considerably more with a window three times as wide.

Twenty five scores, and one of them corresponds to the character that actually came next. Turning that into a single number to improve is the last piece before training.

Cross entropy is the same score as before, applied to scores instead of counts

Take the 25 logits. Exponentiate each one, which makes them all positive, then divide by their total so they sum to one. That is softmax, and it turns scores into a probability distribution exactly like a normalised row of the counting table.

Now look up the probability given to the character that actually came next, take its logarithm, flip the sign. That is the same negative log-likelihood from before. Doing both steps at once is what F.cross_entropy is, and it takes logits rather than probabilities on purpose: computing the softmax and the logarithm together avoids exponentiating a large number and then immediately taking its log, which loses precision and can overflow.

Because it is the same measurement, the two models are directly comparable. A cross entropy of 2.4 from the network means the same thing as an NLL of 2.4 from the table.

figure 12 · 25 scores, one answercontext: b a k
Click any bar to see the loss the model would pay if that character were the right answer. The real answer for this context is e.
def loss(self, X, Y): return F.cross_entropy(self.forward(X), Y)
in plain words

The loss only ever looks at the probability of the one right answer. Everything the model spent on the other 24 characters is charged for indirectly, because probabilities have to sum to one, so any weight put elsewhere was taken from the answer.

Being confidently wrong is punished far harder than being unsure. A probability of 0.5 costs 0.69. A probability of 0.01 costs 4.6.

Here is the moment the wider window earns its place. Given the context b a k, the trained network puts 0.62 on e. The counting table, which can only see the k, puts 0.17 on it. Same corpus, same answer, and the difference is entirely that one model knew a b and an a came before.

That comparison is on a context the model has seen. Getting it to hold on words it has never met is what training is for, and training is where the interesting failure lives.

Training is four lines repeated: measure, blame, nudge, clear

Pick 32 rows of X at random. Run them through the network and get a loss. Call loss.backward(), which works out, for each of the 3,425 numbers, whether nudging it up would raise the loss or lower it, and by how much. That quantity is the gradient. Then move every number a small step in the direction that lowers the loss.

The step size is the learning rate, 0.1 here. Too small and training crawls. Too large and each move overshoots, and the loss bounces around instead of settling.

The gradients must be cleared before each pass, which is what setting p.grad = None does. PyTorch adds new gradients to whatever is already there, so skipping the clear means every step is being steered partly by measurements from steps you have already taken.

The 32 rows are a batch. Using a sample rather than all 562 rows makes each step much cheaper and slightly wrong, which is a trade almost always worth taking: many noisy steps beat a few exact ones.

figure 13 · one pass through the loopready
Run a step, then a hundred. The loss shown is read from the recorded training run at that step count.
idx = torch.randint(0, n, (batch_size,)) loss = model.loss(X_tr[idx], Y_tr[idx]) for p in model.parameters(): p.grad = None # clear, or gradients accumulate loss.backward() # fill in every p.grad for p in model.parameters(): with torch.no_grad(): p -= lr * p.grad # the actual learning
in plain words

Nothing in this loop knows anything about language. It knows one number, the loss, and which way to push each of 3,425 dials to make that number smaller.

All of the structure in the output comes from the data, through that one number. The loop would run identically on any other pile of text.

At step 0, before any learning, the loss is 3.442. Guessing uniformly among 25 characters would score 3.219, so the random starting point is slightly worse than knowing nothing, which is what random should mean. After 100 steps it is 2.251, and after 750 it is 1.413.

Falling loss looks like progress. It is worth asking, carefully, what exactly is getting better.

The training loss keeps falling long after the model stops improving

Hold out twelve of the eighty occupations before training starts. Fit on the other sixty eight, and every so often measure the loss on the twelve the model has never seen. Two numbers now, and they do not do the same thing.

The training loss falls the whole way, from 3.442 to 0.601 after ten thousand steps. The validation loss falls to 2.410 at around step 750, and then turns around and climbs to 4.209.

That climb is overfitting, and the name is exact: the model is still getting better at the sixty eight words in front of it, by learning them specifically, and that specific knowledge is worthless on any other word. With 3,425 parameters and 562 training examples there is easily enough capacity to memorise.

The repair is early stopping: keep the version from around step 750 and throw the rest away. The script's default of ten thousand steps trains straight past the useful model and hands you the memorised one.

figure 14 · two losses, ten thousand stepsstep 0
Drag the slider through the run. Both curves are the recorded losses from the real training run at those step counts.
in plain words

The training loss measures how well the model has fitted the answers it was shown. The validation loss measures whether any of that is useful elsewhere. Only the second one is a measurement of the model.

Watch the samples change alongside it. At ten thousand steps this model happily produces teacher, translator and painter, which are not achievements. They are words from the training list, reproduced.

Two things to keep in mind. The gap between the two curves is a size signal: a large gap with a small corpus usually means the model is too big or trained too long, and both are cheap to fix. And the validation set here is twelve words, so its loss is a noisy number, which is why the curve wobbles near the bottom rather than turning at one clean point.

Stopping at 750 gives a model worth sampling from. What comes out of it depends on one more choice, made after training is over.

Temperature reshapes the distribution without touching the model

Divide the logits by a number before the softmax. That number is the temperature, and it changes nothing that was learned. Same weights, same scores, different shape to the distribution they turn into.

Divide by 0.5 and the differences between logits double, so the leader pulls away and the tail is crushed. Divide by 1.5 and the differences shrink, so the distribution flattens and unlikely characters get a real chance. At exactly 1 the logits are used as they are.

The effect on generated words is direct. Low temperature gives safe, repetitive, often memorised output. High temperature gives variety and more nonsense. There is no correct setting, only a choice about which failure you would rather have.

figure 15 · the same scores at three temperaturesT = 1.0
Change the temperature and the context. Bars and words are the real output of the trained model at that setting.
probs = torch.softmax(logits / temperature, dim=1) ix = torch.multinomial(probs, 1, generator=g).item()
in plain words

Temperature is a knob on the sampling, not on the model. Turning it down does not make the model more accurate; it makes it less willing to take its own low-probability options.

It cannot add knowledge and it cannot remove a mistake. If the model is confidently wrong, a low temperature makes it wrong more consistently.

One thing that catches people out: temperature has no effect on the loss. The loss is measured on the distribution the model actually produces, at temperature 1. Reporting a low-temperature sample as evidence the model improved is comparing two different things.

Which leaves the question the whole project exists to answer. Two models, one corpus. Which one is actually better?

The network wins by a tenth of a nat, and not for the reason you would guess

To compare fairly, both models get the same sixty eight training words and are scored on the same twelve held-out ones. The counting table, fitted on those sixty eight, scores 2.496. The network, stopped at its best point, scores 2.410. A win of 0.086.

That is a real improvement and a small one. On a corpus of eighty words it is roughly what there is to win.

Now the part that is easy to get wrong. Run the same experiment with the window set to one character, so the network sees exactly what the counting table sees, and it scores 2.379. Better than the three-character network. The window was not what helped.

What helped is that the network's numbers are shared. Every character's eight numbers are adjusted by every example that character appears in, so a rare context still gets an answer assembled from things the model learned elsewhere. The counting table has to fill each row from that row's own observations, and on 671 observations most rows are nearly empty.

figure 16 · scored on the same twelve wordsheld-out loss
Click a bar. Every value is a measured held-out loss; lower is better, and 3.219 is what guessing uniformly costs.
in plain words

A wider window is only useful if there is enough data to say something about what fills it. Eighty words cannot support three characters of context, so the extra width mostly adds parameters to overfit with.

The lesson is not that context does not matter. It is that context and corpus size are one decision, not two, and this corpus is the small one.

What would change the answer is more words. The same code on tens of thousands of names shows the wider window paying off clearly, because then there is enough evidence behind each pattern for the model to have something to generalise from. Nothing about the architecture has to change; only the pile of text does.

Guessing the next character is the whole mechanism. Everything else is how much you can afford to remember while you do it.

A last thing worth holding on to. Both models here are doing exactly what a large language model does: assign a probability to every possible next symbol, draw one, move along. What separates them from a real system is the size of the vocabulary, the width of the window, and the number of parameters. Not the idea.

The whole project in one screen

Count pairs. Divide by row totals. Add one so nothing is impossible. Draw from a row and move. Score with the log of the probability you gave the truth. Then, when the table cannot grow any further, swap the rows for a few thousand shared numbers, slide a window over the text, and push those numbers downhill until a held-out score stops improving.

Run it yourself with python build.py --tiny for five hundred steps, or --full for ten thousand, which will show you the overfitting rather than tell you about it.

Every number on this page comes from running the code with the 80 occupation corpus: the 625 counts, the 411 empty boxes, the score of 2.2506, the trace for baker, the embedding rows, the probability distributions, the loss curve and the held-out comparison. The two models were compared on a split by whole word, sixty eight to train and twelve held out, because the script's default split cuts through the middle of words and leaves an easier test than it looks.