LLMBuild a Robot Brain · Week 5 · Lesson 8

Words as numbers: a tiny next-word model

Count which word follows which, turn the counts into chances, and roll a die. That's a language model. It's tiny and a bit silly, and it's the great-great-grandparent of ChatGPT. Then you'll give it a real job on the robot: fixing typos in your commands, the way your phone keyboard does.

Time3–4 hours
You'll needPython 3, paper, a pencil, a cup and some scraps of paper
New wordstoken, tokenize, bigram, probability, sampling, language model, edit (typo)
Project pieceA text generator (a baby LLM) and a typo fixer for the robot's commands

Start here

A computer can "write" by doing one thing over and over: look at the last word, guess what usually comes next, pick one, repeat.

The problem

So far Robo-Helper only understands numbers: coordinates, distances, speeds. But you want to talk to it. "Put the red block in the bin." To a computer that sentence is just a row of letters. Before a robot can understand language, it needs a way to turn words into something it can count and calculate with.

The idea in one picture

Think about your phone keyboard. You type "see you" and it suggests "tomorrow", "soon", "later". How does it know? It has noticed which words you usually type after "you". It's not thinking. It's counting. Keep tapping the middle suggestion and your phone writes a whole (weird) sentence. In this lesson you'll build exactly that.

The same counting fixes typos. Type "teh" and your phone changes it to "the". It tries every real word that is one slip of a finger away, and keeps the one that usually comes after the word before. Robo-Helper will do the same with "put teh red blok in the bin".

Words to know

Token
One piece of text the model works with. For us, a word, or a full stop.
Tokenize
Chop text into tokens: "The cat. Sat" → ["the", "cat", ".", "sat"].
Bigram
A pair of tokens side by side, like ("the", "robot"). "Bi" means two.
Probability
A chance written as a number from 0 (never) to 1 (always). 0.25 means 1 time in 4.
Sampling
Picking one option at random, where likelier options get picked more often.
Language model
A program that gives the probability of each possible next token. ChatGPT and Claude are huge language models.
Edit
One typo: a letter deleted, two letters swapped, one letter replaced, or one letter added. "blok" is one edit away from "block".

Going deeper: everything below is the full lesson: the idea built from nothing, two demos, your homework, and a challenge away from the screen.

What this adds to your robot: a typo fixer that runs in front of the robot's small brains. On 9 commands with real typos, Lesson 9's word counter does exactly what it does for the correct spelling 6 of 9 times without the fixer, and 9 of 9 with it (measured by python3 progress.py --solutions: typo commands handled like the correct spelling: 6/9 -> 9/9 with the typo fixer).

1. The problem

Here's the only text our model will ever read. It's the STORY in lesson08_words.py:

the robot picks up the red block . the robot puts the red block in the bin .
the robot sees a cat . the robot stops . the cat walks away . the robot moves on .
the robot picks up the blue block . the robot puts the blue block in the bin .
the robot is happy . the room is tidy .

We want a program that can write new sentences in the same style. How?

Obvious idea 1: pick random words from the story. You get "block happy the in sees away". Every word is real, but the order is nonsense. Word order is the whole point of language.

Obvious idea 2: write grammar rules by hand. "A sentence is the + a noun + a verb…" That works for 5 sentences, then you need thousands of rules, and English breaks them all the time. People tried this for decades. It never scaled.

The idea that does work: don't write rules. Let the text itself tell you what tends to come next.

2. First principles

Step 1: chop text into tokens

Computers can't count "words" until we decide what a word is. Our rule: lowercase everything, keep runs of letters (and apostrophes) as words, and keep the full stop as its own token, because "end of sentence" is useful to know. Everything else (commas, question marks, numbers) is thrown away.

"The cat. Sat"                      → ["the", "cat", ".", "sat"]
"Where's the cat? It's 5 o'clock!"  → ["where's", "the", "cat", "it's", "o'clock"]

Notice "where's" stays as one token. Remember that. It causes a real bug in Lesson 9.

The whole story becomes 66 tokens, using 23 different ones.

Step 2: count what follows what

Slide along the tokens, two at a time, and keep a tally: "after this word, which word came next?"

the robot picks up the red block .
─────────
    ──────────
          ────────
             ────── ...
pairs: (the, robot) (robot, picks) (picks, up) (up, the) (the, red) ...

