Chapter 6 of 14 30 min
Tokens
A letter is too small a piece and a word too big. Let's see how Sprout cuts up text, where its 8,192 tokens came from, and why it sees Russian as a scatter of bytes.
In this chapter
- explain why a model needs tokens rather than letters or words
- run the BPE algorithm by hand and read Sprout's real tokenizer
- understand what vocabulary size costs and why non-Latin languages are expensive for a small model
The model from the last chapter sees the last eight letters. Eight letters are just "the litt": not even a whole word fits, let alone a sentence. What if each of the eight slots held something bigger than a letter, like a whole common word, or at least a syllable?
That is exactly what every large language model does. Text is cut into tokens, pieces of different lengths, and each piece gets its own number and its own embedding. Sprout has 8,192 of them. Before we get to where they came from, let's look at how it cuts text up.
How Sprout reads
Below is Sprout's real tokenizer, the very one it was trained with. Each colour is a separate token. Type anything, or try the ready-made examples, Russian text and emoji included.
A few things jump out straight away.
- Common words are a single token, including the space in front of them: "·time", "·little", "·garden". To the model, "·the" in mid-sentence and "The" at the start are different tokens.
- A rare word is built from pieces. antidisestablishmentarianism turns into nine tokens: "·ant | id | is | est | ab | lish | ment | arian | ism". The model has never seen the whole word, but it knows the parts.
- Numbers go one digit at a time. That's not an accident but a rule; more on it below.
- Russian text falls apart: nearly every letter becomes two token shards. The sample "Жили-были дед да баба…" has 49 characters and 86 tokens. Each emoji is four shards.
The bottom of the widget shows what we gained. A window of eight letters is a scrap of a word. A window of eight tokens of English is almost a whole phrase: on our validation text a token averages 4.085 characters, so eight slots hold about 33 characters. In Russian, though, the window gets even smaller than the letter one: eight tokens are just four or five letters. Why that happens is the plot twist of this chapter.
Letters are too small, words too big
Before inventing tokens, let's look at the two obvious options.
Letters. The vocabulary is tiny, 97 symbols, and there is no such thing as an unknown letter. But text gets long: a thousand-character story is a thousand steps, each a tiny decision about the next letter. Everything the model needs to remember is stretched over a huge number of positions. In chapter 7 we'll see that the cost of attention grows with the square of the text length: a text four times longer costs sixteen times as much.
Words. The text is short, each slot a whole word. But the vocabulary swells. In our validation text alone, 7.2 million tokens of simple stories and conversations, we counted 28,666 different words, and 6,357 of them appear exactly once. Every word needs its own row in the embedding table, and rare rows barely learn anything. Worse, any new word (a name, a typo, "Sprout" in some other spelling) falls outside the vocabulary, and all the model gets is an "unknown" tag.
Pieces of words are the happy medium. Common words stay whole, rare ones are built from parts, and something completely unfamiliar can be spelled out letter by letter. There are no unknown words at all. On our validation text, a word with a space in front of it is exactly one token 95% of the time: almost as good as a word vocabulary, without its problems.
Bytes at the very bottom
Where should the cutting start? With letters, you'd think, but the world's scripts have tens of thousands of characters, Chinese ones included, and then there are arrows and emoji. Sprout's tokenizer, like GPT-2's and many modern models', starts from bytes.
Computers usually store text in the UTF-8 encoding: every character takes one to four bytes, and a byte is a number from 0 to 255. Plain Latin letters, digits and punctuation take one byte. Russian letters take two: the first (usually D0 or D1 in hexadecimal) says "Cyrillic ahead", the second picks the letter. Emoji take four.
There are 256 possible bytes, and they are the first 256 tokens of Sprout's vocabulary. So the tokenizer can write down any text in any language one way or another: at worst, byte by byte. Apart from four special tokens (more on them later), the other 7,932 are glued-together pairs learned from the corpus. How they were chosen comes next.
Glue the most frequent pair
The algorithm is called BPE, byte pair encoding. It is strikingly simple:
- Write the whole training text as bytes.
- Count which pair of neighbouring tokens occurs most often.
- Declare that pair a new token and replace every occurrence of it.
- Repeat until the vocabulary is as big as you want.
Try it yourself on a tiny text. Every press is one merge.
The pairs with the most occurrences go first: "a"+"t", "t"+"h", then "th"+"e", and "the" is already a single token. Then a space sticks to it. Words that repeat a lot quickly become whole tokens; rare ones stay in pieces. No linguistics at all, just counting.
Here's the same algorithm in plain Python, in a couple of dozen lines. Run it and compare the merges with the widget: they match.
First, cut into chunks
One subtlety. If pairs were merged across the whole text, BPE would happily learn tokens like "g." or "d the": such combinations are common too. But they mix the end of one word with the start of the next, and vocabulary slots get wasted. So before training, the text is cut into chunks with a regular expression, and merges never cross a chunk's borders. Switch the widget at the top of the chapter to "Chunks" to see those borders.
Here is the regular expression, straight from tokenizer.py:
Let's take it apart at the vertical bars, which mean "or":
'(?:[sdmt]|ll|ve|re)— English contractions: 's, 't, 'll, 've, 're. "didn't" is cut into "didn" and "'t".?\p{L}+— a word made of letters of any alphabet, together with one space before it. That's where "·little" comes from.?\p{N}— a single digit. So 2024 is four tokens. That makes place value easier for the model to see and keeps numbers from turning into thousands of arbitrary tokens.?[^\s\p{L}\p{N}]+— a run of punctuation, like '!"' or "...".\s+(?!\S)|\s+— spaces and newlines.
And here is the training loop itself. It does exactly what our mini BPE does, but counts more cleverly: it remembers which chunks contain each pair, and after a merge it recounts only those. Without that, 7,932 merges over 120 thousand documents would take hours; with it, Sprout's tokenizer trained in 19 seconds.
The output is real, from Sprout's training (the log counts merges from zero): the very first merge is "·t", by step 500 the tokenizer is already gluing whole words like "·flow", and step 7,000 gives "·ambulance".
How a word is put together
Now we have a vocabulary. How do we cut up new text? Encoding simply replays the merges in the order they were learned: while the chunk contains a pair from the vocabulary, merge the one that was learned earliest.
The result is easiest to draw as a tree: bytes at the bottom, merges above them, the finished token on top. Each merge's number is its place in the training queue.
Look at "·the": merge no. 1 is "·t", no. 2 "he", and by the seventh merge the whole "·the" is learned. Long words take ages, counted in merges: "·butterfly" waited until no. 2,095, and "·Congratulations" (16 bytes, one of the two longest tokens in the vocabulary; the other is "·recommendations") only appeared at no. 4,312. Curiously, lower-case "·sprout" is one token while capitalised "·Sprout" is three: the capitalised form was rarer in the stories. And "·Росток" is thirteen bytes (the space plus two per letter) with not a single merge.
What's in the vocabulary
Let's walk through the vocabulary from the first merge to the last. Move the slider: at the start come letter pairs and short common words, further on longer and rarer words and names.
The first merges are "·t", "he", "·a", "·s", "·w", "in", and the seventh is "·the". At the very end come "·mushrooms", "Josh", "·armcha" (half an armchair: the whole word never made it), "·recommendations". More than half the vocabulary consists of a space followed by letters: whole words or their beginnings.
The last four numbers, 8188–8191, belong to the special tokens: <|endoftext|>, <|user|>, <|assistant|> and <|end|>. Ordinary text never produces them; the program inserts them. The first separates one story from the next; the others come into play in chapter 12, when we teach Sprout to talk. Try the "specials" example in the widget at the top of the chapter: each marker is a single token, drawn as a solid chip.
How many tokens do we need?
Why 8,192 and not 1,000 or 100,000? A bigger vocabulary means shorter text but a more expensive model. The slider below shows what a smaller vocabulary would do. Nothing has to be retrained: BPE learns greedily, one merge at a time, so stopping training earlier would have given us exactly the first merges of our vocabulary.
| Vocabulary | Characters per token | Embedding table ×384 |
|---|---|---|
| 256 (bytes only) | 1.00 | 98,304 |
| 512 | 2.21 | 196,608 |
| 1,024 | 2.91 | 393,216 |
| 2,048 | 3.50 | 786,432 |
| 4,096 | 3.88 | 1,572,864 |
| 8,192 | 4.09 | 3,145,728 |
The first 256 merges more than halve the length of the text. Every further doubling of the vocabulary buys less: going from 4,096 to 8,192 tokens saves only 5% of the length while the embedding table doubles. In Sprout it already weighs 3.15 million parameters, almost a fifth of the model.
There is a second price too. The bigger the vocabulary, the rarer each token and the worse its embedding learns. Even with 8,192 tokens, 137 of the learned merges never once appear in the 7.2 million tokens of validation text, and 506 appear fewer than ten times. For a 17-million-parameter model writing simple English stories, 8,192 is a sensible compromise.
Comparing models with different tokens
Since the length of a text depends on the vocabulary, there's a trap here. The model at the end of this chapter is the same kind of network as last time, the same MLP class from snapshots.py in a new size, MLP(8192, 8, 48, [384]): eight tokens of 48 numbers and one hidden layer of 384 neurons. On validation text its surprise is 3.31 nats per token. The letter model had 1.103 nats per letter. Three times worse?
No: we're comparing different things. Guessing the next token means guessing about four characters at once, and the surprise for all of them adds up. The fair comparison is per unit of text. If a token holds $c$ characters on average, then
$$\text{nats per character} = \frac{\text{nats per token}}{c} = \frac{3.31}{4.085} \approx 0.81.$$That's clearly better than the letter model's 1.103. Researchers watch out for the same trap: the perplexities of models with different tokenizers can't be compared directly, so they are first converted to a per-character or per-byte figure.
Zipf's law
Tokens occur very unevenly. The linguist George Zipf noticed this for words back in the 1930s and 40s: the second most frequent word occurs about half as often as the first, the third a third as often, and so on. Frequency is inversely proportional to rank. Draw it with both axes on a log scale and you get an almost straight line.
Tokens come out nearly Zipfian: our curve's slope in the middle is about −1.2 against the ideal −1. In practice this means a small handful of tokens does most of the work. The most common token, the full stop, is 7.2% of the text. The top ten tokens cover 27.9% of it, the top hundred 56.2%, the top thousand 86.1%. The other seven thousand share less than a seventh of the text between them. That's why a huge vocabulary pays off poorly: each new merge is a rarer word.
An honest word about Russian
Now it's clear why Russian text falls apart into shards. Sprout's tokenizer learned only from English stories. There was practically no Cyrillic in the corpus, since cleaning removed non-English texts, and not one of the 7,932 merges contains a byte above 127. So for Russian the tokenizer has nothing but the 256 byte tokens: every letter costs two tokens, and every space one more.
- "Once upon a time there lived an old man and an old woman. They had a hen." — 73 characters, 20 tokens: 3.65 characters per token.
- «Жили-были старик со старухой. Была у них курочка.» (the same opening in Russian) — 49 characters, 88 tokens: 0.56 characters per token.
- «Росток» ("Sprout" in Russian) — 6 letters, 12 tokens.
- 🌱 — one character, 4 tokens.
To Sprout, the same story in Russian is more than four times as long. Its context is 512 tokens: about 2,100 characters of English but under 300 characters of Russian, a couple of paragraphs against a couple of sentences. And that's not all. In 7.2 million tokens of validation text, bytes above 127 turned up only 30 times. Their embeddings have barely been trained: to Sprout, a Russian letter is two almost meaningless vectors in a row.
This holds for every model: a language that is scarce in the training corpus is expensive in the tokenizer too. The tokenizers of big multilingual models are trained on text in many languages at once, so they do have merges for Cyrillic. But the "token tax" on languages less common than English in the corpus never disappears; it only gets smaller.
A tokenizer is a vocabulary of frequent pieces learned by counting. Whatever the corpus had plenty of, it reads in big cheap pieces; whatever was scarce, in small expensive ones.
Why does Sprout spend six tokens on the Russian word «кот» ("cat") but one on the English " cat"?
BPE learned only the pairs that were frequent in the training text. There was almost no Cyrillic there, so Russian letters stay as pairs of bytes, while " cat" was merged into a single token early in training.
What if the text contains something really strange?
Nothing bad happens: any text is bytes, and all 256 bytes are in the vocabulary. A Chinese character becomes three byte tokens, a rare emoji four, and a composite emoji like "family of four" twenty-five. The model may not understand it, but it can always write it down and read it back without loss. That's why byte-level BPE never has an "unknown token".
Sprout right now
Sprout's build has barely changed: an eight-slot window, embeddings, a hidden layer. But every slot now holds a token, and the window spans about 33 characters instead of eight. Its surprise is 3.31 nats per token on validation text, or about 0.81 nats per character against 1.103 for the letter model. It has more parameters, too: of its 3.7 million, about 3.15 million sit in the output layer that scores every one of the 8,192 tokens. The prose hangs together better, but eight tokens are still just one phrase. How to look further back without bloating the network is the next chapter's subject: attention.