At a glance
Andrej Karpathy opens with a set face, and says why: tokenization is his least favorite part of working with large language models. It is necessary anyway, because it is hairy and gnarly and full of hidden foot guns, and because a startling share of the oddness people blame on model architecture traces straight back to it. "A lot of the issues that may look like just issues with the neural network architecture or the large language model itself are actually issues with the tokenization, and fundamentally trace back to it."
Over two hours and thirteen minutes he builds a byte pair encoding tokenizer from an empty cell: Unicode code points, UTF-8 bytes, a pair counter, a merge function, a training loop, then encode and decode, including the specific bugs each one hides. Then he adds everything production tokenizers put on top: the regex that pre splits text so merges can never cross category boundaries, read piece by piece; what GPT-4 changed in that regex; special tokens and the model surgery they require; SentencePiece and the real configuration differences against tiktoken; and how vocabulary size trades off against the embedding table and the softmax. The reference implementation is minbpe, published alongside the lecture with a four step exercise that walks you to your own GPT-4 tokenizer.
The last twenty minutes are the payoff. He returns to the list of crimes he opened with and explains each one with the mechanism now visible: why GPT-4 miscounts the letters in .DefaultCellStyle, why it cannot reverse that string but can if you first make it list the characters out, why "hello how are you" is 5 tokens and its Korean translation is 15, why 677 arrives as two tokens, why GPT-2 wrote bad Python, why a trailing space in your prompt pushes the model out of distribution, and why asking GPT-2 about a Reddit user called SolidGoldMagikarp makes it insult you.
His own closing position: do not brush this off, there are foot guns and security issues and AI safety issues living in this stage. And eternal glory goes to anyone who manages to get rid of it.
The deep explanation
Where the last video left off, and why that tokenizer was a toy
Karpathy starts by rewinding to Let's build GPT from scratch, the previous video in the series, because that video already did tokenization. It just did a very naive, simple version of it.
In that Colab notebook the training set was the tiny Shakespeare dataset, which in the beginning is just a large string in Python. Just text. The question the whole field has to answer is how you plug text into a neural network, and the answer there was the simplest possible one: collect every distinct character that occurs in the string, find there are 65 of them, and build a lookup table from each one character string to an integer. Tokenize "hi there" and you get back a short sequence of integers. Take the first 1,000 characters of the dataset and encode them, and because the scheme is character level, you get exactly 1,000 tokens out.
Those integers then index an embedding table. Sixty five possible tokens means an embedding table with 65 rows. You take the integer for a token, use it as a lookup into that table, pluck out the corresponding row, and that row is a vector of trainable parameters that you train by back propagation. That vector is what feeds into the Transformer. The Transformer never sees a character. It sees a row of a table.
That is the whole mechanism, and it survives intact into state of the art models. What changes is the vocabulary. In practice nobody works at the character level. They work at the chunk level, and the chunks are constructed by algorithms like byte pair encoding, which is the subject of the next two hours.
For a paper that introduced byte level BPE as the tokenization mechanism for large language models, Karpathy points at the GPT-2 paper, Language Models are Unsupervised Multitask Learners. Scroll to the section on input representation and you get the properties they wanted from a tokenizer and the conclusion they landed on: a vocabulary of 50,257 possible tokens, with a context size of 1,024 tokens, up from GPT-1's 512. In the attention layer of the Transformer every token attends to the previous tokens in the sequence, and it can see up to 1,024 of them.
Tokens, in other words, are the atom. Everything is in units of tokens. "Tokenization is the process for translating strings or text into sequences of tokens, and vice versa." Search the Llama 2 paper for the word "token" and you get 63 hits, including the headline number that they trained on two trillion tokens of data.
The crime sheet he opens with
Before any code, Karpathy front loads the motivation with a list of failures, because he wants the reader sufficiently motivated for why all of this gross work matters. Every item on this list comes back at the 1:51:41 mark with a mechanism attached:
- Why can't my LLM spell words very well?
- Why can't my LLM do super simple string processing tasks, like reversing a string?
- Why is my LLM worse at non English languages, say Japanese?
- Why is my LLM bad at simple arithmetic?
- Why did GPT-2 have more than its share of trouble coding in Python?
- Why did my LLM abruptly halt when it sees the string
<|endoftext|>? - What is this weird warning I get about a trailing whitespace?
- Why does the LLM break if I ask it about SolidGoldMagikarp?
- Why should I prefer YAML over JSON with LLMs?
His framing: "tokenization is at the heart of a lot of weirdness in large language models, and I would advise that you do not brush it off."
Tokenization by example, before any code
Rather than start in a notebook, he starts in a browser, at Tiktokenizer. What he likes about this web app is that tokenization runs live in your browser in JavaScript, so you type on the left and watch the chunks appear on the right, each token in its own color. The tokenizer selected at the start is gpt2, and the sample string he has pasted in currently tokenizes into 300 tokens.
Walk through the English sentence first. The word "Tokenization" becomes two tokens, 30642 and 1634. The token " is" is 318. The token " at" is 379. The token " the" is 262. Turn on the whitespace display at the bottom of the app and you can see there are spaces and newline characters in there, though you can hide them for clarity. The thing to notice is that the space is part of the token chunk. The common token is " the" with its leading space attached, not "the".
Then arithmetic, and the arbitrariness begins. In 127 + 677, the 127 feeds into the model as a single token, but 677 feeds in as two separate tokens, " 6" and "77". So the large language model has to take account of that split and somehow process it correctly inside its network. He scrolls through four digit numbers and the pattern is pure chance: 804 breaks into " 8" and "04", 1275 into "12" and "75", 6773 into " 6" and "773", 8041 into " 8" and "041". "Sometimes you have multiple digits as a single token, sometimes you have individual digits as many tokens, and it's all kind of pretty arbitrary and coming out of the tokenizer."
Then the egg demonstration, which is the cleanest illustration of how little the tokenizer cares about concepts. The string "Egg" at the start of a sentence becomes two tokens. Write "I have an egg" and " egg" with its leading space is suddenly a single token, for the exact same letters. Lowercase "egg" by itself is a single token too, and you can see from the color that it is a different token, because the tokenizer is case sensitive. Capital "EGG" would be different tokens again. So for one concept, egg, whether it sits at the beginning of a sentence or in the middle, lowercase or uppercase or mixed, you get a handful of completely different token ids, and the language model has to learn from raw internet text that all of those are the same thing and group them in its parameters. "It has to sort of group them in the parameters of the neural network and understand, just based on the data patterns, that these are all very similar."
Then Korean. He pastes in an introduction from OpenAI's ChatGPT written in Korean, because non English languages work measurably worse in ChatGPT and part of that is the tokenizer, not just the model. When you train the tokenizer there is a training set, and there is a lot more English in it than anything else, so the tokenizer learns a lot more long tokens for English. Take a single English sentence and it might be 10 tokens. Translate it into Korean or Japanese and the count goes up substantially, because the chunks over there are a lot more broken up. Three separate taxes follow from that. The sequence length of every document bloats, so in the attention of the Transformer those tokens run out of context sooner. All the non English text is stretched out from the Transformer's point of view. And translating it back to English would be significantly fewer tokens for the identical meaning.
Then Python, which is the single most vivid frame in the first fifteen minutes. He pastes in a snippet of Python doing FizzBuzz and points at the indentation. Every individual space is its own token. Token 220, over and over: 220, 220, 220, 220. Then " if" as one token. So when the Transformer consumes that text, all those spaces feed in one by one through the whole sequence. "This is being extremely wasteful, tokenizing it in this way." And as a result GPT-2 is not very good with Python, and it has nothing to do with coding or the language model itself. It is that if you indent with spaces in Python the way everybody does, you bloat the text out and shred it across far too much of the sequence, and you run out of context length.
Then the fix, live. He scrolls up and switches the tokenizer from gpt2 to cl100k_base, which is the GPT-4 tokenizer. The same string that was 300 tokens drops to 185 tokens. Roughly half, and roughly because the GPT-4 vocabulary is roughly double the GPT-2 one: about 50,000 to about 100,000. That is a good thing, because the same text is now squished into half as many tokens, which is a denser input, and because every token attends to a finite number of tokens before it, you are now able to see roughly twice as much text as context when predicting what comes next.
But not infinitely good, and he flags the trade off here before coming back to it an hour and a half later. As you increase the number of tokens, your embedding table gets a lot larger, and at the output you are trying to predict the next token, so the softmax grows as well. "There's some kind of a sweet spot somewhere, where you have a just right number of tokens in your vocabulary, where everything is appropriately dense and still fairly efficient."
The specific GPT-4 improvement he wants noticed is whitespace. In the same Python snippet, four spaces are now represented as one single token. Then a token for the next three spaces, then " if". Seven spaces elsewhere get grouped into a single token. "This was a deliberate choice made by OpenAI when they designed the GPT-4 tokenizer." What it does is densify Python, so the model can attend to more code before it when predicting the next token. Which means the improvement in Python coding ability from GPT-2 to GPT-4 is not purely a matter of the language model, the architecture and the optimization. A real part of it is the design of the tokenizer and how it groups characters into tokens.
Strings in Python are sequences of Unicode code points
Now to code. The goal restated: take strings, turn them into integers in some fixed vocabulary, use those integers to look up vectors in a table, and feed those vectors into the Transformer. The reason it gets tricky is that we do not just want the simple English alphabet. We want Korean, where 안녕하세요 is hello. We want every special character you might find on the internet, emoji included.
So what is a string in Python anyway? Go to the documentation and strings are immutable sequences of Unicode code points.
What is a Unicode code point? It is defined by the Unicode Consortium as part of the Unicode standard, which is a definition of roughly 150,000 characters across 161 scripts, what they look like, and which integers represent them. The standard is very much alive: the latest version at recording time is 15.1, from September 2023.
You access the code point of a single character in Python with ord. ord("h") is 104. It can get arbitrarily larger: the emoji he tries comes back as 128,000 and a Korean character comes back around 50,000. You cannot pass a whole string, because ord takes a single character and returns its integer. But you can map it across a string, [ord(x) for x in "some string"], and get the full list of code points.
So why not just use the code points and skip tokenization entirely
A fair question, and he asks it directly: we already have integers here, so why not use them natively as the vocabulary and have no tokenization at all?
Two reasons. First, the vocabulary would be quite long: 150,000 different code points. Second, and more worryingly, the Unicode standard is alive and keeps changing, so it is not a stable representation to build a model on. A vocabulary you have to revise when the Consortium ships a new version is not a vocabulary.
Unicode byte encodings: UTF-8, UTF-16, UTF-32
Something better means encodings. The Unicode Consortium defines three: UTF-8, UTF-16 and UTF-32. These are the ways you take Unicode text and translate it into binary data, into byte streams.
UTF-8 is by far the most common. It takes every single code point and translates it to a byte stream of between one and four bytes, so it is a variable length encoding: depending on the code point, you end up with one to four bytes according to the schema. UTF-32 is nice in that it is fixed length rather than variable length, but it has many other downsides. The full spectrum of pros and cons across the three is out of scope, but he points at a blog post he enjoyed, A Programmer's Introduction to Unicode, whose reference list at the end includes the UTF-8 Everywhere Manifesto. That manifesto makes the case for why UTF-8 is significantly preferred and a lot nicer than the alternatives, and why it dominates the internet. The one advantage he calls out to give you a sense of it: UTF-8 is the only one of the three that is backwards compatible with the much simpler ASCII encoding of text.
Then he does it in the notebook. Python strings have .encode(), so "...".encode("utf-8") gives you a bytes object, which prints badly, so he wraps it in list() to get the raw bytes as integers. Those are the actual bytes representing the string under UTF-8.
Run the same string through UTF-16 and you get a slightly different byte stream, and the disadvantage shows up immediately: zero, something, zero, something, zero, something. For simple ASCII or English characters you get that wasteful alternating structure throughout. UTF-32 is worse in the same way, a lot of zeros followed by something. "So this is not desirable."
So UTF-8 it is. But if we use UTF-8 naively, these are byte streams, which implies a vocabulary of only 256 possible tokens. And that is very, very small. What it would do is stretch all of our text out over very, very long sequences of bytes. The embedding table would be tiny and the final prediction layer would be tiny, which is fine, but the sequences would be enormous, and we have a finite context length and finite attention budget for computational reasons. So we would never attend to enough text before us to predict the next token well.
The requirement is now precise. We want to keep the UTF-8 encoding of strings, and we want a vocabulary size larger than 256 that we can tune as a hyperparameter. The answer is byte pair encoding, which compresses those byte sequences by a variable amount.
The daydream: deleting tokenization
Before building it, he takes a detour he clearly wishes were the main road. "I would love nothing more than to be able to feed raw byte sequences into language models."
There is a paper on how that could potentially be done, from the summer before recording: MEGABYTE: Predicting Million-byte Sequences with Multiscale Transformers. The problem is that you have to go in and modify the Transformer architecture, because attention becomes extremely expensive when the sequences are that long. So they propose a hierarchical structuring of the Transformer that would let you feed in raw bytes. Their conclusion, which he reads out: "Together, these results establish the viability of tokenization free autoregressive sequence modeling at scale."
"Tokenization free would indeed be amazing. We would just feed byte streams directly into our models." But he does not believe it has been proven out yet by enough groups at enough scale. He hopes someone comes up with it. For now we come back and compress with BPE.
The byte pair encoding algorithm, by hand
The Wikipedia page is "actually quite instructive as far as the basic idea goes," so he walks its example rather than inventing one. The toy vocabulary has only four elements, a, b, c and d, and the input sequence is aaabdaaabac, which is eleven symbols long. Too long, and we would like to compress it.
The loop is three steps:
- Iteratively find the pair of tokens that occurs most frequently.
- Replace every occurrence of that pair with a single new token, appended to the vocabulary.
- Repeat.
Run it. The pair aa occurs most often, so we mint a new token, call it Z, and replace every occurrence of aa with Z. A sequence of 11 characters with a vocabulary of 4 is now a sequence of 9 tokens with a vocabulary of 5, because we created a fifth element standing for the concatenation of aa.
Repeat. Now the most frequent pair is ab, so we mint Y, and Y stands for ab. Seven tokens, vocabulary of six.
Repeat one more time. The most frequent pair is now ZY, which is a pair of minted tokens, so the merge is on top of previous merges. Mint X for ZY. Five tokens, vocabulary of seven. We went from 11 tokens over a vocabulary of 4 to 5 tokens over a vocabulary of 7, and that is the entire trade.
aaabdaaabac that opens minbpe's quick start. Sequence length falls 11, 9, 7, 5 while vocabulary size rises 4, 5, 6, 7. Minted tokens are themselves eligible for merging, which is why the third merge is a pair of merges, and why the result is not a tree.The real thing works exactly the same way with a different starting point. "In the exact same way, we start out with byte sequences, so we have 256 vocabulary size, but we're now going to go through these and find the byte pairs that occur the most, and we're going to iteratively start minting new tokens, appending them to our vocabulary and replacing things." The output is two things: a compressed training dataset, and an algorithm for encoding any arbitrary sequence into that vocabulary and decoding it back to strings.
Starting the implementation: 533 code points, 616 bytes
He takes the first paragraph of a blog post he enjoyed, pastes it into the notebook as one very long line, and encodes it to UTF-8. The tokens at this point are a raw stream of bytes, and to make them easier to manipulate in Python he converts every byte to an integer and wraps the whole thing in a list.
The numbers matter here because they make the UTF-8 expansion concrete. The original paragraph is 533 code points long. Encoded to UTF-8 it is 616 bytes, so 616 tokens at this stage. The reason it grew is exactly the variable length encoding: simple ASCII characters become a single byte each, but the more complex Unicode characters in the paragraph become multiple bytes, up to four.
get_stats: counting every consecutive pair
First step of the algorithm: find the pair of bytes that occurs most frequently, because that is the pair we are going to merge. He stops and tells the reader to write the function themselves if they are following along in a notebook, then pastes his own.
He calls it get_stats. It takes a list of integers, uses a dictionary to keep the counts, iterates consecutive elements of the list with the Pythonic zip(ids, ids[1:]) trick from the previous video, and increments by one for every pair it sees. The keys come out as tuples of consecutive elements and the values are counts.
To read the result he sorts it in a slightly compound way worth pausing on: iterate stats.items(), which returns key value pairs, build a list of (value, key) instead so that Python's default sort uses the count first, and reverse it so it descends.
Top of the list: (101, 32), which occurred 20 times. He double checks by searching the token list for 101, 32 and finds exactly 20 occurrences. Then he asks what the pair actually is, using chr, the opposite of ord. chr(101) is e and chr(32) is a space. So the single most common byte pair in this English paragraph is "e followed by a space," which means a great many words in it end in the letter e.
merge: replacing the pair, and the bound check that bites
Now mint a new token. The existing tokens run from 0 to 255, so the new one gets id 256, and we iterate the whole list swapping every 101, 32 for 256.
His merge function takes a list of ids, the pair to replace, and the new index idx. It builds a new list, walks the old one left to right, and at each position checks whether the current element and the next one match the pair.
And here is the careful bit, the thing he flags explicitly: "here is a bit of a tricky condition that you have to append if you're trying to be careful, and that is that you don't want this here to be out of bounds at the very last position." If you are standing on the rightmost element of the list and you look at ids[i+1], you get an index error. So the condition has to also assert you are not at the very last element. On a match you append the replacement index and advance the position by two, skipping the whole pair. Otherwise you copy the element at that position and advance by one.
He tests it on a toy case first: the list [5, 6, 6, 7, 9, 1] with the pair (6, 7) replaced by 99 gives back the list with the 6 and the 7 collapsed into a single 99. Then he runs it for real, merging the top pair into 256.
The verification is clean. The list was length 616. It is now length 596, a decrease of exactly 20, which makes sense because there were 20 occurrences. Search the new list for 256 and you find plenty of them. Search it for 101, 32 and there are none, where the original had plenty. One pair successfully merged.
The training loop: 20 merges, a forest rather than a tree, and 1.27x
Before the loop he swaps the training text for something bigger: instead of the first paragraph he takes the entire blog post, stretched out into a single line, because longer text gives more representative statistics for the byte pairs and a more sensible result.
Then the loop. First decide the final vocabulary size, which is the hyperparameter. He picks 276, because 256 raw byte tokens plus 20 merges is 276, and 20 merges is a comfortable number to look at. He copies the token list with list(tokens) and creates a merges dictionary that will hold the mapping from (child one, child two) to the new token id.
A nice aside on what that dictionary describes. "What we're going to be building up here is a binary tree of merges, but actually it's not exactly a tree, because a tree would have a single root node with a bunch of leaves. For us we're starting with the leaves on the bottom, which are the individual bytes, those are the starting 256 tokens, and then we're starting to merge two of them at a time. And so it's not a tree, it's more like a forest."
Twenty iterations: find the most common pair, mint the next integer starting at 256, print the merge, replace every occurrence, record the pair in merges. The first merge is the one we already did by hand, (101, 32) becoming 256.
Two properties of that output he wants understood. The individual tokens 101 and 32 can still occur in the sequence after the merge. It is only when they occur exactly consecutively that they become 256. And the newly minted token 256 is itself eligible for merging: the twentieth merge in his run consumes token 259, which was itself produced by an earlier merge, to produce token 275. "Every time we replace these tokens they become eligible for merging in the next round of iteration. So that's why we're building up a small sort of binary forest instead of a single individual tree."
Then the compression ratio. He started with 24,000 bytes and after 20 merges has about 19,000 tokens. Divide the two and the compression ratio is roughly 1.27x, achieved with 20 merges. More vocabulary elements, more compression.
The tokenizer is a completely separate stage from the model
This is the point he stops and draws a diagram for, because it is the thing people blur most often.
"The tokenizer is a completely separate object from the large language model itself. Everything in this lecture, we're not really touching the LLM itself. We're just training the tokenizer. This is a completely separate pre processing stage."
What that means concretely:
- The tokenizer has its own training set of documents, potentially completely different from the language model's training set.
- You run byte pair encoding on that set to train the vocabulary.
- This happens once, at the beginning, as a pre processing stage.
- Once it is trained you have the vocabulary and the merges, and with them you can go in both directions: raw text to token sequence, and token sequence back to raw text. The tokenizer is a translation layer between the two realms.
- In a state of the art application you take all of the language model's training data, run it through the tokenizer, translate everything into one massive token sequence, and then throw away the raw text. What sits on disk is tokens, and that is what the language model reads when it trains.
And because the tokenizer's training set is its own choice, the mixture in it is a real design decision with a real downstream effect. "If you add some amount of data, like say you have a ton of Japanese data in your tokenizer training set, then that means that more Japanese tokens will get merged, and therefore Japanese will have shorter sequences, and that's going to be beneficial for the large language model, which has a finite context length." The same logic applies to code against prose. How much of a language is in the tokenizer's corpus determines the density that language gets in token space.
Hold onto that, because it is also the entire explanation of SolidGoldMagikarp two hours later.
decode: tokens back to text, and the invalid start byte
With merges trained, both directions become implementable. He starts with decode: given a list of integers, return a Python string.
His solution builds a preprocessing variable called vocab, a dictionary mapping token id to the bytes object for that token. The first 256 entries are the raw bytes for ids 0 through 255. Then he walks the merges in order and populates the rest by concatenating the bytes of the first child with the bytes of the second child. These are bytes objects, so the + here is bytes concatenation.
One tricky thing he flags: he iterates the dictionary with .items(), and it genuinely matters that this runs in the order the items were inserted into merges. "Luckily, starting with Python 3.7 this is guaranteed to be the case, but before Python 3.7 this iteration may have been out of order with respect to how we inserted elements into merges, and this may not have worked."
Then the function itself: iterate the ids, look up each one's bytes in vocab, join them all with b"".join(...) to get the raw byte string, and call .decode("utf-8") on it. Where encoding went from string to bytes, this goes from bytes back to string.
And then the bug, which he deliberately shipped so he could show it. Decode [97] and you get a, nothing crazy. Decode [128] as a single element and Python raises:
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x80 in position 0: invalid start byte
To understand that you go back to the UTF-8 page. Multi byte characters have to carry a specific envelope: the leading byte announces how many bytes follow with a prefix of ones, and the continuation bytes each start 10. The binary representation of 128 is a one followed by all zeros, and that fits none of the rules. A leading 1 must be followed by another 1 and then a 0, and then the content. So 128 standing alone is an invalid start byte and cannot be decoded.
The fix is the errors argument of bytes.decode. The default is errors="strict", which raises. Switch it to errors="replace" and you get back the Unicode replacement character instead of an exception.
Why this matters in production: "not every single byte sequence is valid UTF-8, and if it happens that your large language model, for example, predicts your tokens in a bad manner, then they might not fall into valid UTF-8, and then we won't be able to decode them." So the standard practice is errors="replace", which is also what you find in the code OpenAI released. And the operational tell for you as a user: whenever you see that replacement character in an output, "something went wrong and the LLM output was not a valid sequence of tokens."
encode: text to tokens, and the lowest index merge
Now the other direction. Given a string, produce a list of token integers.
Start the same way: encode the text to UTF-8, call list() on the bytes to get a list of integer bytes. Those are the starting tokens, the raw bytes of the sequence. But some of them need merging, according to the merges dictionary.
And the order is not optional. "Merges was built from top to bottom, and this is sort of the order in which we inserted stuff into merges, and so we prefer to do all these merges in the beginning before we do these merges later, because for example this merge over here relies on the 256 which got merged here." Later merges are defined in terms of earlier ones, so you must apply them in the order they were learned.
The loop reuses get_stats from training, but for a different purpose. "At this point we don't actually care how many times they occur in the sequence, we only care what the raw pairs are in that sequence." He uses only the dictionary's keys, as the set of candidate merges present in the text.
Then the selection, which is the one genuinely clever line in the implementation. He wants the pair in stats that has the lowest index in merges, because early merges must happen before late ones. He does it with min over the dictionary's keys with a key function that looks each pair up in merges and returns its index:
pair = min(stats, key=lambda p: merges.get(p, float("inf")))
The float("inf") fallback is the part worth dwelling on. If a pair in the token sequence is not a merging pair at all, it has no index in merges, so it is not eligible, and infinity guarantees it loses every comparison and never gets selected. "The reason infinity is nice here is because for sure we're guaranteed that it's not going to participate in the list of candidates when we do the min."
But that leaves a failure mode he catches out loud. If there is nothing left to merge, every candidate evaluates to infinity, and min returns whichever pair happens to come first in stats, arbitrarily. That pair is not actually mergeable. So the check after the min is a test for exactly that: "if this pair is not in merges that was returned, then this is a signal for us that actually there was nothing to merge, no single pair can be merged anymore." In that case, break.
If the pair is in merges, look up its index and call merge(tokens, pair, idx), which returns a new list with every occurrence replaced. Loop until nothing can be merged, then return the tokens. He checks the output and spots 32 in there, which is a space in ASCII, so it looks right.
Then he comes back for the edge case he left out. "This is not quite the right implementation just yet, because we are leaving out a special case." If the input is a single character or an empty string, stats is empty, and that blows up inside min. So the guard is a length check: run the loop only while len(tokens) >= 2, because with fewer than two tokens there is nothing to merge and you can just return.
The round trip tests, and the one direction that does not hold
Two test cases, and the second one is the interesting one.
Take a string, encode it, decode it back, and you expect the same string. Is that true for all strings? In his tests it holds, and he believes it holds in general. He checks it on the training text the tokenizer was trained on, and then on a piece of validation text he grabbed from a web page the tokenizer has never seen, and both round trip correctly. "That gives us some confidence that this was correctly implemented."
But the reverse direction is not an identity, and he is explicit about why: "going backwards is not, you're not going to have an identity going backwards, because as I mentioned, not all token sequences are valid UTF-8 byte streams, and so therefore some of them can't even be decodable." Decode then encode is not guaranteed to give you back your tokens. Encode then decode is.
That closes out the basic tokenizer. "The parameters of this tokenizer really are just this dictionary of merges, and that basically creates the little binary forest on top of raw bytes." With the merges table you can encode and decode between raw text and token sequences, and that is the complete object.
"What we're going to do now though is we're going to look at some of the state of the art large language models and the kinds of tokenizers that they use, and we're going to see that this picture complexifies very quickly."
The complete tokenizer, as code
Four functions and two dictionaries. This is the whole object, in the shape minbpe ships it, with his own comments kept where they explain a decision rather than a line.
The two helpers, which training and encoding both use:
def get_stats(ids, counts=None):
"""
Given a list of integers, return a dictionary of counts of consecutive pairs
Example: [1, 2, 3, 1, 2] -> {(1, 2): 2, (2, 3): 1, (3, 1): 1}
"""
counts = {} if counts is None else counts
for pair in zip(ids, ids[1:]): # iterate consecutive elements
counts[pair] = counts.get(pair, 0) + 1
return counts
def merge(ids, pair, idx):
"""
In the list of integers (ids), replace all consecutive occurrences
of pair with the new integer token idx
Example: ids=[1, 2, 3, 1, 2], pair=(1, 2), idx=4 -> [4, 3, 4]
"""
newids = []
i = 0
while i < len(ids):
# if not at the very last position AND the pair matches, replace it
if ids[i] == pair[0] and i < len(ids) - 1 and ids[i+1] == pair[1]:
newids.append(idx)
i += 2
else:
newids.append(ids[i])
i += 1
return newids
The i < len(ids) - 1 clause in merge is the out of bounds guard he stops to explain. Without it the last element of the list raises an index error, and only on inputs whose final element happens to be the first half of the pair, which is exactly the kind of bug that survives a casual test.
Training, which is the loop from the walkthrough with the bookkeeping added:
def train(self, text, vocab_size, verbose=False):
assert vocab_size >= 256
num_merges = vocab_size - 256
# input text preprocessing
text_bytes = text.encode("utf-8") # raw bytes
ids = list(text_bytes) # list of integers in range 0..255
# iteratively merge the most common pairs to create new tokens
merges = {} # (int, int) -> int
vocab = {idx: bytes([idx]) for idx in range(256)} # int -> bytes
for i in range(num_merges):
stats = get_stats(ids) # count every consecutive pair
pair = max(stats, key=stats.get) # the pair with the highest count
idx = 256 + i # mint the next available id
ids = merge(ids, pair, idx) # replace every occurrence
merges[pair] = idx # save the merge
vocab[idx] = vocab[pair[0]] + vocab[pair[1]]
if verbose:
print(f"merge {i+1}/{num_merges}: {pair} -> {idx} "
f"({vocab[idx]}) had {stats[pair]} occurrences")
self.merges = merges # used in encode()
self.vocab = vocab # used in decode()
Three things worth reading closely. num_merges = vocab_size - 256 is the arithmetic behind his choice of 276: twenty merges on top of the byte floor, which is also the arithmetic behind GPT-2's 50,257 with its 50,000 merges and one special token. max(stats, key=stats.get) is the idiom for "the key with the largest value," which is how the most frequent pair gets selected. And vocab[idx] = vocab[pair[0]] + vocab[pair[1]] is bytes concatenation, built up in merge order, which is the decoding table assembled for free during training rather than reconstructed afterwards.
Decoding, three lines:
def decode(self, ids):
# given ids (list of integers), return Python string
text_bytes = b"".join(self.vocab[idx] for idx in ids)
text = text_bytes.decode("utf-8", errors="replace")
return text
That errors="replace" is the fix for the invalid start byte. The default, errors="strict", raises on any byte sequence a language model emits that does not happen to be valid UTF-8, which is a crash in production triggered by a sampling accident.
Encoding, with the subtlety he spends several minutes on preserved in the comment:
def encode(self, text):
# given a string text, return the token ids
text_bytes = text.encode("utf-8") # raw bytes
ids = list(text_bytes) # list of integers in range 0..255
while len(ids) >= 2:
# find the pair with the lowest merge index
stats = get_stats(ids)
pair = min(stats, key=lambda p: self.merges.get(p, float("inf")))
# subtle: if there are no more merges available, the key will
# result in an inf for every single pair, and the min will be
# just the first pair in the list, arbitrarily
# we can detect this terminating case by a membership check
if pair not in self.merges:
break # nothing else can be merged anymore
# otherwise let's merge the best pair (lowest merge index)
idx = self.merges[pair]
ids = merge(ids, pair, idx)
return ids
The while len(ids) >= 2 is the empty string and single character guard. The float("inf") default is what makes an ineligible pair lose every comparison. And the membership check after the min is the one piece of this function you would never write unless you had hit the bug, because the failure is silent: min happily returns a pair that cannot be merged, and without the check you would merge it with whatever merges returns next.
Note what is not in here. No regex splitting, so this tokenizer will merge e with a following space and will produce dog. as a token given the chance. No special tokens. Those are the next two sections, and in minbpe they are the difference between BasicTokenizer and RegexTokenizer.
The regex that stops the merges nobody wants
Back to the GPT-2 paper, 2019, about five years before the recording, and specifically its input representation section. Everything in it matches what we just built, right up to one point, and then it departs.
The problem they describe is punctuation. Take a common word like dog. It occurs very frequently, and it occurs right next to all kinds of punctuation: dog., dog!, dog? and so on. Naively, BPE will happily merge all of those into single tokens, and you end up with a cluster of near duplicate tokens that are just "dog with a slightly different punctuation mark." As Karpathy puts it, "it feels like you're clustering things that shouldn't be clustered, you're combining kind of semantics with punctuation, and this feels suboptimal." The paper says their experiments agreed.
So what they do is enforce, top down and manually, that some types of characters should never be merged together, as a set of rules layered on top of byte pair encoding. The place that happens is encoder.py in the GPT-2 repository. Karpathy registers a complaint about the name in passing: "I don't personally love that they call it encoder.py, because this is the tokenizer, and the tokenizer can do both encode and decode, so it feels kind of awkward to me."
The core of it is one regex pattern that looks very complicated, and one import that is easy to miss. The file does not import re, it does import regex as re, where regex is a third party package you pip install that extends the standard library's re with more power, including the Unicode property classes this pattern depends on.
Here is the pattern as released:
self.pat = re.compile(r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""")
He copies it into the notebook and takes it for a spin with re.findall(pat, text), exactly as their code does. findall walks the string left to right, tries to match the pattern wherever it currently stands, and collects every occurrence into a list.
The first structural thing to see is that the pattern is "made up of a lot of ors," the vertical bars, so at each position you try the alternatives left to right and the first one that matches wins.
encoder.py, read the way Karpathy reads it at 57:36. Note it uses the third party regex package, not the standard library re, for the Unicode property classes \p{L} and \p{N}.Walk it on "Hello world how are you". At the start, Hello is not 's or 't or any of the contractions, but it is "an optional space followed by \p{L} one or more times." What is \p{L}? From the documentation he finds: a letter, any kind of letter from any language. Hello is made of letters, so the alternative matches, and then the match ends, because a whitespace is not a letter. From there a fresh attempt begins, the optional space matches, then the letters, so " world" comes out as the next element. Run findall on the whole thing and you get five elements: Hello, " world", " how", " are", " you".
Why does that matter? "Instead of directly encoding it for tokenization, we are first splitting it up." The text becomes a list of texts, all of these elements are processed independently by the tokenizer, and all of the results of that processing are simply concatenated. Which means, and this is the whole point, "you're only ever finding merges between the elements of this list." Merges can only ever be considered within one element. After all the merging is done per element, the results are joined.
The concrete consequence, in his words: "you are never going to be merging this e with this space, because they are now parts of separate elements of this list." Remember that the single most common pair in his own training text was (101, 32), which is exactly e followed by a space. That merge is now structurally impossible. "Using this regex pattern to chunk up the text is just one way of enforcing that some merges are not to happen."
And the high level intent across the whole pattern: do not merge across letters, across numbers, across punctuation.
\p{N} is "any kind of numeric character in any script," so numbers are separated out from letters. Feed it "Hello world 123 how are you" and world stops matching at the space before 1, because 1 is not a letter, and the numeric group picks it up as its own entity.
What the GPT-2 pattern gets wrong, in his own assessment
The apostrophes are where he stops being neutral. "Why are they doing the apostrophes here? Honestly I think that these are just very common apostrophes that are used. I don't love that they've done this."
Two separate problems. First, the apostrophe is hardcoded as the plain ASCII one. Write house's with a straight quote and the contraction rule splits it out cleanly. Write it with the Unicode typographic apostrophe instead and the rule does not fire, the apostrophe becomes its own thing, and you get different tokenization for what any human reads as the same text.
Second, case. In the GPT-2 docs, where they define the pattern, there is an acknowledgement:
"Should have added re.IGNORECASE so BPE merges can happen for capitalized versions of contractions."
Because they did not, these rules do not separate the apostrophe forms when the letters are uppercase. Lowercase house's splits one way; uppercase HOUSE'S suddenly has the apostrophe come out by itself. "So the tokenization will work differently in uppercase and lowercase, inconsistently separating out these apostrophes. So it feels extremely gnarly and slightly gross, but that's how that works."
And a third, broader worry: "these are quite language specific probably, so I don't know that all the languages for example use or don't use apostrophes, but that would be inconsistently tokenized as a result."
The whitespace alternative with the negative lookahead gets its own careful explanation, because the reason for it is subtle. \s+(?!\S) matches a run of whitespace up to but not including the last whitespace character. Why would you want that? Because the space is always included at the beginning of the word in this scheme, as in " r", " u" and so on. Suppose you have a long run of spaces followed by a word. This alternative catches all the spaces except the last one, so that the last one is free to come around and join with the word, producing " you" rather than stranding it. "The reason that's nice is because " you" is the common token." Add more spaces and you still get " you" at the end, plus a chunk of extra whitespace in front. "Basically the GPT-2 tokenizer really likes to have a space, letters or numbers, and it preens these spaces, and this is just something that it is consistent about."
He finishes with a real world example: feed the pattern a piece of Python code and the resulting list has many elements, because the split fires every time a category changes, "and so there will never be any merges within these elements."
The spaces OpenAI never merged, and the training code nobody released
Here is where he draws a line between what you can reconstruct and what you cannot.
You might assume OpenAI used this pattern to split text into chunks and then ran plain BPE within each chunk. That is not exactly what happened, and you can prove it from the outside. Notice that the runs of spaces in the Python example become whole elements of the list. Those spaces never actually end up merged by OpenAI. Paste the identical chunk into Tiktokenizer with the GPT-2 tokenizer selected and all the spaces are kept independent, all of them token 220. "So I think OpenAI at some point enforced some rule that these spaces would never be merged. There's some additional rules on top of just chunking and BPE that OpenAI is not clear about."
And the reason you have to infer it rather than read it: "The training code for the GPT-2 tokenizer was never released. All we have is the code that I've already shown you, but this code here that they've released is only the inference code for the tokens. This is not the training code. You can't give it a piece of text and train a tokenizer." You can apply their merges to new text. You cannot reproduce how they got those merges. "We don't know exactly how OpenAI trained the tokenizer, but it wasn't as simple as chunk it up and BPE it."
tiktoken, and exactly what GPT-4 changed
tiktoken is OpenAI's official tokenization library. pip install tiktoken, and like encoder.py it is inference only: it tokenizes, it does not train.
Usage is simple enough that he shows it in a few lines, getting GPT-2 tokens out of one encoding and GPT-4 tokens out of the other. And the difference shows up immediately on whitespace. In GPT-2 the runs of spaces remain unmerged. In GPT-4 they merge, which is the same behavior we saw in Tiktokenizer.
The reason is that the regex changed. Go into the installed library and open tiktoken_ext/openai_public.py, which is where the definitions of all the tokenizers OpenAI maintains live. "Necessarily, to do the inference, they had to publish some of the details about the strings."
What you find there for GPT-2 is a pattern with a comment above it saying the original release used the pattern we just read, and that this one "is equivalent, but executes faster." Then scroll down to cl100k_base, the GPT-4 tokenizer, and the pattern has genuinely changed:
# GPT-2 (gpt2, r50k_base), rewritten for speed
r50k_pat_str = r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}++| ?\p{N}++| ?[^\s\p{L}\p{N}]++|\s++$|\s+(?!\S)|\s"""
# GPT-4 (cl100k_base)
pat_str = r"""'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}++|\p{N}{1,3}+| ?[^\s\p{L}\p{N}]++[\r\n]*+|\s++$|\s*[\r\n]|\s+(?!\S)|\s"""
He declines to read the new one character by character. "I'm not going to actually go into the full detail of the pattern change, because honestly this is mind numbing. I would just advise that you pull out ChatGPT and the regex documentation and just step through it." But he names the three changes that matter.
One, case insensitivity. The (?i:...) group around the contractions is a case insensitive match. Which is precisely the fix for the complaint in the GPT-2 docs. "The comment that we saw earlier on, oh we should have used re.IGNORECASE, basically we're now going to be matching these apostrophe s, apostrophe d, apostrophe m, both in lowercase and in uppercase. So that's fixed."
Two, whitespace handling, which he does not detail but which is the change you can see in the Python example: runs of spaces now group into single tokens.
Three, a hard cap on digits. \p{N}{1,3} matches one to three numeric characters and no more. "They will never merge numbers that are more than three digits. Only up to three digits of numbers will ever be merged." Which exists to prevent tokens that are very long number sequences.
And then the honest caveat that he repeats several times in this stretch: "we don't really know why they do any of this stuff, because none of this is documented, and it's just we just get the pattern. So yeah, it is what it is."
Plus the headline number: the vocabulary went from roughly 50,000 to roughly 100,000.
| Setting | GPT-2 (gpt2, r50k_base) | GPT-4 (cl100k_base) |
|---|---|---|
| Vocabulary size | 50,257 (explicit_n_vocab in tiktoken) | Roughly 100,000, "roughly double" |
| Composition of the vocabulary | 256 raw byte tokens + 50,000 merges + 1 special = 50,257 | Not stated in the video, same construction |
| Context length | 1,024 tokens, up from GPT-1's 512 | Not covered here |
| Contraction handling | lowercase only, and the docs admit "should have added re.IGNORECASE" | case insensitive via '(?i:[sdmt]|ll|ve|re) |
| Digit runs | Uncapped in the pattern, so merges of long digit strings happen by chance | capped at three by \p{N}{1,3} |
| Whitespace in code | one token per space, all of them token 220, which never merge | runs grouped: four spaces become a single token, seven spaces become a single token |
| His test string | 300 tokens | 185 tokens for the identical text |
| Special tokens | 1: <|endoftext|> at id 50256 | 5: <|endoftext|> 100257, <|fim_prefix|> 100258, <|fim_middle|> 100259, <|fim_suffix|> 100260, <|endofprompt|> 100276 |
| Training code released | no, inference only, so the merges cannot be reproduced | no, inference only |
| Rationale documented | no | no, reverse engineered from the pattern |
| First merge in training | Not shown | Two spaces into one token, which becomes token 256 |
tiktoken_ext/openai_public.py and the GPT-2 paper. The green cells are the three deliberate fixes; the amber ones are the things still undocumented. The whitespace row is most of the reason GPT-4 writes better Python than GPT-2.Reading encoder.py: their encoder is our vocab, their vocab.bpe is our merges
A brief but genuinely useful walkthrough, because it makes the mapping from the lecture's code to OpenAI's released code explicit.
At the bottom of encoder.py they load two files, encoder.json and vocab.bpe, do some light processing on them, and call the resulting object encoder, which is the tokenizer. Together those two files are the saved tokenizer, and you can download and inspect them yourself.
The translation:
- Their
encoderis exactly ourvocab. Ours maps integer to bytes; theirs is inverted and maps the other way "for no amazing reason." - Their
vocab.bpe, "confusingly," is actually ourmerges.
"Using just these two variables you can represent a tokenizer, and you can both do encoding and decoding once you've trained this tokenizer." Our two critical variables and their two files are the same two things.
One thing in their file is a dead end, and he says so rather than pretending it is deep. In addition to the encoder and decoder they also have a byte_encoder and a byte_decoder, "and this is actually unfortunately just kind of a spurious implementation detail and isn't actually deep or interesting in any way." It is a whole separate layer applied serially with the tokenizer: byte encode, then encode, then decode, then byte decode. "It's not that interesting so I won't cover it, and you can step through it if you'd like."
Ignore those two and the file becomes familiar. The meat is their bpe function, and you should recognize its loop: it identifies the bigram, the pair, that should be merged next, then a for loop goes over the sequence merging that pair wherever it finds it, repeating until it runs out of possible merges. Plus an encode and a decode just like ours. "Unfortunately it's a little bit of a messy code that they have, but algorithmically it is identical to what we've built up above."
Special tokens, and the attack surface they create
Beyond tokens that come from raw bytes and BPE merges, you can insert tokens of your own to delimit parts of the data or to give the token stream structure.
Start with the arithmetic of GPT-2's vocabulary, because it resolves cleanly. The length of their encoder object is 50,257. There are 256 raw byte tokens. OpenAI did 50,000 merges. That is 50,256. So what is the last one?
One special token, and you can see its name in the file: <|endoftext|>, id 50256, the very last token in the vocabulary. Its job is to delimit documents in the training set. You tokenize all your documents, you get a stream of tokens in the range 0 to 50,255, and between documents you insert 50256. "We are using this as a signal to the language model that the document has ended, and what follows is going to be unrelated to the document previously."
With the caveat that nothing enforces this. "The language model has to learn this from data. It needs to learn that this token usually means that it should wipe its sort of memory of what came before, and that what came before this token is not actually informative to what comes next. But we are expecting the language model to just learn this."
He demonstrates it in Tiktokenizer. Type the characters of <|endoftext|> one at a time and you get a run of ordinary tokens for the individual pieces, right up until the string is complete, at which point the whole thing collapses into token 50256. And the reason that works is important: "this didn't actually go through the BPE merges. Instead the code that actually outputs the tokens has special case instructions for handling special tokens." Those instructions are not in encoder.py. They are in the tiktoken library, which is implemented in Rust, where you find "all kinds of special case handling for these special tokens that you can register, create, add to the vocabulary, and then it looks for them, and whenever it sees these special tokens like this it will actually come in and swap in that special token. So these things are outside of the typical algorithm of byte pair encoding."
Special tokens get used far more heavily once you leave base language modelling. Refresh Tiktokenizer and the default example uses a fine tuned model's scheme rather than a base model's: pick GPT-3.5-turbo and you see <|im_start|> and <|im_end|> and friends, marking the start and end of every single message in a conversation. "This is short for imaginary monologue start, by the way." Many more tokens are in use there to delimit conversations and keep track of the flow of messages, because you no longer just want to delimit documents, you want to delimit entire conversations between an assistant and a user.
tiktoken documents how to extend it: you can fork cl100k_base, add more special tokens of your own invention with new ids, and the library will correctly swap them in when it sees those strings. Looking at the same definitions file, GPT-2 registers exactly one special token, the end of text. GPT-4 registers five: end of text, three FIM tokens, and one more. FIM is short for fill in the middle, from this paper, which he flags as beyond the scope of the video.
But adding a special token is not free, and this is the part people skip. "When you add special tokens you of course have to do some model surgery to the Transformer and all the parameters involved in that Transformer." Specifically:
- The embedding matrix for the vocabulary has to be extended by a row, and that row is typically initialized with small random numbers, because you need a vector standing for that token.
- The final layer of the Transformer, the projection into the classifier, has to be extended by one as well.
"So basically there's some model surgery involved that you have to couple with the tokenization changes, if you are going to add special tokens. But this is a very common operation that people do, especially if they'd like to fine tune the model, for example taking it from a base model to a chat model like ChatGPT."
Your turn: the minbpe exercise
At this point, he says, you have everything you need to build your own GPT-4 tokenizer. He did exactly that while developing the lecture and published the code as minbpe, alongside exercise.md, which breaks the task into four steps that build up to a GPT-4 tokenizer. "Anytime you feel stuck, just reference the minbpe repository. Either the tests could be useful or the repository itself. I try to keep the code fairly clean and understandable."
The acceptance test is behavioral: once you write it, you should be able to reproduce tiktoken's GPT-4 behavior, encoding a string to exactly the right token ids and decoding it back to recover the original. And then one more thing tiktoken cannot do for you: "you should be able to implement your own train function, which tiktoken library does not provide. It's again only inference code. But you could write your own. minbpe does it as well, and that will allow you to train your own token vocabularies."
Then the comparison that makes this section worth the time. Inside minbpe he visualizes two vocabularies side by side. On the left, the GPT-4 merges: the first 256 entries are raw individual bytes, and then the merges GPT-4 actually performed during its training, in order. "The very first merge that GPT-4 did was merge two spaces into a single token, for you know two spaces, and that is token 256."
On the right, the merge order he obtained by training minbpe himself. His training text was the Wikipedia page for Taylor Swift, shipped in the repo as tests/taylorswift.txt, "not because I'm a Swifty, but because that is one of the longest Wikipedia pages apparently that's available. But she is pretty cool."
The two vocabularies line up remarkably well. GPT-4 merged i and n to become in; minbpe did the same thing. His token 259 is " t"; GPT-4 made the same merge a little earlier. "The difference here is, to my understanding, only a difference of the training set. As an example, because I see a lot of whitespace, I suspect that GPT-4 probably had a lot of Python code in its training set, I'm not sure, for the tokenizer, and here we see much less of that of course in the Wikipedia page. So roughly speaking they look the same, and they look the same because they're running the same algorithm."
SentencePiece, and why the open models behave differently
SentencePiece is the other library in wide use, and the reason it matters is that unlike tiktoken it can do both training and inference, efficiently. It supports a number of algorithms for training vocabularies, one of which is the byte pair encoding we have been building. It lives on GitHub under Google, and it is what Llama and the Mistral series use, along with many other models.
The big difference is subtle enough that he insists on working an example, because it is hard to explain in the abstract: the order of operations is reversed.
- tiktoken: take the code points in the string, encode them to bytes with UTF-8, then merge bytes.
- SentencePiece: work directly on the level of the code points themselves. Look at whatever code points occur in your training set and start merging those.
So BPE runs on code points. And then what happens when you meet a code point you have never seen? That is governed by two settings. Rarity is determined by the character_coverage hyperparameter. Rare code points either get mapped to a special unknown token, <unk>, or, if the byte_fallback option is turned on, they get encoded with UTF-8 and the individual bytes of that encoding are translated into tokens, using special byte tokens that get added to the vocabulary for exactly this purpose.
"So it uses BPE on the code points and then it falls back to bytes for rare code points. Personally I find the tiktoken way significantly cleaner, but it's a subtle but pretty major difference between the way they approach tokenization."
The configuration surface, and the baggage in it
He imports SentencePiece, takes the description of SentencePiece itself as a toy dataset, and writes it to toy.txt, because the library "really likes to have a file."
And then the honest reaction: "what's kind of a little bit crazy about SentencePiece is that there's a ton of options and configurations." The reason is lineage. SentencePiece has been around a while and tries to handle a large diversity of things, "and because it's been around I think it has quite a bit of accumulated historical baggage as well." The training options page is long, and the raw protobuf for the trainer spec is actually the more useful documentation.
Many of the options are simply irrelevant to BPE. His example: --shrinking_factor "is not used in the byte pair encoding algorithm, so this is just an argument that is irrelevant to us. It applies to a different training algorithm."
What he does instead is reverse engineer a known good configuration. "I tried to set up SentencePiece in a way that is very, very similar, as far as I can tell maybe identical hopefully, to the way that Llama 2 was trained." The method: take the tokenizer.model file Meta released, open it with the generated protobuf, inspect all the options, and copy over everything that looked relevant.
The settings that result, and what each category is for:
- Input and output. Raw text in
toy.txt, outputtok400.modelandtok400.vocab,model_typeofbpe,vocab_sizeof 400. - Normalization rules, which he turns off as far as he can. "Normalization used to be very prevalent, I would say before LLMs, in natural language processing. So in machine translation and text classification and so on, you want to normalize and simplify the text, and you want to turn it all lowercase, and you want to remove all double whitespace. And in language models we prefer not to do any of it, or at least that is my preference as a deep learning person. You want to not touch your data, you want to keep the raw data as much as possible in a raw form."
- The concept of sentences, which gets the sharpest criticism in the section. SentencePiece has options for how many sentences you train on, the maximum sentence length, whether to shuffle sentences. "For it, sentences are kind of like the individual training examples. But again in the context of LLMs I find that this is a very spurious and weird distinction. Sentences are just, don't touch the raw data. Sentences happen to exist but in raw datasets there are a lot of, like, what exactly is a sentence, what isn't a sentence. I think it's really hard to define what an actual sentence is if you really dig into it, and there could be different concepts of it in different languages. So why even introduce the concept? It doesn't honestly make sense to me. I would just prefer to treat a file as a giant stream of bytes."
- Rare character treatment, via character coverage, where "when I say word I mean code points."
- Merge rules for splitting digits and whitespace and numbers. "I think this is a little bit equivalent to tiktoken using the regular expression to split up categories. There's kind of an equivalence of it in SentencePiece, where you can also for example split up the digits."
- Special tokens, which are hardcoded: the
unktoken, beginning of sentence, end of sentence, and a pad token. "The unk token must exist, from my understanding."
What the trained vocabulary actually looks like
He trains, loads tok400.model, and inspects the vocabulary, and the ordering is itself informative. SentencePiece lays out its vocabulary in four bands, in this order:
- Special tokens.
<unk>with id 0, then beginning of sequence as 1 and end of sequence as 2. He setpad_idto negative one, so there is no pad token in this run. - The 256 byte tokens, present because
byte_fallbackwas true in Llama, with their ids. - The merge tokens. "These are the parent nodes in the merges, so we're not seeing the children, we're just seeing the parents and their id."
- The individual code point tokens, which come last. These are every code point that occurred in the training set, minus the ones that were extremely rare as determined by character coverage. "If a code point occurred only a single time out of like a million sentences or something like that, then it would be ignored and it would not be added to our vocabulary."
Then he encodes a test string that mixes English and Korean, and gets back both ids and the decoded pieces. Two things jump out.
The Korean characters were not part of the training set, so SentencePiece is meeting code points it has never seen, and they have no token. They would be <unk> tokens. But because byte_fallback is true, it instead encodes them with UTF-8 and uses the byte tokens to represent those bytes. You can see the UTF-8 encoding in the id stream, shifted by three, because of the three special tokens occupying ids 0, 1 and 2 ahead of the byte band.
To prove what the fallback is doing, he turns it off and retrains. All the byte tokens disappear from the vocabulary, and the merge band grows, "because we're not taking up space in the vocab size with all the bytes." Then he encodes the same string and the entire Korean portion collapses to a single 0, because unknown is token zero. "You have to keep in mind that this would feed into your language model. So what is a language model supposed to do when all kinds of different things that are unrecognized, because they're rare, just end up mapping into unk? It's not exactly the property that you want. So that's why I think Llama correctly used byte fallback true, because we definitely want to feed these unknown or rare code points into the model in some manner."
The dummy prefix, and the bold underscore
Two more quirks in the decoded output.
Spaces come back as a bold underscore character rather than a space. "I'm not 100% sure, by the way, why SentencePiece switches whitespace into these bold underscore characters. Maybe it's for visualization, I'm not 100% sure why that happens."
And then: why is there an extra space in front of hello? That comes from add_dummy_prefix, which is true. The documentation says it adds whitespace at the beginning of the text in order to treat world and hello world the same way.
What it is fighting is real. Go back to Tiktokenizer and world as a token by itself has a different id than " world": 1917 against 14. Two different tokens for the language model, which then has to learn from data that they are nearly the same concept. "So to the language model in the tiktoken world, basically words in the beginning of sentences and words in the middle of sentences actually look completely different, and it has to learn that they are roughly the same." The dummy prefix fights that by prepending a space during preprocessing, so both become " world". Llama 2 uses this option too.
His verdict on SentencePiece
He states it plainly as a four part summary:
- "There's a lot of historical baggage in SentencePiece, a lot of concepts that I think are slightly confusing and potentially contain foot guns, like this concept of a sentence and its maximum length."
- "Otherwise it is fairly commonly used in the industry, because it is efficient and can do both training and inference."
- "It has a few quirks, like for example unk token must exist, and the way the byte fallbacks are done, and so on. I don't find particularly elegant."
- "Unfortunately I have to say it's not very well documented, so it took me a lot of time working with this myself, just visualizing things and trying to really understand what is happening here."
And the practical instruction that follows from all of that: if you want your tokenization to look identical to Meta's Llama 2, copy paste these settings, exactly as he did. Do not reason it out from the documentation.
How to set the vocabulary size
Now the architectural question he deferred at the thirteen minute mark, answered properly. For this he goes back to gpt.py, the Transformer built in the previous video, and asks where vocab_size actually appears. In that file it was 65, an extremely small number, and it will grow a lot. But it barely appears in most of the layers. There are exactly two places:
The token embedding table. A two dimensional array where vocab_size is the number of rows and each row has n_embd entries, the number of channels in the Transformer. Each vocabulary element gets a vector trained by back propagation. As vocab_size grows, this table grows by rows.
The lm_head linear layer at the very end of the Transformer, which produces the logits that become the probabilities for the next token. "Intuitively we're trying to produce a probability for every single token that might come next at every point in time, and so if we have more and more tokens we need to produce more and more probabilities. So every single token is going to introduce an additional dot product that we have to do in this linear layer."
So why can vocab_size not be infinite? He gives three reasons, and the third is the one people miss.
One, compute. The embedding table grows and the lm_head layer grows, so you do a lot more computation at the output and the final layer becomes more expensive.
Two, undertrained parameters. "If you have a very large vocabulary size, say we have a million tokens, then every one of these tokens is going to come up more and more rarely in the training data, because there's a lot more other tokens all over the place. And so we're going to be seeing fewer and fewer examples for each individual token, and you might be worried that basically the vectors associated with every token will be undertrained as a result, because they just don't come up too often and they don't participate in the forward backward pass."
Three, over squishing. As vocabulary grows, sequences shrink, which is good because you attend to more text. "But also you might be worrying that too large of chunks are being squished into single tokens, and so the model just doesn't have as much time to think per some number of characters in the text. Basically we're squishing too much information into a single token, and then the forward pass of the Transformer is not enough to actually process that information appropriately."
His bottom line: "this is mostly an empirical hyperparameter, and it seems like in state of the art architectures today this is usually in the high 10,000s or somewhere around 100,000 today."
Extending the vocabulary of a model that is already trained
Common operation, mild surgery, and he lays out the recipe.
Why you would do it: when you fine tune for a chat model "a lot more new special tokens get introduced on top of the base model to maintain the metadata and all the structure of conversation objects between a user and an assistant, so that takes a lot of special tokens. You might also try to throw in more special tokens for example for using the browser or any other tool. And so it's very tempting to add a lot of tokens for all kinds of special functionality."
How you do it, which is the same surgery as before stated as a procedure:
- Resize the embedding, adding rows for the new tokens.
- Initialize those new parameters from scratch, as small random numbers.
- Extend the weight inside the final linear layer, so dot products get computed for the new tokens and probabilities can be produced for them.
"So both of these are just a resizing operation. It's a very mild model surgery and can be done fairly easily."
And the training pattern that usually goes with it: "it's quite common that basically you would freeze the base model, you introduce these new parameters, and then you only train these new parameters to introduce new tokens into the architecture. You can freeze arbitrary parts of it, or you can train arbitrary parts of it, and that's totally up to you."
Gist tokens: the design space beyond special tokens
He then opens a door and immediately closes it, flagging that it could be an entire video by itself. "There's an entire design space of applications in terms of introducing new tokens into a vocabulary that go way beyond just adding special tokens and special new functionality."
The example is Learning to Compress Prompts with Gist Tokens. The setup: you are using a language model in a setting that requires very long prompts, and those long prompts slow everything down, because you have to encode them, use them, attend over them. Heavy.
What the paper does instead: introduce a few new tokens, put them in a sequence, and train the model by distillation. Keep the entire model frozen and train only the representations of the new tokens, their embeddings, optimizing so that the behavior of the language model is identical to the model that has the very long prompt. "So it's a compression technique of compressing that very long prompt into those few new gist tokens, and so you can train this, and then at test time you can discard your old prompt and just swap in those tokens, and they sort of stand in for that very long prompt and have an almost identical performance."
Where this sits in the taxonomy: "this is one technique and a class of parameter efficient fine tuning techniques where most of the model is basically fixed, and there's no training of the model weights, there's no training of LoRA or anything like that of new parameters. The parameters that you're training are now just the token embeddings."
Tokenizing images, video and audio
The last structural point before the payoff section, and it is a reassuring one: the Transformer does not need to change.
"There's a lot of momentum in how you actually could construct Transformers that can simultaneously process not just text as the input modality but a lot of other modalities, be it images, videos, audio." The question people ask is whether you have to change the architecture fundamentally. "What a lot of people are starting to converge towards is that you're not changing the architecture. You stick with the Transformer, you just kind of tokenize your input domains and then call it a day and pretend it's just text tokens, and just do everything else in an identical manner."
For images he points at an early paper with a clean graphic showing how you chunk an image into integers, which become the tokens of images. The tokens can be hard tokens, where you force them to be integers, or soft tokens, where you do not require them to be discrete but you do force the representations through a bottleneck, as in autoencoders. The underlying technique the chapter title names is vector quantization, which is how a continuous representation gets turned into a finite codebook of discrete symbols in the first place.
For video he points at OpenAI's Sora report, which "really blew the mind of many people and inspired a lot of people in terms of what's possible." Its framing is exactly parallel: whereas LLMs have text tokens, Sora has visual patches. They came up with a way to chunk videos into tokens with their own vocabularies, "and then you can either process discrete tokens, say with autoregressive models, or even soft tokens with diffusion models." Beyond the scope of this video, but the shape of the idea is the same shape we spent two hours on.
The payoff: every item on the crime sheet, explained
"Now that we have come quite deep into the tokenization algorithm and we understand a lot more about how it works, let's loop back around to the beginning of this video and go through some of these bullet points and really see why they happen."
This is the twenty minutes the rest of the lecture exists to make possible. Each item is a live demo, not an assertion.
Why it cannot spell
Characters are chunked into tokens, and some of those tokens are fairly long. To find the worst case he went into the GPT-4 vocabulary and looked for one of the longer tokens: .DefaultCellStyle turns out to be a single individual token. "That's a lot of characters for a single token, so my suspicion is that there's just too much crammed into this single token, and my suspicion was that the model should not be very good at tasks related to spelling of this single token."
So he asks GPT-4: how many letters L are there in the word .DefaultCellStyle? The prompt is "intentionally done that way," because .DefaultCellStyle is one token, so what you see as sixteen characters the model sees as one opaque symbol.
It gets it wrong. "It doesn't actually know how many Ls are in there. It thinks there are three, and actually there are four, if I'm not getting this wrong myself." Four: one in Default, two in Cell, one in Style.
Why it cannot reverse a string, and the trick that makes it work
Same token, a different character level task. He asks GPT-4 to reverse the string .DefaultCellStyle. It reaches for the code interpreter, he stops it and tells it to just try. "It gave me jumble. It doesn't actually really know how to reverse this string going from right to left, so it gave a wrong result."
Then the fix, and the fix is the proof of the diagnosis. Working with the hypothesis that this is tokenization, he asks for the same reversal in two explicit steps. Step one: print out every single character separated by spaces. Step two: reverse that list.
It tries to use a tool again, he stops it again, and this time it works. "It first produced all the characters, and that was actually correct, and then it reversed them, and that was correct, once it had this."
Why: "somehow it can't reverse it directly, but when you go just first listing it out in order, it can do that somehow, and then once it's broken up this way, this becomes all these individual characters, and so now this is much easier for it to see these individual tokens and reverse them and print them out."
The useful generalization: any character level task gets dramatically easier if you first force the string into one token per character.
Why it is worse in non English languages
Two causes stacked, not one, and he is careful to separate them. "It's not only that the language model sees less non English data during training of the model parameters, but also the tokenizer is not sufficiently trained on non English data."
The measurement: "hello how are you" is 5 tokens. Its translation is 15 tokens. A three times blow up for identical meaning.
And the example that surprises him: 안녕하세요 "is just hello basically in Korean, and that ends up being three tokens. I'm actually kind of surprised by that, because that is a very common phrase there, just the typical greeting of like hello, and that ends up being three tokens, whereas our hello is a single token. So basically everything is a lot more bloated and diffuse, and this is I think partly the reason that the model works worse on other languages."
Why arithmetic is hard
The mechanism is a mismatch between what addition needs and what tokenization provides. "Addition is very sort of like, there's an algorithm that is character level for doing addition. So for example here we would first add the ones and then the tens and then the hundreds. You have to refer to specific parts of these digits." Place value arithmetic requires access to individual digit positions.
"But these numbers are represented completely arbitrarily based on whatever happened to merge or not merge during the tokenization process."
He points at a blog post he rates, Integer tokenization is insane, which systematically explores how numbers tokenize in what he believes is GPT-2. The finding for four digit numbers: a given number may arrive as a single token, or as two tokens split 1 and 3, or 2 and 2, or 3 and 1. All the different numbers get all the different combinations, "and you can imagine this is all completely arbitrary. And the model unfortunately sometimes sees a token for all four digits, sometimes for three, sometimes for two, sometimes for one, and it's in an arbitrary manner. And so this is definitely a headwind, if you will, for the language model, and it's kind of incredible that it can kind of do it and deal with it, but it's also kind of not ideal."
Which is why, when Meta trained Llama 2 with SentencePiece, "they make sure to split up all the digits as an example, and this is partly to improve simple arithmetic kind of performance." Forcing every digit into its own token removes the arbitrariness entirely.
Why GPT-2 was bad at Python
Partly a modelling issue, in the architecture, the dataset and the strength of the model. But also, concretely, tokenization: "as we saw here with the simple Python example, the encoding efficiency of the tokenizer for handling spaces in Python is terrible, and every single space is an individual token, and this dramatically reduces the context length that the model can attend across."
His own verdict on it: "that's almost like a tokenization bug for GPT-2, and that was later fixed with GPT-4."
Why the model halts when it sees <|endoftext|>
"Here's a very strange behavior." He tells GPT-4 to print the string <|endoftext|>, and it says: could you please specify the string. He tells it again that <|endoftext|> is the string. He gives it the string, and then it just does not print it.
"So obviously something is breaking here with respect to the handling of the special token, and I don't actually know what OpenAI is doing under the hood here, and whether they are potentially parsing this as an actual token instead of this just being end of text as individual pieces of it, without the special token handling logic."
His hypothesis is about the allowed_special argument. "It might be that someone, when they're calling encode, they are passing in the allowed_special and they are allowing end of text as a special character in the user prompt. But the user prompt of course is attacker controlled text, so you would hope that they don't really parse or use special tokens from that kind of input."
And then the security point, stated as a general principle: "so your knowledge of these special tokens ends up being an attack surface, potentially. And so if you'd like to confuse LLMs, then just try to give them some special tokens and see if you're breaking something by chance."
Why a trailing space ruins your completion
This one he calls "a really fun one," and it is the clearest demonstration of what it means to be out of distribution.
He goes to the OpenAI playground and picks GPT-3.5-turbo-instruct, deliberately, because that is a completion model rather than a chat model, much closer to a base model: you give it a token sequence and it continues it.
Prompt: Here's a tagline for an ice cream shop. Submit, and you get a reasonable continuation. No problem.
Now the same prompt with a single trailing space before submitting. The playground throws a warning:
"Your text ends in a trailing space, which causes worse performance due to how the API splits text into tokens."
Here is what is happening, worked through against the training data. Imagine you found that completion somewhere on the internet, and the LLM trained on it. The continuation starts with a word, say one beginning with o. In GPT's scheme the space character is always a prefix to these tokens, so what appears in the training data is not an o token. It is a " o" token, where the space is part of the o, and together they are token 8840.
So when you leave the prompt ending at shop and let the model complete, it can sample " o" normally. But when you add your own space, that space is encoded as token 220, a space by itself. "And this token otherwise would be part of the tagline, because if there actually is a tagline here, so " o" is the token. And so this is suddenly out of distribution for the model, because this space is part of the next token, but we're putting it here like this, and the model has seen very, very little data of actual space by itself. And we're asking it to complete the sequence, like add in more tokens, but the problem is that we've sort of begun the first token, and now it's been split up, and now we're out of this distribution, and now arbitrary bad things happen."
The general statement of the lesson: "the LLM is on top of these tokens, and these tokens are text chunks, they're not characters in a way you and I would think of them. They are the atoms of what the LLM is seeing, and there's a bunch of weird stuff that comes out of it."
Partial tokens, and the undocumented machinery for them
He pushes the same idea harder. Take .DefaultCellStyle, the long single token, and truncate it: .DefaultCellSty, without the le.
"I bet you that the model has never in its training set seen .DefaultCellSty without le in there. It's always seen this as a single group, because this is some kind of a function, I'm guessing, I don't actually know what this is part of, this is some kind of API. But I bet you that it's never seen this combination of tokens in its training data, because I think it would be extremely rare."
He pastes it into the playground and asks for a completion. Immediate error:
"The model predicted a completion that begins with a stop sequence, resulting in no output. Consider adjusting your prompt or stop sequences."
"What happened here when I clicked submit is that immediately the model emitted an end of text token, I think, or something like that. It basically predicted the stop sequence immediately, and so it had no completion. And so this is why I'm getting a warning again, because we're off the data distribution and the model is just predicting totally arbitrary things. It's just really confused. Basically this is giving it brain damage. It's never seen this before, it's shocked, and it's predicting end of text or something."
He tries again. This time it completes, but the request gets flagged: "this request may violate our usage policies." "Basically something just goes wrong, and there's something like jank. You can just feel the jank, because the model is extremely unhappy with just this, and it doesn't know how to complete it, because it's never occurred in a training set. In a training set it always appears like this and becomes a single token."
He gives the whole family a name: partial tokens. "Either you sort of complete the first character of the next token, or you have long tokens that you then have just some of the characters off. All of these are kind of like issues with partial tokens."
And then the pointer to the hidden machinery that exists because of this. "If you actually dig into the tiktoken repository, go to the Rust code and search for unstable, and you'll see encode_unstable_native, unstable_tokens, and a lot of special case handling. None of this stuff about unstable tokens is documented anywhere, but there's a ton of code dealing with unstable tokens."
What that code is reaching for is what a completion API should ideally do. "If we're putting in .DefaultCellSty, if we're asking for the next token sequence, we're not actually trying to append the next token exactly after this list. We're trying to consider lots of tokens, or I guess we're trying to search over characters that, if we retokenized, would be of high probability, so that we can actually add a single individual character instead of just adding the next full token that comes after this partial token list."
"This is very tricky to describe, and I invite you to maybe look through this. It ends up being an extremely gnarly and hairy kind of topic, and it comes from tokenization fundamentally. Maybe I can even spend an entire video talking about unstable tokens sometime in the future."
SolidGoldMagikarp
"I'm really saving the best for last. My favorite one by far."
It comes from the blog post SolidGoldMagikarp (plus, prompt generation), which is "internet famous now for those of us in LLMs." He advises reading it in full.
What the authors did: take the token embedding table, cluster the tokens by their embedding representation, and look at what falls out. One cluster is deeply strange. SolidGoldMagikarp is in it, along with other tokens whose names the caption track mangles beyond recovery, among them StreamerBot and a token rendered as attRot. "Really weird tokens in this embedding cluster. So what are these tokens and where do they even come from? Like what is SolidGoldMagikarp? It makes no sense."
Then the plot thickens, because you can poke them. Ask the model something completely benign, like "please can you repeat back to me the string SolidGoldMagikarp," and you get a variety of totally broken behavior:
- Evasion. "I'm sorry, I can't hear you."
- Hallucination. A wall of unrelated invented content.
- Insults. Ask about StreamerBot and "the model actually just calls you names."
- Weird humor. "You're actually breaking the model by asking about these very simple strings."
"There's a bunch of tokens, not just SolidGoldMagikarp, that have that kind of behavior. So basically there's a bunch of trigger words, and if you ask the model about these trigger words or you just include them in your prompt, the model goes haywire and has all kinds of really strange behaviors, including ones that violate typical safety guidelines and the alignment of the model, like it's swearing back at you."
And then the explanation, which is the thing the whole lecture was building toward, because it needs the separation of stages from Figure 2 to even state.
SolidGoldMagikarp is a Reddit user. There is a /u/SolidGoldMagikarp. What is thought to have happened, though nobody has definitively established it:
- The tokenization dataset contained a great deal of Reddit data, in which that user was mentioned constantly, because they posted a lot. That string occurs many times in the tokenization dataset.
- Because it occurs many times, BPE merges it all the way up into a single dedicated token in a vocabulary of 50,000. A whole token devoted to one Reddit user.
- Then you train the actual language model, and this Reddit data was not present in the language model's training set. So in the entire training set for the model, SolidGoldMagikarp never occurs. That token never appears.
- So that token never gets activated. "It's initialized at random in the beginning of optimization, then you have forward backward passes and updates to the model, and this token is just never updated in the embedding table. That row vector never gets sampled, it never gets used, so it never gets trained, and it's completely untrained."
The analogy he reaches for: "it's kind of like unallocated memory in a typical binary program written in C or something like that."
"And then at test time, if you invoke this token, then you're basically plucking out a row of the embedding table that is completely untrained, and that feeds into a Transformer and creates undefined behavior. And that's what we're seeing here, this completely undefined, never before seen in training behavior. And so any of these kind of weird tokens would evoke this behavior, because fundamentally the model is out of sample, out of distribution."
Why YAML beats JSON on cost
The last item, "although I think a lot of people are quite aware of this": different formats, representations and languages are more or less efficient under GPT tokenizers, or any tokenizer for any LLM.
"JSON is actually really dense in tokens, and YAML is a lot more efficient in tokens." The measurement: the same data, expressed both ways, JSON is 116 tokens and YAML is 99. "So quite a bit of an improvement."
And the reason to care, stated in economic terms: "in the token economy where we are paying per token in many ways, and you are paying in the context length, and you're paying in dollar amount for the cost of processing all this kind of structured data, prefer to use YAML over JSON. And in general, the tokenization density is something that you have to care about and worry about at all times, and try to find efficient encoding schemes, and spend a lot of time in Tiktokenizer and measure the different token efficiencies of different formats and settings."
| Symptom | What the tokenizer actually did | His example on camera | What to do about it |
|---|---|---|---|
| Cannot count letters in a word | Long strings collapse into one opaque token, so the characters are not visible to the model at all | .DefaultCellStyle is a single GPT-4 token; asked how many Ls it holds, GPT-4 says three, the answer is four | Force one token per character first, or hand the job to code |
| Cannot reverse a string | Same cause. Reversal is character work and the model only has chunks | Reversing .DefaultCellStyle directly returns jumble | works in two steps: print every character space separated, then reverse that list |
| Worse in non English languages | Two causes stacked: less non English data for the model AND fewer merges learned for that script, so chunks are smaller | "hello how are you" is 5 tokens, the translation is 15; 안녕하세요 alone is 3 tokens against 1 for hello | budget 3x the tokens, or pick a model whose tokenizer saw your language |
| Bad at simple arithmetic | Digit groupings are set by corpus frequency, not place value, so the same four digit number may split 1 and 3, or 2 and 2, or 3 and 1 | 127 is one token but 677 is " 6" plus "77"; 804 is " 8" plus "04"; 6773 is " 6" plus "773" | Use a calculator or code interpreter. Llama 2 splits every digit into its own token on purpose |
| GPT-2 wrote poor Python | Every indentation space is its own token, all of them token 220, and OpenAI blocked them from ever merging | A FizzBuzz snippet shows 220, 220, 220, 220 before " if" | fixed in GPT-4 by grouping whitespace runs: four spaces became one token |
Model halts on <|endoftext|> | Special tokens bypass BPE entirely and are swapped in by string matching, so user text containing one can be parsed as the real token | Asking GPT-4 to print the literal string <|endoftext|> produces evasion, then nothing | treat it as an attack surface: never allow special tokens from attacker controlled input |
| Trailing whitespace warning | The space belongs to the front of the NEXT token, so a lone space is token 220 and the sequence goes out of distribution | GPT-3.5-turbo-instruct warns on a prompt ending in a space; " o" is token 8840, not an o token | Do not end a completion prompt with whitespace |
| Nonsense after a truncated token | Partial tokens: a prefix of a long token is a sequence the model has essentially never seen | .DefaultCellSty makes the playground return "the model predicted a completion that begins with a stop sequence, resulting in no output," then a policy flag on the retry | Do not split a token mid chunk. tiktoken's undocumented unstable code paths exist for this |
| SolidGoldMagikarp | The token exists because the TOKENIZER corpus had it, but the MODEL corpus did not, so its embedding row was never trained and still sits at random initialization | Asking GPT-2 to repeat the string produces evasion, hallucination, insults; StreamerBot makes it call you names | train the tokenizer and the model on the same data. He calls an untrained row "unallocated memory" |
| JSON costs more than YAML | Formats have different token densities under the same tokenizer | The same data is 116 tokens as JSON and 99 as YAML | Measure your formats in Tiktokenizer and pick the dense one |
Final recommendations
He closes with a short, blunt summary and a list of what to actually do.
On the stage itself: "I know it's annoying, I know it's irritating, I personally really dislike this stage. What I do have to say at this point is don't brush it off. There's a lot of foot guns, sharp edges here, security issues, AI safety issues, as we saw, plugging in unallocated memory into language models. So it's worth understanding this stage."
And then the line the whole lecture earns: "Eternal glory goes to anyone who can get rid of it." He showed one paper that tried, and hopes a lot more follow.
The recommendations, in order:
- If you can, reuse the GPT-4 tokens and vocabulary in your application. That is something you should consider.
- Use tiktoken for inference. "It is a very efficient and nice library for inference for BPE." He also likes the byte level BPE that tiktoken and OpenAI use, on the merits.
- If you must train your own vocabulary from scratch, then use BPE with SentencePiece. With a long list of caveats: "as I mentioned I'm not a huge fan of SentencePiece. I don't like its byte fallback and I don't like that it's doing BPE on Unicode code points. It also has like a million settings, and I think there's a lot of foot guns here, and I think it's really easy to miscalibrate them and you end up cropping your sentences or something like that because of some type of parameter that you don't fully understand."
- Be very careful with the settings. "Try to copy paste exactly what Meta did, or basically spend a lot of time looking at all the hyperparameters and go through the code of SentencePiece and make sure that you have this correct."
- And even then he considers the algorithm inferior to byte level BPE. "Maybe the best, if you really need to train your vocabulary, maybe the best thing is to just wait for minbpe to become as efficient as possible, and that's something that maybe I hope to work on."
The thing that does not exist yet, stated as a wish: "really what we want is we want tiktoken but training code, and that is the ideal thing that currently does not exist. And minbpe is an implementation of it, but currently it's in Python."
"There might be an advanced video that has even drier and even more detailed in the future, but for now I think we're going to leave things off here."
Key takeaways
- The tokenizer is a separate model with its own training set and its own training run. It is a one time pre processing stage, it never touches the Transformer, and the Transformer never touches text. Everything downstream follows from that separation.
- Text becomes UTF-8 bytes, and byte pair encoding repeatedly merges the most frequent adjacent pair. Each merge shortens the sequence by the number of occurrences and grows the vocabulary by one. Twenty merges on his blog post took 24,000 bytes to 19,000 tokens, a compression ratio of 1.27.
- The parameters of a BPE tokenizer are just the merges dictionary. From it you derive
vocabfor decoding, and with those two you can encode and decode. OpenAI'sencoder.jsonandvocab.bpeare precisely those two objects. - Minted tokens are eligible for later merges, which is why the structure is a forest of small binary trees sitting on top of 256 byte leaves, not a single tree.
- Encoding must apply merges in the order they were learned, because later merges are defined in terms of earlier ones. The implementation is a
minover candidate pairs keyed on their index inmerges, withfloat("inf")for ineligible pairs. - Decode then encode is not an identity. Not every byte sequence is valid UTF-8, so a model can emit tokens that do not decode. Always use
errors="replace", and treat the replacement character in output as a signal that something went wrong. - Real tokenizers pre split text with a regex before any merging, so merges can never cross category boundaries. That pattern is the reason a leading space belongs to the word that follows it, and the reason
dog.never became a token. - GPT-4 improved on GPT-2 in three documented ways: case insensitive contractions, digit runs capped at three, and grouped whitespace. None of the reasoning was published. The same test string went from 300 tokens to 185.
- Special tokens bypass BPE entirely and are swapped in by string matching in the library. Unescaped user text containing one is an injection vector, and he says so explicitly.
- SentencePiece merges Unicode code points rather than bytes, with byte fallback for rare ones, and carries a large configuration surface inherited from machine translation. Turn
byte_fallbackon, copy a known good configuration, and do not trust the documentation. - Vocabulary size is a three way trade: shorter sequences against a fatter embedding table and output layer, thinner training per token, and too much meaning squeezed into one forward pass. Current practice sits in the high tens of thousands to around 100,000.
- Extending a trained model's vocabulary is mild surgery: add rows to the embedding, extend the final projection, initialize small and random, freeze the rest and train only the new parameters. Gist tokens are the same mechanism used for prompt compression.
- The entire crime sheet resolves to tokenization. Spelling, string reversal, non English cost, arithmetic, GPT-2's Python, the trailing space warning, partial token jank, SolidGoldMagikarp, and JSON against YAML all have a mechanism in this stage and nowhere else.
- An untrained embedding row is unallocated memory. When the tokenizer's corpus and the model's corpus disagree, you ship tokens whose vectors are still at random initialization, and invoking one is undefined behavior.
Chapters
Karpathy's own 24 chapters, verbatim. Click any timestamp to jump the player.
- 0:00:00 intro: Tokenization, GPT-2 paper, tokenization-related issues
- 0:05:50 tokenization by example in a Web UI (tiktokenizer)
- 0:14:56 strings in Python, Unicode code points
- 0:18:15 Unicode byte encodings, ASCII, UTF-8, UTF-16, UTF-32
- 0:22:47 daydreaming: deleting tokenization
- 0:23:50 Byte Pair Encoding (BPE) algorithm walkthrough
- 0:27:02 starting the implementation
- 0:28:35 counting consecutive pairs, finding most common pair
- 0:30:36 merging the most common pair
- 0:34:58 training the tokenizer: adding the while loop, compression ratio
- 0:39:20 tokenizer/LLM diagram: it is a completely separate stage
- 0:42:47 decoding tokens to strings
- 0:48:21 encoding strings to tokens
- 0:57:36 regex patterns to force splits across categories
- 1:11:38 tiktoken library intro, differences between GPT-2/GPT-4 regex
- 1:14:59 GPT-2 encoder.py released by OpenAI walkthrough
- 1:18:26 special tokens, tiktoken handling of, GPT-2/GPT-4 differences
- 1:25:28 minbpe exercise time! write your own GPT-4 tokenizer
- 1:28:42 sentencepiece library intro, used to train Llama 2 vocabulary
- 1:43:27 how to set vocabulary set? revisiting gpt.py transformer
- 1:48:11 training new tokens, example of prompt compression
- 1:49:58 multimodal [image, video, audio] tokenization with vector quantization
- 1:51:41 revisiting and explaining the quirks of LLM tokenization
- 2:10:20 final recommendations
Notable quotes
Tokenization is my least favorite part of working with large language models, but unfortunately it is necessary to understand in some detail, because it is fairly hairy, gnarly, and there's a lot of hidden foot guns to be aware of. Andrej Karpathy, the opening sentence, 0:00:00
A lot of the issues that may look like just issues with the neural network architecture or the large language model itself are actually issues with the tokenization, and fundamentally trace back to it. Andrej Karpathy, the thesis of the whole lecture, 0:04:08
For the same concept egg, depending on if it's in the beginning of a sentence, at the end of a sentence, lowercase, uppercase or mixed, all this will be basically very different tokens and different IDs, and the language model has to learn from raw data from all the internet text that it's going to be training on that these are actually all the exact same concept. Andrej Karpathy, on the egg demonstration in Tiktokenizer, 0:08:47
GPT-2 is not very good with Python, and it's not anything to do with coding or the language model itself. Andrej Karpathy, pointing at a column of token 220s in a FizzBuzz snippet, 0:11:49
The improvement in the Python coding ability from GPT-2 to GPT-4 is not just a matter of the language model and the architecture and the details of the optimization, but a lot of the improvement here is also coming from the design of the tokenizer and how it groups characters into tokens. Andrej Karpathy, on grouped whitespace in cl100k_base, 0:14:21
Together, these results establish the viability of tokenization free autoregressive sequence modeling at scale. Andrej Karpathy, reading the conclusion of the MEGABYTE paper out loud, 0:23:06
It's not a tree, it's more like a forest. Andrej Karpathy, on the shape the merges dictionary builds, 0:37:01
The tokenizer is a completely separate object from the large language model itself. Andrej Karpathy, stopping to draw the diagram, 0:39:06
So the tokenization will work differently in uppercase and lowercase, inconsistently separating out these apostrophes. So it feels extremely gnarly and slightly gross, but that's how that works. Andrej Karpathy, on the missing re.IGNORECASE in GPT-2, 1:06:52
The training code for the GPT-2 tokenizer was never released. Andrej Karpathy, on why some of this has to be inferred from behavior, 1:11:01
We don't really know why they do any of this stuff, because none of this is documented, and it's just we just get the pattern. So yeah, it is what it is. Andrej Karpathy, on the GPT-4 regex changes, 1:14:36
In language models we prefer not to do any of it, or at least that is my preference as a deep learning person. You want to not touch your data, you want to keep the raw data as much as possible in a raw form. Andrej Karpathy, on SentencePiece's normalization options, 1:33:09
I would just prefer to treat a file as a giant stream of bytes. Andrej Karpathy, on SentencePiece's concept of a sentence, 1:34:10
It doesn't actually know how many Ls are in there. It thinks there are three, and actually there are four, if I'm not getting this wrong myself. Andrej Karpathy, after asking GPT-4 to count the letters in .DefaultCellStyle, 1:52:41
Your knowledge of these special tokens ends up being an attack surface, potentially. And so if you'd like to confuse LLMs, then just try to give them some special tokens and see if you're breaking something by chance. Andrej Karpathy, on the end of text halting behavior, 1:58:20
Basically this is giving it brain damage. It's never seen this before, it's shocked, and it's predicting end of text or something. Andrej Karpathy, on feeding the playground a partial token, 2:02:26
It's kind of like unallocated memory in a typical binary program written in C. Andrej Karpathy, on an embedding row that was never trained, 2:08:34
Fundamentally the model is out of sample, out of distribution. Andrej Karpathy, the one line explanation of SolidGoldMagikarp, 2:09:06
Eternal glory goes to anyone who can get rid of it. Andrej Karpathy, closing on tokenization itself, 2:10:38
Really what we want is we want tiktoken but training code, and that is the ideal thing that currently does not exist. Andrej Karpathy, the last technical sentence of the lecture, 2:12:13
A note on the caption track
The automatic captions for this video mangle technical names constantly, so if you read the raw transcript below alongside this page, the names you want are: byte pair encoding (captioned variously as "bik pair", "bite pair" and "B paare"), minbpe ("MBP", "M be"), tiktoken ("Tik token", "Tech token"), SentencePiece, Mistral ("mistal"), ASCII ("asky"), bigram ("Byram"), the <unk> token ("an token", "ank"), tok400.model ("talk 400"), <|im_start|> as short for imaginary monologue start ("imaginary mcore start"), .DefaultCellStyle ("default style", "default cell sta"), SolidGoldMagikarp ("sold gold Magikarp"), and YAML ("yl", "theal").
Two numbers in the captions are wrong rather than merely misspelled. The first token of the word "Tokenization" is 30642, not the "3,642" the captions give; Karpathy's own text version of this lecture confirms it. And the captioned description of the twentieth merge, "a merge of 25 and 259 becoming 275," drops a digit from the first id; the structural point it illustrates, that a token minted by an earlier merge can be the parent of a later one, is unaffected.
One thing the captions simply lose: the names in the strange embedding cluster he reads out at 2:05:29. SolidGoldMagikarp and StreamerBot are recoverable, and attRot is probably the third, but the rest are past rescue. The original post has the full list.
What has changed since February 2024
The lecture holds up almost completely, which is itself the point: this is the layer of the stack that moves slowest. Three things are worth knowing if you open openai_public.py today and find more than he found.
The vocabulary doubled again. tiktoken now ships o200k_base alongside cl100k_base, named for roughly 200,000 tokens, with <|endoftext|> at id 199999 and <|endofprompt|> at 200018, plus an o200k_harmony variant that adds more conversation delimiters. Same move as GPT-2 to GPT-4, same trade off he lays out at 1:43:27: shorter sequences bought with a fatter embedding table and a fatter softmax.
The splitting pattern kept evolving in the direction he predicted. o200k_base drops the single ?\p{L}+ letter alternative in favour of two alternatives that treat uppercase led and lowercase led runs separately and fold the contraction suffixes into each with (?i:'s|'t|'re|'ve|'m|'ll|'d)?, which is the case handling complaint from the GPT-2 docs taken a step further. The three digit cap, \p{N}{1,3}, survives untouched.
And the non English token tax is now acknowledged in the source. Above the o200k_base pattern sits a comment that reads, in part: "This regex could be made more efficient. If I was the one working on this encoding, I would have done a few other things differently too, e.g. I think you can allocate tokens more efficiently across languages." That is the 15 tokens against 5 from the payoff section, conceded in a code comment rather than fixed.
What has not changed is the thing he wished for. Byte level BPE is still the standard, nothing tokenization free has displaced it, and minbpe's last commit was July 2024, still in Python. "tiktoken but training code" remains the ideal thing that does not exist, though there is now a community Rust port, gnp/minbpe-rs, linked from his README. The other library you will meet in practice and he does not cover is Hugging Face tokenizers, which is a Rust implementation with Python bindings and the same conceptual shape. And if you want the algorithm in readable Python rather than a Rust core, tiktoken itself ships _educational.py, roughly 220 lines carrying bpe_train, bpe_encode, a SimpleBytePairEncoding class with the same train, encode and decode surface he builds, and a terminal visualiser that colors the tokens. It is the closest official companion to this lecture.
Where this sits in the LLM Learning track
This is Part 2 of the LLM Learning track, the first of the two build it yourself lectures, and it comes first for a structural reason: tokenization is the first thing in the pipeline, and it is the easiest thing to get quietly wrong.
It picks up directly from Part 1. The deep dive into LLMs sketched tokenization in ten minutes and then listed the cognitive quirks that come out of it; this lecture is the long version of that ten minutes, and it retroactively supplies the mechanism for several of the behaviors that page describes. Attention in transformers opened up the machine that consumes these tokens, and the embedding table at the start of that walkthrough is the exact table whose rows get minted, resized and occasionally left untrained here. LLMs in five formulas gave you the memory and efficiency formulas that vocabulary size feeds straight into.
What it sets up is Let's reproduce GPT-2, the other half of this stage. That build assumes a tokenizer exists and loads one, so the two lectures run in pipeline order: tokenizer first, model second. The 50,257 vocabulary size you tune in that video is the number this one explains, including why it is 50,257 rather than 50,256.
Read it before Part 3 as well. John Schulman on RLHF is about teaching a model to hedge instead of hallucinate, and a non trivial share of the hallucinations that look like reasoning failures are the partial token and untrained embedding failures catalogued here. And Chris Olah on mechanistic interpretability works on the embedding and feature structure that this lecture shows you can poison from the outside, simply by training the tokenizer on a corpus the model never sees.
Resources mentioned
The code he builds and the code he reads
- minbpe, the clean reference implementation published with this lecture:
base.py,basic.py,regex.pyandgpt4.py, plustrain.pyand theexercise.mdfour step progression to your own GPT-4 tokenizer.lecture.mdis his own written version of this video, andtests/taylorswift.txtis the training corpus. - tiktoken, OpenAI's official tokenizer library. Inference only.
tiktoken_ext/openai_public.pyholds the vocabularies, the regex patterns and the special token tables forgpt2,r50k_base,p50k_baseandcl100k_base. The undocumented unstable token machinery lives in the Rust core,src/lib.rs. - openai/gpt-2, the 2019 code release, and specifically
src/encoder.py, the inference only tokenizer with the original splitting pattern. Its two saved files are downloadable:encoder.json(theirvocab) andvocab.bpe(theirmerges). - SentencePiece from Google, used to train the Llama 2 vocabulary, with its full list of training options.
- regex, the third party Python package that
encoder.pyimports asre, needed for the\p{L}and\p{N}Unicode property classes.
Papers
- Language Models are Unsupervised Multitask Learners, the GPT-2 paper. Read the input representation section: vocabulary of 50,257, context of 1,024, and the argument for byte level BPE.
- Llama 2: Open Foundation and Fine Tuned Chat Models, from Meta, the worked example of SentencePiece settings he copies, and the source of the two trillion tokens figure.
- MEGABYTE: Predicting Million-byte Sequences with Multiscale Transformers, the tokenization free daydream at 22:47.
- Efficient Training of Language Models to Fill in the Middle, the FIM paper behind the three
fimspecial tokens incl100k_base. - Learning to Compress Prompts with Gist Tokens, prompt compression by training new token embeddings against a frozen model.
- Video generation models as world simulators, the Sora report: LLMs have text tokens, Sora has visual patches.
- Neural Discrete Representation Learning, the canonical reference for the vector quantization that turns images and audio into discrete tokens.
- Neural Machine Translation of Rare Words with Subword Units, Sennrich et al. 2015, the paper that brought BPE into neural sequence modelling. Not named on camera; it is the citation minbpe's README points at.
Tools and references
- Tiktokenizer, the browser app he spends the first fifteen minutes in. Switch between
gpt2,cl100k_baseand the chat schemes, and turn on whitespace display. - The Unicode Consortium and Unicode 15.1, September 2023, the version current at recording. Background on Unicode and UTF-8.
- A Programmer's Introduction to Unicode, the blog post he recommends, and the UTF-8 Everywhere Manifesto it points to.
- Byte pair encoding on Wikipedia, whose
aaabdaaabacwalkthrough he steps through and minbpe's quick start reuses. - Python documentation for
str(immutable sequences of Unicode code points),ord, and the codec error handlers behinderrors="replace". - The OpenAI playground, where he runs the trailing space and partial token demonstrations on
gpt-3.5-turbo-instruct.
Posts, people and models
- Andrej Karpathy, founding member of OpenAI and former director of AI at Tesla.
- SolidGoldMagikarp (plus, prompt generation), the LessWrong post that found the untrained token cluster, and the Reddit usernames behind it.
- Integer tokenization is insane, the systematic survey of how numbers split.
- Llama and Mistral, the model families that use SentencePiece.
- The Taylor Swift Wikipedia page, one of the longest available, and therefore his tokenizer training corpus.
- Let's build GPT from scratch, the previous lecture, with its tiny Shakespeare dataset and its 65 character vocabulary.