Do this for the whole story and here's what follows "robot":

after "robot":   picks 2   puts 2   sees 1   stops 1   moves 1   is 1     (8 in total)

In Python we store this as a dictionary of dictionaries: counts["robot"]["picks"] == 2. That's the whole "brain" of our model. Just counts.

Step 3: turn counts into probabilities

Counts depend on how long the text is. We want chances. Divide each count by the total:

picks  2 / 8 = 0.25
puts   2 / 8 = 0.25
sees   1 / 8 = 0.125
stops  1 / 8 = 0.125
moves  1 / 8 = 0.125
is     1 / 8 = 0.125
              ─────
total         1.0    ← probabilities always add up to 1

So after "robot", there's a 1-in-4 chance of "picks". That's what next_word_probs returns. For "the", it's robot 0.5, red 0.125, bin 0.125, blue 0.125, cat 0.0625, room 0.0625.

Predict first: starting from "the", what's the chance the model writes exactly "the robot picks up"?

Multiply the chance of each step: P(robot after the) × P(picks after robot) × P(up after picks) = 0.5 × 0.25 × 1.0 = 0.125, or 1 time in 8. "picks" is always followed by "up" in our story, so that step is certain.

Step 4: sampling, or how to roll a weighted die

Now we write. We could always pick the most likely word, but then "the" is always followed by "robot", and "robot" always by "picks"… the model would repeat the same sentence forever. Instead we pick randomly, but fairly: likely words come up more often.

Picture a ruler from 0 to 1, cut into pieces as long as each probability:

after "the":
0             0.5    0.625  0.75  0.8125 0.9375  1
|    robot     | red  | bin  | cat |  blue | room |

(The pieces go in the order the words were first seen after "the": robot, red, bin, cat, blue, room.)

Pick a random number between 0 and 1 and see which piece it lands in. Python's random.Random(4) gives 0.236 first. That lands in "robot", so the next word is "robot". Python does this for you with rng.choices(words, weights=probs).

Step 5: repeat, and you've built a language model

start with ["the"]
look at the LAST word → get its next-word probabilities → sample one → add it
look at the LAST word → ...
stop after n new words, or if the last word never had anything after it

Here's real output from the solution (seed 4, 12 new words each):

-> the robot picks up the robot puts the cat walks away . the
-> the robot picks up the blue block in the robot stops . the
-> the blue block in the red block . the blue block in the

Every pair of neighbouring words appeared in the story. But the sentences wander: "picks up the robot puts the cat". Why? Our model only remembers one word. After "the" it has no idea it was in the middle of "picks up the …".

Predict first: can this model ever write "the robot sees a block"?

No. "a" is only ever followed by "cat" in the story, so P(block after a) = 0. A bigram model can only use pairs it has seen. It can make new sentences, but never a new pair.

Step 6: so how is ChatGPT different?

The loop is the same: give the chances of each next token, pick one, add it, repeat. When an AI chatbot writes to you, it's doing your generate loop, one token at a time. What modern LLMs add:

But under all that, the job is still: predict the next token. You've just built the smallest possible version.

Step 7: the robot's first job, fixing typos

People type fast and make mistakes. Next lesson's word counter (Lesson 9) has never seen "bleu", so for "go to the bleu block" it finds no object and replies Sorry, I can't do that: 'go' needs an object. For the correct spelling it drives to the blue block. A next-word model can fix that. Two parts.

Part 1: list every word one typo away. There are four kinds of typo. For "cat":

delete one letter          cat → at
swap two neighbours        cat → act
replace one letter         cat → bat   (try all 26 letters in each place)
insert one letter          cat → cart  (try all 26 letters in each gap)

A 3-letter word has 3 deletes, 2 swaps, 3 × 26 = 78 replaces and 4 × 26 = 104 inserts: 187 in all. Some come out the same (replacing "c" with "c" gives "cat" back), so the set has 182 different strings. Almost all are nonsense like "zat". We keep only the known words: words the model has counted.

Part 2: let the word before decide. For the robot, the known words come from Lesson 9's 34 example commands (53 different tokens). Here's the same typo with two different words before it:

"go te ..."   known words one typo from "te": me, the, to
              seen after "go": to (0.25)                → to
"put te ..."  known words one typo from "te": me, the, to
              seen after "put": the (0.67)              → the

Same typo, different fix, because of the word before. That's the bigram model at work. Two fallbacks: if none of the candidates was ever seen after the previous word, pick the one that starts the most pairs (the most common word); if there are no known candidates at all, leave the word alone. A word at the start of a command uses "." as its previous word, because commands are joined with full stops.

Real output on the 9 TYPO_COMMANDS in the file:

'put teh red blok in the bin'    -> 'put the red block in the bin'
'go to the bleu block'           -> 'go to the blue block'
'whre is the green block'        -> 'where is the green block'
'fnd the red block'              -> 'find the red block'
'stpo'                           -> 'stop'
'put the gren block away'        -> 'put the green block away'
'thorw away the blue block'      -> 'throw away the blue block'
'head to the red blokc'          -> 'head to the red block'
'put the red bclok away'         -> 'put the red bclok away'   (wanted 'put the red block away')
fixed 8 of 9

The last one fails on purpose. "bclok" has two typos (a swap and a swap), and we only look one edit away. You could look two edits away, but "bclok" has 286 one-edit strings and 36,860 two-edit strings, so there are many more chances of a wrong guess.

Predict first: what does it do with "drop it in the bn"?

It writes drop is in the bin. "bn" became "bin" (after "the", "bin" was seen and "in" and "on" weren't). But "it" was never a typo. It's just not in the 34 commands, and "is" is one letter away, so the fixer "fixes" it. A real phone keyboard has a dictionary of every proper word, so it knows "it" is fine. Ours only knows the words it has counted.

3. Play with it

Interactive: a bigram model you can retrain

This uses exactly the Python rules: the same tokenizer, the same counting, and the same random numbers as Python's random.Random(seed). With the original story, seed 4 and start word "the", you get the three sentences printed by lesson08_words.py. Edit the text and everything updates.

–
Tokens
–
Different tokens
–
Different pairs
–

Things to try: change the seed. Type a word that isn't in the text (nothing to show, and generation stops at once). Paste in a paragraph from a school essay of yours and see how the "style" changes.

Interactive: the typo fixer

The same edits1, fix_word and fix_typos as the Python. The known words and the counts come from the commands in the box, which start as Lesson 9's 34 TRAIN commands, joined with full stops exactly like lesson08_words.py does. Type a command with typos and see how each word is chosen.

Fixed: –

The commands it learned from (edit them)

4. Homework

Open homework/lesson08_words.py. STORY, LETTERS, TYPO_COMMANDS and known_words are written for you. Write these seven functions, in order. The first four are the language model; the last three are the typo fixer.

4.1 tokenize(text)

Lowercase the text and return a list of tokens: words (letters and apostrophes) plus "." as its own token.

Example: tokenize("The cat. Sat") → ["the", "cat", ".", "sat"].

Hint 1

Two jobs: make it lowercase, then find the pieces. Python strings have a method for the first.

Hint 2

The docstring gives you a tip: re.findall with a pattern. A pattern is a mini-language for "what a piece looks like". [a-z']+ means "one or more letters or apostrophes", \. means "a full stop", and | means "or".

Hint 3

Return re.findall(the pattern from the docstring, text.lower()). In the file, the pattern is written as a raw string: r"...".

4.2 count_bigrams(tokens)

Return a dictionary of dictionaries: counts[word][next_word] = how many times next_word came right after word.

Example: count_bigrams(["a", "b", "a", "b", "c"]) → {"a": {"b": 2}, "b": {"a": 1, "c": 1}}. (The pairs are a-b, b-a, a-b, b-c.)

Hint 1

A list of 5 tokens has 4 pairs. Write them out on paper first.

Hint 2

You can loop with an index, for i in range(len(tokens) - 1), using tokens[i] and tokens[i + 1]. Or the neat way: zip(tokens, tokens[1:]) gives the pairs directly.

Hint 3

For each pair (a, b): if a isn't in counts yet, add an empty dict for it (setdefault does this in one go). Then add 1 to counts[a][b], using .get(b, 0) so a new pair starts from 0.

4.3 next_word_probs(counts, word)

Turn counts[word] into probabilities that add up to 1. If word was never seen, return {}.

Example: next_word_probs({"b": {"a": 1, "c": 3}}, "b") → {"a": 0.25, "c": 0.75}.

Hint 1

It's Step 3 above: each count divided by the total.

Hint 2

counts.get(word, {}) gives an empty dict for unknown words. sum(d.values()) gives the total.

Hint 3

Get the inner dict and its total. If the total is 0, return {}. Otherwise build a new dict with each next_word: count / total.

4.4 generate(counts, start, n, rng)

Start with [start]. Repeat up to n times: get the probabilities after the last word, sample one with rng.choices, add it. Stop early if there are no options. Return the words joined with spaces.

Example: generate(counts, "zzz", 5, rng) → "zzz" (no options after "zzz", so it stops at once). With a real start word and n = 5, you get 6 words.

Hint 1

Keep a list called something like out. The last word is out[-1].

Hint 2

rng.choices(words, weights=probs_list) returns a list with one item. Take [0]. Build words and the weights in the same order, from the probabilities dict.

Hint 3

for _ in range(n): get probs for out[-1]; if empty, break; words = list(probs); append rng.choices(words, weights=[probs[w] for w in words])[0]. At the end, " ".join(out).

4.5 edits1(word)

Every string one typo away from word: delete one letter, swap two neighbouring letters, replace one letter (with any of LETTERS), or insert one letter (anywhere, including the very start and end). Return a set.

Example: edits1("cat") contains "at", "act", "bat" and "cart" (and "cat" itself, from replacing a letter with the same letter). len(edits1("ab")) is 130.

Hint 1

Cut the word into a left part and a right part at every position: for "cat" that's ("", "cat"), ("c", "at"), ("ca", "t"), ("cat", ""). Every kind of typo is easy to write from those pairs.

Hint 2

With a pair (a, b): delete is a + b[1:] (only if b isn't empty). Swap is a + b[1] + b[0] + b[2:] (only if b has 2 or more letters). Replace is a + c + b[1:] for every letter c. Insert is a + c + b for every letter c.

Hint 3

splits = [(word[:i], word[i:]) for i in range(len(word) + 1)]. Build the four lists with comprehensions over splits (and over LETTERS for replace and insert), then return set(deletes + swaps + replaces + inserts).

4.6 fix_word(word, prev, counts, known)

Fix one word. If word is in known, keep it. Otherwise look at the known words in edits1(word):

Example: fix_word("bok", "red", {"red": {"box": 1, "block": 3}}, {"red", "box", "block"}) → "box". "block" is likelier after "red", but it's two typos from "bok", so it isn't a candidate.

Hint 1

The candidates are the set edits1(word) & known: & keeps what's in both sets. Sort them, so ties are decided alphabetically.

Hint 2

Get probs = next_word_probs(counts, prev). The candidates seen after prev are the ones that are keys of probs. max(list, key=...) picks the best, and on a tie it keeps the first one, which is the alphabetical first if the list is sorted.

Hint 3

Return early twice: word if it's known, and word if there are no candidates. Then after = [w for w in candidates if w in probs]; if after, return max(after, key=lambda w: probs[w]); else return max(candidates, key=lambda w: sum(counts.get(w, {}).values())).

4.7 fix_typos(text, counts)

Fix each token of tokenize(text) with fix_word, using the previously fixed word as prev. Start with prev = ".". Return the fixed tokens joined with spaces.

Example: with counts from "put the red block in the bin . put the blue block in the bin", fix_typos("put teh red blok", counts) → "put the red block".

Hint 1

Work out known = known_words(counts) once, before the loop, not once per word.

Hint 2

Use the fixed word as the next prev, not the typo. After "teh" is fixed to "the", the next word should be chosen by what follows "the".

Hint 3

out, prev = [], "."; for each w in tokenize(text): prev = fix_word(w, prev, counts, known) and append prev to out. Return " ".join(out).

Run it

cd homework
python3 lesson08_words.py

What you should see (real output of the reference solution):

after 'robot': {'picks': 0.25, 'puts': 0.25, 'sees': 0.12, 'stops': 0.12, 'moves': 0.12, 'is': 0.12}
-> the robot picks up the robot puts the cat walks away . the
-> the robot picks up the blue block in the robot stops . the
-> the blue block in the red block . the blue block in the

Fixing typos with the robot's command words (from Lesson 9's examples):
  'put teh red blok in the bin'    -> 'put the red block in the bin'
  'go to the bleu block'           -> 'go to the blue block'
  'whre is the green block'        -> 'where is the green block'
  'fnd the red block'              -> 'find the red block'
  'stpo'                           -> 'stop'
  'put the gren block away'        -> 'put the green block away'
  'thorw away the blue block'      -> 'throw away the blue block'
  'head to the red blokc'          -> 'head to the red block'
  'put the red bclok away'         -> 'put the red bclok away'   (wanted 'put the red block away')
fixed 8 of 9

The typo part needs your Lesson 9 file, because it borrows TRAIN from lesson09_commands.py. If you haven't done Lesson 9 yet, the first part still runs; come back to the typo part next week. Why 0.12 and not 0.125? The program rounds to 2 decimal places for printing, and Python rounds 0.125 to the even neighbour, 0.12.

Check it

cd ..
python3 check.py 8

Seven checks: tokenize, count_bigrams, next_word_probs, generate, edits1 and fix_typos (which also tests your fix_word). Green ✓ per check, red ✗ with a hint, yellow … for "not written yet". Real hints you might see:

Stretch

5. Real-world challenge: a language model in a cup

You'll train and run a bigram model by hand on your own writing.

  1. Pick about 20 messages you sent (not other people's), or a page of your own writing. Copy them onto paper. Leave out anything private.
  2. Choose your 5 most common words (often "i", "the", "to", "you", "a"). For each, make a tally table of the word that came right after it.
  3. Pick one word, say "i". For each follower, write it on scraps of paper: one scrap per tally mark. If "i" was followed by "am" 3 times and "think" once, that's 3 "am" scraps and 1 "think" scrap. Put them in a cup. Now the cup is the probabilities.
  4. Generate: draw a scrap, write it down, then use the cup for that word (make cups for your other common words too; if you land on a word with no cup, stop). Make 3 "sentences".
  5. Then type the same messages into the STORY in your Python file (or into the demo box above) and compare the computer's sentences with yours.

Record: your tally tables, your 3 hand-made sentences, and one probability you worked out (e.g. P(am after i) = 3/4 = 0.75). What it should teach you: the "model" is just your own habits, counted. It sounds a bit like you, but it has no idea what it's saying. A model can only be as good as the text it learned from.

6. Your Robo-Helper so far

You've added a baby language model (tokenize, count_bigrams, next_word_probs, generate) and its first robot job, a typo fixer (edits1, fix_word, fix_typos).

What this adds to your robot: in the course's simulator, brains.clean() runs your fix_typos in front of the small brains, using the words of every command they were trained on. Measured with python3 progress.py --solutions: typo commands handled exactly like the correct spelling go from 6/9 to 9/9 for the word counter. Without the fixer, "go to the bleu block" and "put the gren block away" are refused, and "fnd the red block" puts the red block in the bin instead of finding it. "put the red bclok away" stays unfixed, but the robot still gets it right, because "put" and "red" are enough for the word counter.

The robot won't write stories in the final project. Two pieces live on: the typo fixer, and tokenize. In Lesson 9, lesson09_commands.py starts with from lesson08_words import tokenize and uses it to chop up commands like "put the red block in the bin" before counting words to guess what you want. Then Lesson 11's capstone uses that command reader, so your tokenizer ends up inside the finished Robo-Helper. And in Lesson 10 you'll plug in a real LLM: the grown-up version of what you just made.

7. Common mistakes

8. Check yourself

What does a bigram model remember when it picks the next word?

Only the single last word. That's why the sentences wander off topic.

Predict: what does tokenize("STOP! Stop, now.") return?

["stop", "stop", "now", "."]. Lowercased, "!" and "," dropped, the full stop kept.

After "block", our story has "." twice and "in" twice. What does next_word_probs(counts, "block") return?

{".": 0.5, "in": 0.5}.

Why sample randomly instead of always picking the most likely next word?

Always picking the top word gives the same sentence every time, and it often gets stuck in loops. Sampling gives variety while still favouring likely words.

Why does "te" become "to" after "go" but "the" after "put"?

Both are one typo from "te". The fixer keeps the candidate most likely to come after the previous word. In the robot's commands, "go" is followed by "to" but never by "the", and "put" is followed by "the" two times in three.

Name two things ChatGPT has that your model doesn't.

Any two of: a much longer context (thousands of tokens, not 1 word); a neural network instead of a count table, so it can handle combinations it never saw; training on trillions of words instead of 66; word-piece tokens; extra training to follow instructions.

9. Go deeper (optional)