Created with Solve It. The course and platform for solving problems with code.Start learning →

Note

This dialog is used to write a blog post from the enriched transcript the 'Let's build the GPT Tokenizer' video

Code

from fastcore.all import *
from dialoghelper import *

Code

tool_info()

Note

Tools available from dialoghelper:

  • &curr_dialog: Get the current dialog info.
  • &msg_idx: Get absolute index of message in dialog.
  • &add_html: Send HTML to the browser to be swapped into the DOM using hx-swap-oob.
  • &find_msg_id: Get the current message id.
  • &find_msgs: Find messages in current specific dialog that contain the given information.
    • (solveit can often get this id directly from its context, and will not need to use this if the required information is already available to it.)
  • &read_msg: Get the message indexed in the current dialog.
  • &del_msg: Delete a message from the dialog.
  • &add_msg: Add/update a message to the queue to show after code execution completes.
  • &update_msg: Update an existing message.

Code

def read_text(filename:str):
    'Read a text file'
    return Path(filename).read_text()

Note

&read_text - a tool to read text files such as code files

Note

&run_cmd - run any bash command, including ripgrep rg

Note

&update_msg_with_strs_replace - a more efficient update_msg function which use list of string replacement pairs.

Code

def skip_msgs_between(start, end):
    for m in find_msgs_between(start, end): update_msg(m['id'], skipped=True)

Code

def duplicate_msgs_between(start, end, skip_types=[]):
    for m in find_msgs_between(start, end)[::-1]:
        if m['msg_type'] not in skip_types:
            add_msg(content=m['content'], msg_type=m['msg_type'], output=json.dumps(m['output']) if m['msg_type']=='code' else m['output'])

Note

Let's build the GPT Tokenizer Enriched Transcript

As a solveit user you can run this transcript artifact end to end as a learning resource, you can also use AI to ask questions, write code to to improve your understanding or take additional notes during the process.

Note

Part 1:

Note

Introduction to Tokenization

[00:00] Andrej Karpathy: Hi everyone. So in this video, I'd like us to cover the process of tokenization in large language models. Now, you see here that I have a sad face, and that's because, well, 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 are a lot of hidden foot guns to be aware of. And a lot of oddness with large language models typically traces back to tokenization.

[00:25] Andrej Karpathy: So what is tokenization? Now, in my previous video, "[Let's build GPT from scratch](https://youtube.com/ watch?v=kCc8FmEb1nY)," we actually already did tokenization, but we did a very naive, simple version of tokenization.

[00:36] Andrej Karpathy: So when you go to the Google Colab for that video, you see here that we loaded our training set. And our training set was this Shakespeare dataset. Now in the beginning, the Shakespeare dataset is just a large string in Python. It's just text. And so the question is, how do we plug text into large language models?

[00:57] Andrej Karpathy: And in this case here, we created a vocabulary of 65 possible characters that we saw occur in this string. These were the possible characters, and we saw that there are 65 of them. And then we created a lookup table for converting from every possible character, a little string piece, into a token, an integer.

[01:18] Andrej Karpathy: So here, for example, we tokenized the string "hi there" and we received this sequence of tokens. And here we took the first 1,000 characters of our dataset and we encoded it into tokens. And because this is character level, we received 1,000 tokens in a sequence. So token 18, 47, etc.

Code

import torch
text = 'This is some text dataset hello, and hi some words!'
# get the unique characters that occur in this text
chars = sorted(list(set(text)))
vocab_size = len(chars)
print(''.join(chars))
print(vocab_size)

# create a mapping from characters to integers
stoi = { ch:i for i,ch in enumerate(chars) }
itos = { i:ch for i,ch in enumerate(chars) }
encode = lambda s: [stoi[c] for c in s] # encoder: take a string, output a list of integers
decode = lambda l: ''.join([itos[i] for i in l]) # decoder: take a list of integers, output a string

print(encode("hii there"))
print(decode(encode("hii there")))

# let's now encode the entire text dataset and store it into a torch.Tensor
data = torch.tensor(encode(text), dtype=torch.long)
print(data.shape, data.dtype)
print(data[:1000]) # the first 1000 characters we'll look like this

Output: 373

 !,Tadehilmnorstwx
18
[7, 8, 8, 0, 15, 7, 6, 13, 6]
hii there
torch.Size([51]) torch.int64
tensor([ 3,  7,  8, 14,  0,  8, 14,  0, 14, 12, 10,  6,  0, 15,  6, 17, 15,  0,
         5,  4, 15,  4, 14,  6, 15,  0,  7,  6,  9,  9, 12,  2,  0,  4, 11,  5,
         0,  7,  8,  0, 14, 12, 10,  6,  0, 16, 12, 13,  5, 14,  1])

Note

[01:41] Andrej Karpathy: Now, later we saw that the way we plug these tokens into the language model is by using an embedding table. And so basically, if we have 65 possible tokens, then this embedding table is going to have 65 rows. And roughly speaking, we're taking the integer associated with every single token, we're using that as a lookup into this table, and we're plucking out the corresponding row. And this row is a, uh, is trainable parameters that we're going to train using backpropagation. And this is the vector that then feeds into the transformer, um, and that's how the transformer sort of perceives every single token.

[02:19] Andrej Karpathy: So here we had a very naive tokenization process that was a character-level tokenizer. But in practice, state-of-the-art, uh, language models, people use a lot more complicated schemes, unfortunately, for, uh, constructing these, uh, token vocabularies. So we're not dealing on a character level, we're dealing on a chunk level. And the way these, um, character chunks are constructed is using algorithms such as, for example, the byte-pair encoding algorithm, which we're going to go into in detail, um, and cover in this video.

Note

Tokenization in GPT-2 and Llama 2

[02:52] Andrej Karpathy: I'd like to briefly show you the paper that introduced byte-level encoding as a mechanism for tokenization in the context of large language models. And I would say that that's probably the GPT-2 paper. And if you scroll down here to the section "Input Representation," this is where they cover tokenization, the kind of properties that you'd like the tokenization to have. And they conclude here that they're going to have a tokenizer where you have a vocabulary of 50,257 possible tokens. And the context size is going to be 1,024 tokens. So in the, in the attention layer of the transformer neural network, every single token is attending to the previous tokens in the sequence, and it's going to see up to 1,024 tokens. So tokens are this fundamental unit, um, the atom of, uh, large language models, if you will. And everything is in units of tokens, everything is about tokens. And tokenization is the process for translating strings or text into sequences of tokens and, uh, vice versa.

[03:55] Andrej Karpathy: When you go into the Llama 2 paper as well, I can show you that when you search "token," you're going to get 63 hits. Um, and that's because tokens are, again, pervasive. So here they mentioned that they trained on 2 trillion tokens of data and so on. So we're going to build our own tokenizer. Luckily, the byte-pair encoding algorithm is not, um, that super complicated, and we can build it from scratch ourselves and we'll see exactly how this works.

Note

The Weirdness of Tokenization

[04:20] Andrej Karpathy: Before we dive into code, I'd like to give you a brief taste of some of the complexities that come from the tokenization because I just want to make sure that we've motivated it sufficiently for why we are doing all this and why this is so gross. So, 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. A lot of the issues that may look like just issues with the neural architecture or the large language model itself are actually issues with the tokenization and fundamentally trace back to it.

[04:50] Andrej Karpathy: So, if you've noticed any issues with large language models can't, you know, not able to do spelling tasks very easily, that's usually due to tokenization. Simple string processing can be difficult for the large language model to perform natively. Uh, non-English languages can work much worse, and to a large extent, this is due to tokenization. Sometimes LLMs are bad at simple arithmetic, also can trace be traced to tokenization. Uh, GPT-2 specifically would have had quite a bit more issues with Python than, uh, future versions of it due to tokenization. There's a lot of other issues. Maybe you've seen weird warnings about a trailing whitespace. This is a tokenization issue. Um, if you had asked GPT earlier about "SolidGoldMagikarp" and what it is, you would see the LLM go totally crazy and it would start going off about completely unrelated tangent topic. Maybe you've been told to use YAML over JSON with structured data. All that has to do with tokenization. So basically, tokenization is at the heart of many issues.

Tokenization Issues in LLMs

Based on Andrej Karpathy's analysis, here are the key issues caused by tokenization:

• Why can't LLM spell words? Tokenization.

• Why can't LLM do string processing tasks like reversing a string? Tokenization.

• Why is LLM bad at non-English languages (especially ones with different scripts)? Tokenization.

• Why is LLM bad at simple arithmetic? Tokenization.

• Why did GPT-2 have more than necessary trouble coding in Python? Tokenization.

• Why did my LLM abruptly halt when it sees the string "<|endoftext|>"? Tokenization.

• Why should I prefer YAML over JSON with LLMs? Tokenization.

• Why is LLM not actually end-to-end language modeling? Tokenization.

• Why should I prefer to use f-strings instead of .format(...)? Tokenization.

• Why is LLM worse at tasks in JSON compared to YAML? Tokenization.

• Why should LLMs not be used for password generation? Tokenization.

• What is the root of suffering? Tokenization.

[05:50] Andrej Karpathy: I will loop back around to these at the end of the video, but for now, let me just, um, skip over it a little bit. And let's go to this web app, um, the tiktokenizer that vercel.app. So I have it loaded here. And what I like about this web app is that tokenization is running sort of live in your browser in JavaScript. So you can just type here stuff, "hello world," and the whole string re-tokenizes.

pasted_image

A web application called 'Tiktokenizer'. The left pane is a text editor, and the right pane shows the tokenized output. The text includes examples of English, arithmetic, Korean, and Python code.

[06:15] Andrej Karpathy: So, here what we see on the left is the string that you put in. On the right, we're currently using the GPT-2 tokenizer. We see that this string that I pasted here is currently tokenizing into 300 tokens. And here they are sort of, uh, shown explicitly in different colors for every single token. So for example, uh, this word "Tokenization" became two tokens, the token 30,642 and 1,634. The token " is" is token 318. Be careful, on the bottom you can show whitespace, and keep in mind that there are spaces and, uh, slash n, new line characters in here, but you can hide them for clarity. The token " at" is token 379. The token " the" is 262, etc. So you notice here that the space is part of that, uh, token chunk.

[07:16] Andrej Karpathy: Now, so this is kind of like how our English sentence broke up, and that seems all well and good. Now, now here I put in some arithmetic. So we see that, uh, the token 127 plus and then token 6, space 6, followed by 77. So what's happening here is that 127 is feeding in as a single token into the large language model, but the, um, number 677 will actually feed in as two separate tokens. And so the large language model has to, uh, sort of, um, take account of that and process it correctly in its network. And see here, 804 will be broken up into two tokens. And it's all completely arbitrary. And here I have another example of four-digit numbers, and they break up in a way that they break up, and it's totally arbitrary. Sometimes you have, um, multiple digits, a single token. Sometimes you have individual digits as many tokens, and it's all kind of pretty arbitrary and comes out of the tokenizer.

[08:15] Andrej Karpathy: Here's another example. We have the string "Egg." And you see here that this became two tokens. But for some reason when I say, "I have an egg," you see when it's a " an egg," it's two tokens, it's, sorry, it's a single token. So just "Egg" by itself in the beginning of a sentence is two tokens, but here as a " an egg" it's suddenly a single token for the exact same string. Okay? Here, lowercase "egg" turns out to be a single token, and in particular, notice that the color is different, so this is a different token. So this is case sensitive. And of course, uh, capital "EGG" would also be different tokens, and again, um, this would be two tokens arbitrarily. So 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, uh, 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 being trained on that these are actually all the exact same concept. And 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, but maybe not almost exactly similar, but very, very similar.

[09:30] Andrej Karpathy: Um, after the egg demonstration here, I have, um, an introduction from OpenAI's ChatGPT in Korean. So, "mannaseo bangawoyo," uh, etc. Uh, so this is in Korean. And the reason I put this here is because you'll notice that, um, non-English languages work slightly worse in ChatGPT. Part of this is because, of course, the training dataset for ChatGPT is much larger for English than for everything else. But the same is true not just for the large language model itself, but also for the tokenizer. So when we train the tokenizer, we're going to see that there's a training set as well. And there's a lot more English than non-English. And what ends up happening is that we're going to have a lot more longer tokens for English.

[10:17] Andrej Karpathy: So, how do I put this? If you have a single sentence in English and you tokenize it, you might see that it's 10 tokens or something like that. But if you translate that sentence into, say, Korean or Japanese or something else, you'll typically see that the number of tokens used is much larger. And that's because the chunks here are a lot more broken up. Uh, so we're using a lot more tokens for the exact same thing. And what this does is it bloats up the sequence length of all the documents. So you're using up more tokens, and then in the attention of the transformer, when these tokens try to attend each other, you are running out of context, um, in the maximum context length of that transformer. And so basically, all the non-English text is stretched out from the perspective of the transformer, and this just has to do with the, um, training set used for the tokenizer and the tokenization itself. So it will create a lot bigger tokens and a lot larger groups in English, and it will have a lot of little boundaries for all the other non-English text. So if we translated this into English, it would be significantly fewer tokens.

[11:24] Andrej Karpathy: The final example I have here is a little snippet of Python for doing FizzBuzz. And what I'd like you to notice is, look, all these individual spaces are all separate tokens. They are token 220. So, uh, 220, 220, 220, 220, and then " if" is a single token. And so what's going on here is that when the transformer is going to consume or try to, uh, create this text, it needs to, um, handle all these spaces individually. They all feed in one by one into the entire transformer in the sequence. And so this is being extremely wasteful, tokenizing it in this way. And so, as a result of that, GPT-2 is not very good with Python. And it's not anything to do with coding or the language model itself, it's just that if you use a lot of indentation using space in Python, like you usually do, uh, you just end up bloating out all the text, and it's separated across way too much of the sequence, and we are running out of the context length in the sequence, uh, roughly speaking, what's what's happening. We're being way too wasteful. We're taking up way too much token space.

Note

Improving Tokenization: GPT-2 vs. GPT-4

[12:29] Andrej Karpathy: Now, if we also scroll up here, we can change the tokenizer. So note here that GPT-2 tokenizer creates a token count of 300 for this string here. We can change it to cl100k_base, which is the GPT-4 tokenizer. And we see that the token count drops to 185. So for the exact same string, we are now roughly halving the number of tokens. And roughly speaking, this is because, uh, the number of tokens in the GPT-4 tokenizer is roughly double that of the number of tokens in the GPT-2 tokenizer. So we went from roughly 50k to roughly 100k.

[13:01] Andrej Karpathy: Now, you can imagine that this is a good thing because the same text is now squished into half as many tokens. So, uh, this is a lot denser input to the transformer. And in the transformer, every single token has a finite number of tokens before it that it's going to pay attention to. And so what this is doing is we're roughly able to see twice as much text as a context for what token to predict next, uh, because of this change. But of course, just increasing the number of tokens is, uh, not strictly better infinitely, uh, because as you increase the number of tokens, now your embedding table is, uh, sort of getting a lot larger. And also at the output, we are trying to predict the next token, and there's the softmax there, and that grows as well. We're going to go into more detail later on this, but 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.

[13:57] Andrej Karpathy: Now, one thing I would like you to note specifically for the GPT-4 tokenizer is that the handling of the whitespace for Python has improved a lot. You see that here, these four spaces are represented as one single token for the three spaces here, and then the token " if." And here, seven spaces were all grouped into a single token. So we're being a lot more efficient in how we represent Python. And this was a deliberate choice made by OpenAI when they designed the GPT-4 tokenizer. And they group a lot more whitespace into a single character. What this does is it densifies Python, and therefore, we can attend to more code before it when we're trying to predict the next token in the sequence. And so 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.

pasted_image

The Tiktokenizer web app showing a Python FizzBuzz code snippet. The tokenizer is set to 'cl100k_base' (GPT-4). The indentation spaces are grouped into single, larger tokens, unlike the GPT-2 tokenizer.

Note

From Strings to Integers: Unicode and Encodings

[14:56] Andrej Karpathy: Okay, so let's now start writing some code. So, remember what we want to do. We want to take strings and feed them into language models. For that, we need to somehow tokenize strings into some integers in some fixed vocabulary. And then we will use those integers to make a lookup into a lookup table of vectors and feed those vectors into the transformer as an input.

[15:20] Andrej Karpathy: Now, the reason this gets a little bit tricky, of course, is that we don't just want to support the simple English alphabet. We want to support different kinds of languages. So this is "annyeonghaseyo" in Korean, which is hello. And we also want to support many kinds of special characters that we might find on the internet, for example, emoji. So, how do we feed this text into, uh, transformers?

Note

text = "안녕하세요 👋 hello world 🤗"
print(text)
# Output: 안녕하세요 👋 hello world 🤗

Note

[15:43] Andrej Karpathy: Well, what is this text anyway in Python? So if you go to the documentation of a string in Python, you can see that strings are immutable sequences of Unicode code points. Okay, what are Unicode code points? We can go to Wikipedia Unicode page. So Unicode code points are defined by the Unicode Consortium as part of the Unicode standard. And what this is really is that it's just a definition of roughly 150,000 characters right now. And roughly speaking, what they look like and what integers, um, represent those characters. So this is 150,000 characters across 161 scripts as of right now. So if you scroll down here, you can see that the standard is very much alive. The latest standard 15.1 is September 2023.

Unicode, formally The Unicode Standard, is a text encoding standard maintained by the Unicode Consortium designed to support the use of text written in all of the world's major writing systems. Version 15.1 of the standard defines 149,813 characters and 161 scripts used in various ordinary, literary, academic, and technical contexts.

[16:31] Andrej Karpathy: And basically, this is just a way to define lots of types of characters, like for example, all these characters across different scripts. So, the way we can access the Unicode code point given a single character is by using the ord function in Python. So for example, I can pass in ord of 'h', and I can see that for the single character 'h', the Unicode code point is 104. Okay? Um, but this can be arbitrarily complicated. So we can take, for example, our emoji here, and we can see that the code point for this one is 128,000. Or we can take "an," and this is 50,000. Now, keep in mind, you can't plug in strings here because, uh, this doesn't have a single code point. It only takes a single Unicode code point character and tells you its integer.

Code

# Get Unicode code point for English character
print(f"ord('h') = {ord('h')}")

# Get Unicode code point for emoji
print(f"ord('🤗') = {ord('🤗')}")

# Get Unicode code point for Korean character
print(f"ord('안') = {ord('안')}")

Output: 90

ord('h') = 104
ord('🤗') = 129303
ord('안') = 50504

Note

ord(character, /)

Return the ordinal value of a character.

If the argument is a one-character string, return the Unicode code point of that character. For example, ord('a') returns the integer 97 and ord('€') (Euro sign) returns 8364. This is the inverse of chr().

If the argument is a bytes or bytearray object of length 1, return its single byte value. For example, ord(b'a') returns the integer 97.

[17:24] Andrej Karpathy: So in this way, we can look up all the, um, characters of this specific string and their code points. So ord(x) for x in this string, and we get this encoding here. Now, see here, we've already turned, the raw code points already have integers. So why can't we simply just use these integers and not have any tokenization at all? Why can't we just use this natively as is and just use the code point?

Code

# Get Unicode code points for each character in the string
text = "안녕하세요 👋 hello world 🤗"
L([ord(x) for x in text])

Output: 93

(#21) [50504,45397,54616,49464,50836,32,128075,32,104,101,108,108,111,32,119,111,114,108,100,32...]

Note

[17:52] Andrej Karpathy: Well, one reason for that, of course, is that the vocabulary in that case would be quite long. So in this case, for Unicode, this is a vocabulary of 150,000 different code points. But more worryingly than that, I think, the Unicode standard is very much alive and it keeps changing. And so it's not kind of a stable representation necessarily that we may want to use directly. So for these reasons, we need something a bit better.

[18:16] Andrej Karpathy: So to find something better, we turn to encodings. So if you go to the Wikipedia page here, we see that the Unicode Consortium defines three types of encodings: UTF-8, UTF-16, and UTF-32. These encodings are the way by which we can take Unicode text and translate it into binary data or byte strings. UTF-8 is by far the most common. So this is the UTF-8 page. Now, this Wikipedia page is actually quite long, but what's important for our purposes is that UTF-8 takes every single code point and it translates it to a byte string. And this byte string is between one to four bytes. So it's a variable-length encoding. So depending on the Unicode point, according to the schema, you're going to end up with between one to four bytes for each code point.

pasted_image

A screenshot of the Wikipedia page for UTF-8, showing a table that maps Unicode code point ranges to their corresponding byte-length representation in UTF-8 (1, 2, 3, or 4 bytes).

[19:02] Andrej Karpathy: On top of that, there's UTF-8, uh, UTF-16, and UTF-32. UTF-32 is nice because it is fixed length instead of variable length, but it has many other downsides as well. So the full kind of spectrum of pros and cons of all these different three encodings are beyond the scope of this video. I'd just like to point out that I enjoyed this blog post, and this blog post at the end of it also has a number of references that can be quite useful. Uh, one of them is "UTF-8 Everywhere Manifesto." Um, and this manifesto describes the reason why UTF-8 is significantly preferred and a lot nicer than the other encodings and why it is used a lot more prominently, um, on the internet. One of the major advantages that's just to give you a sense is that UTF-8 is the only one of these that is backward compatible to the much simpler ASCII encoding of text. Um, but I'm not going to go into the full detail in this video. So suffice to say,

Note

Introduction to UTF-8 Encoding

[20:00] Speaker A: Suffice it to say that we like the UTF-8 encoding. And, uh, let's try to take this string and see what we get if we encode it into UTF-8.

[20:08] Speaker A: The string class in Python actually has .encode, and you can give it the encoding, which is, let's say, UTF-8. Now, what we get out of this is not very nice because this is the bytes, this is a bytes object, and it's not very nice in the way that it's printed. So I personally like to take it through a list because then we actually get the raw bytes of this, uh, encoding.

[20:31] Speaker A: So this is the raw bytes that represent this string according to the UTF-8 encoding.

Comparing UTF-8, UTF-16, and UTF-32

[20:37] Speaker A: We can also look at UTF-16. We get a slightly different byte stream. And here we start to see one of the disadvantages of UTF-16. You see how we have zero, zero something, zero something, zero something. We're starting to get a sense that this is a bit of a wasteful encoding. And indeed, for simple ASCII characters or English characters here, uh, we just have this structure of zero something, zero something, and it's not exactly nice.

[21:02] Speaker A: Same for UTF-32. When we expand this, we can start to get a sense of the wastefulness of this encoding for our purposes. You see a lot of zeros followed by something. And so, uh, this is not desirable.

Code

text = "안녕하세요 👋 hello world 🤗"

# UTF-8 encoding
utf8_bytes = list(text.encode('utf-8'))
print(f"UTF-8: {utf8_bytes}")

# UTF-16 encoding  
utf16_bytes = list(text.encode('utf-16'))
print(f"UTF-16: {utf16_bytes}")

# UTF-32 encoding  
utf32_bytes = list(text.encode('utf-32'))
print(f"UTF-32: {utf32_bytes}")

Output: 814

UTF-8: [236, 149, 136, 235, 133, 149, 237, 149, 152, 236, 132, 184, 236, 154, 148, 32, 240, 159, 145, 139, 32, 104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 32, 240, 159, 164, 151]
UTF-16: [255, 254, 72, 197, 85, 177, 88, 213, 56, 193, 148, 198, 32, 0, 61, 216, 75, 220, 32, 0, 104, 0, 101, 0, 108, 0, 108, 0, 111, 0, 32, 0, 119, 0, 111, 0, 114, 0, 108, 0, 100, 0, 32, 0, 62, 216, 23, 221]
UTF-32: [255, 254, 0, 0, 72, 197, 0, 0, 85, 177, 0, 0, 88, 213, 0, 0, 56, 193, 0, 0, 148, 198, 0, 0, 32, 0, 0, 0, 75, 244, 1, 0, 32, 0, 0, 0, 104, 0, 0, 0, 101, 0, 0, 0, 108, 0, 0, 0, 108, 0, 0, 0, 111, 0, 0, 0, 32, 0, 0, 0, 119, 0, 0, 0, 111, 0, 0, 0, 114, 0, 0, 0, 108, 0, 0, 0, 100, 0, 0, 0, 32, 0, 0, 0, 23, 249, 1, 0]

Note

[21:16] Speaker A: So, suffice it to say that we would like to stick with UTF-8 for our purposes. However, if we just use UTF-8 naively, these are byte streams. So that would imply a vocabulary length of only 256 possible tokens. Uh, but this, this vocabulary size is very, very small. What this is going to do if we just were to use it naively is that all of our text would be stretched out over very, very long sequences of bytes.

[21:44] Speaker A: And so, um, what what this does is that certainly the embedding table is going to be tiny, and the prediction at the top at the final layer is going to be very tiny, but our sequences are very long. And remember that we have pretty finite, um, context length in the attention that we can support in a transformer for computational reasons. And so we only have that much context length, but now we have very, very long sequences, and this is just inefficient, and it's not going to allow us to attend to sufficiently long text, uh, before us for the purposes of the next token prediction task.

[22:18] Speaker A: So we don't want to use the raw bytes of the UTF-8 encoding. We want to be able to support larger vocabulary size that we can tune as a hyperparameter, but we want to stick with the UTF-8 encoding of these strings. So what do we do?

Note

Introducing Byte Pair Encoding (BPE)

[22:34] Speaker A: Well, the answer, of course, is we turn to the Byte Pair Encoding algorithm, which will allow us to compress these byte sequences, um, to a variable amount. So we'll get to that in a bit, but I just want to briefly speak to the fact that I would love nothing more than to be able to feed raw byte sequences into, uh, language models. In fact, there's a paper about how this could potentially be done, uh, from the summer last year.

[22:58] Speaker A: Now, the problem is you have to go in and you have to modify the transformer architecture because, as I mentioned, you're going to have a problem where the attention will start to become extremely expensive because the sequences are so long. And so in this paper, they propose kind of a hierarchical structuring of the transformer that could allow you to just feed in raw bytes. And so at the end, they say, "Together, these results establish the viability of tokenization-free autoregressive sequence modeling at scale." So tokenization-free would indeed be amazing. We would just feed byte streams directly into our models. But unfortunately, I don't know that this has really been proven out yet by sufficiently many groups at sufficient scale. Uh, but something like this at one point would be amazing, and I hope someone comes up with it. But for now, we have to come back, and we can't feed this directly into language models, and we have to compress it using the Byte Pair Encoding algorithm. So let's see how that works.

MEGABYTE: Predicting Million-byte Sequences with Multiscale Transformers

Note

How Byte Pair Encoding Works

[23:50] Speaker A: So as I mentioned, the Byte Pair Encoding algorithm is not all that complicated, and the Wikipedia page is actually quite instructive as far as the basic idea goes. What we're doing is we have some kind of an input sequence. Uh, like, for example, here we have only four elements in our vocabulary: a, b, c, and d. And we have a sequence of them. So instead of bytes, let's say we just have four, a vocab size of four.

[24:12] Speaker A: This sequence is too long, and we'd like to compress it. So what we do is that we iteratively find the pair of, uh, tokens that occur the most frequently. And then once we've identified that pair, we replace that pair with just a single new token that we append to our vocabulary. So for example, here, the byte pair 'aa' occurs most often. So we mint a new token, let's call it capital Z, and we replace every single occurrence of 'aa' by Z. So now we have two Z's here.

[24:47] Speaker A: So here, we took a sequence of 11 characters with vocabulary size four, and we've converted this to a, um, sequence of only nine tokens, but now with a vocabulary of five, because we have a fifth vocabulary element that we just created, and it's Z, standing for concatenation of 'aa'. And we can again repeat this process. So we again look at the sequence and identify the, uh, pair of tokens that are most frequent. Let's say that that is now 'ab'. Well, we are going to replace 'ab' with a new token that we mint, called Y. So Y becomes 'ab', and then every single occurrence of 'ab' is now replaced with Y. So we end up with this.

[25:30] Speaker A: So now we only have 1, 2, 3, 4, 5, 6, 7 characters in our sequence, but we have not just, um, four vocabulary elements, or five, but now we have six. And for the final round, we again look through the sequence, find that the phrase 'ZY' or the pair 'ZY' is most common, and replace it one more time with another, um, character, let's say X. So X is 'ZY', and we replace all occurrences of 'ZY', and we get this following sequence.

[26:02] Speaker A: So basically, after we've gone through this process, instead of having a, um, sequence of 11, uh, tokens with a vocabulary length of four, we now have a sequence of 1, 2, 3, 4, 5 tokens, but our vocabulary length now is seven. And so in this way, we can iteratively compress our sequence as we mint new tokens. So in the exact same way, we start, we start off 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. And in this way, we're going to end up with a compressed training dataset and also an algorithm for taking any arbitrary sequence and encoding it using this, uh, vocabulary, and also decoding it back to strings. So let's now implement all that.

Step 1: Initial sequence

aaabdaaabac

Most frequent pair: aa (occurs 2 times) Replace aa with Z:

Zabdaabac → ZabdZabac

Step 2: Continue compression

ZabdZabac

Most frequent pair: ab (occurs 2 times)
Replace ab with Y:

ZYdZYac

Step 3: Final merge

ZYdZYac

Most frequent pair: ZY (occurs 2 times) Replace ZY with X:

XdXac

Final result: XdXac

Final vocabulary: {a, b, c, d, Z=aa, Y=ab, X=ZY} Original length: 11 tokens → Compressed length: 5 tokens

[27:03] Speaker A: So here's what I did. I went to this blog post that I enjoyed, and I took the first paragraph, and I copy-pasted it here into text. So this is one very long line here.

[27:14] Speaker A: Now, to get the tokens, as I mentioned, we just take our text and we encode it into UTF-8. The tokens here at this point will be our raw bytes, single stream of bytes. And just so that it's easier to work with, instead of just a bytes object, I'm going to convert all those bytes to integers and then create a list of it, just so it's easier for us to manipulate and work with in Python and visualize. And here I'm printing all of that. So this is the original, um, this is the original paragraph, and its length is 533, uh, code points. And then here are the bytes encoded in UTF-8, and we see that this has a length of 616 bytes at this point, or 616 tokens. And the reason this is more is because a lot of these simple ASCII characters or simple characters, they just become a single byte, but a lot of these Unicode, more complex characters become multiple bytes, up to four, and so we are expanding that size.

[28:13] Speaker A: So now what we'd like to do as a first step of the algorithm is we'd like to iterate over here and find the pair of bytes that occur most frequently, because we're then going to merge it. So if you are working along on the notebook on the side, then I encourage you to basically click on the link, find this notebook, and try to write that function yourself. Otherwise, I'm going to come here and implement first the function that finds the most common pair.

[28:36] Speaker A: Okay, so here's what I came up with. There are many different ways to implement this, but I'm calling the function get_stats. It expects a list of integers. I'm using a dictionary to keep track of basically the counts. And then this is a Pythonic way to iterate consecutive elements of this list, uh, which we covered in the previous video. And then here, I'm just keeping track of, just incrementing by one, um, for all the pairs. So if I call this on all the tokens here, then the stats comes out here. So this is a dictionary. The keys are these tuples of consecutive elements, and this is the count.

[29:11] Speaker A: So just to, uh, print it in a slightly better way, this is one way that I like to do that, where you, it's a little bit compound here, so you can pause if you like. But we iterate over all the items. The .items() called on dictionary returns pairs of key-value. And instead, I create a list here of value-key, because if it's a value-key list, then I can call sort() on it. And by default, Python will, uh, use the first element, which in this case will be value, to sort by if it's given tuples. And then reverse, so it's descending, and print that.

[29:50] Speaker A: So basically, it looks like 101, 32 was the most commonly occurring consecutive pair, and it occurred 20 times. We can double check that that makes reasonable sense. So if I just search 101, 32, then you see that these are the 20 occurrences of that, um, pair.

[30:09] Speaker A: And if we'd like to take a look at what exactly that pair is, we can use chr, which is the opposite of ord in Python. So we give it a, um, Unicode code point, so 101 and of 32, and we see that this is 'e' and 'space'. So basically, there's a lot of 'e space' here, meaning that a lot of these words seem to end with 'e'. So here's 'e space' as an example. So there's a lot of that going on here, and this is the most common pair.

[30:36] Speaker A: So now that we've identified the most common pair, we would like to iterate over the sequence. We're going to mint a new token with the ID of 256, right? Because these tokens currently go from 0 to 255. So when we create a new token, it will have an ID of 256. And we're going to iterate over this entire, um, list, and every, every time we see 101, 32, we're going to swap that out for 256. So let's implement that now, and feel free to, uh, do that yourself as well.

[31:10] Speaker A: So first, I commented, uh, this just so we don't pollute, uh, the notebook too much. This is a nice way of in Python obtaining the highest ranking pair. So we're basically calling the max on this dictionary stats, and this will return the maximum key. And then the question is, how does it rank keys? So you can provide it with a function that ranks keys, and that function is just stats.get. Uh, stats.get would basically return the value. And so we're ranking by the value and getting the maximum key. So it's 101, 32, as we saw.

Code

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}
    Optionally allows to update an existing dictionary of counts
    """
    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

Code

# Step 1: Get the sample text from Nathan Reed's blog post
text = """Unicode is a standard for encoding and representing text in computers. It was created to solve the problem of multiple incompatible character encodings that existed before it. In the early days of computing, different regions and manufacturers developed their own ways to encode text, leading to a fragmented landscape where text that looked fine on one system would appear as gibberish on another."""

print(f"Text: {text}")
print(f"Length in characters: {len(text)}")

Output: 138

Text: Unicode is a standard for encoding and representing text in computers. It was created to solve the problem of multiple incompatible character encodings that existed before it. In the early days of computing, different regions and manufacturers developed their own ways to encode text, leading to a fragmented landscape where text that looked fine on one system would appear as gibberish on another.
Length in characters: 398

Code

# Step 2: Encode the text to UTF-8 bytes and convert to list of integers
tokens = list(text.encode("utf-8"))
print(f"UTF-8 encoded bytes: {tokens[:50]}...")  # Show first 50 bytes
print(f"Length in bytes: {len(tokens)}")

Output: 264

UTF-8 encoded bytes: [85, 110, 105, 99, 111, 100, 101, 32, 105, 115, 32, 97, 32, 115, 116, 97, 110, 100, 97, 114, 100, 32, 102, 111, 114, 32, 101, 110, 99, 111, 100, 105, 110, 103, 32, 97, 110, 100, 32, 114, 101, 112, 114, 101, 115, 101, 110, 116, 105, 110]...
Length in bytes: 398

Code

# Step 3: Find the most common consecutive pair using get_stats
stats = get_stats(tokens)
print(f"Total number of unique pairs: {len(stats)}")

# Show top 10 most frequent pairs
top_pairs = sorted([(count, pair) for pair, count in stats.items()], reverse=True)[:10]
print("\nTop 10 most frequent pairs:")
for count, pair in top_pairs:
    print(f"  {pair}: {count} times")

Output: 207

Total number of unique pairs: 165

Top 10 most frequent pairs:
  (101, 32): 12 times
  (32, 116): 11 times
  (116, 101): 9 times
  (100, 32): 9 times
  (115, 32): 8 times
  (114, 101): 8 times
  (105, 110): 8 times
  (101, 114): 7 times
  (99, 111): 7 times
  (32, 97): 7 times

Code

# Step 3a: Understand how zip(ids, ids[1:]) works for consecutive pairs
sample_list = [1, 2, 3, 4, 5]
consecutive_pairs = list(zip(sample_list, sample_list[1:]))
print(f"Sample list: {sample_list}")
print(f"Consecutive pairs: {consecutive_pairs}")
print("This is the 'Pythonic way' Andrej mentions for iterating consecutive elements")

Output: 121

Sample list: [1, 2, 3, 4, 5]
Consecutive pairs: [(1, 2), (2, 3), (3, 4), (4, 5)]
This is the 'Pythonic way' Andrej mentions for iterating consecutive elements

Code

# Step 4: Get the most frequent pair using max() function
most_frequent_pair = max(stats, key=stats.get)
print(f"Most frequent pair: {most_frequent_pair}")
print(f"Occurs {stats[most_frequent_pair]} times")

# Convert bytes back to characters to see what this pair represents
char1 = chr(most_frequent_pair[0])
char2 = chr(most_frequent_pair[1])
print(f"This represents: '{char1}' + '{char2}'")

Output: 70

Most frequent pair: (101, 32)
Occurs 12 times
This represents: 'e' + ' '

Code

# Step 4a: Verify the most frequent pair by finding its occurrences in the text
pair_to_find = most_frequent_pair  # (101, 32) which is 'e' + ' '

# Find all positions where this pair occurs
occurrences = []
for i in range(len(tokens) - 1):
    if tokens[i] == pair_to_find[0] and tokens[i + 1] == pair_to_find[1]:
        occurrences.append(i)

print(f"Found {len(occurrences)} occurrences of pair {pair_to_find} ('e' + ' ') at positions:")
print(f"Positions: {occurrences}")

Output: 124

Found 12 occurrences of pair (101, 32) ('e' + ' ') at positions:
Positions: [6, 93, 97, 117, 130, 170, 181, 277, 317, 323, 345, 352]

Code

# Step 5: Prepare to merge - create new token ID
# Current tokens are 0-255 (256 possible values), so new token will be 256
new_token_id = 256
print(f"Will replace pair {most_frequent_pair} with new token ID: {new_token_id}")
print(f"Ready to implement merge function...")

Output: 52

Will replace pair (101, 32) with new token ID: 256
Ready to implement merge function...

Note

[48:22] Speaker A: Okay, and now we're going to go the other way. So we are going to implement this arrow right here, where we are going to be given a string and we want to encode it into tokens.

[48:32] Speaker A: So this is the signature of the function that we're interested in. And uh, this should basically print a list of integers of the tokens. So again, uh, try to maybe implement this yourself if you'd like a fun exercise. Uh, and pause here, otherwise I'm going to start putting in my solution. So again, there are many ways to do this. So, um, this is one of the ways that sort of I came up with. So the first thing we're going to do is we are going to take our text, encode it into UTF-8 to get the raw bytes. And then as before, we're going to call list on the bytes object to get a list of integers of those bytes. So those are the starting tokens, those are the raw bytes of our sequence.

[49:15] Speaker A: But now, of course, according to the merges dictionary above, and recall this was the merges, some of the bytes may be merged according to this lookup. And in addition to that, remember that the 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 um, for example, this merge over here relies on the 256 which got merged here. So we have to go in the order from top to bottom sort of if we are going to be merging anything.

[49:49] Speaker A: Now, we expect to be doing a few merges, so we're going to be doing while true. Um, and now we want to find a pair of bytes that is consecutive that we are allowed to merge according to this. In order to reuse some of the functionality that we've already written, I'm going to reuse the function uh, get_stats.

[50:10] Speaker A: So recall that get stats uh, will give us the, will basically count up how many times every single pair occurs in our sequence of tokens and return that as a dictionary. And the dictionary was a mapping from all the different uh, byte pairs to the number of times that they occur, right? Uh, 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. And so I'm only going to be using basically the keys of this dictionary. I only care about the set of possible merge candidates, if that makes sense.

[50:44] Speaker A: Now we want to identify the pair that we're going to be merging at this stage of the loop. So what do we want? We want to find the pair or like the a key inside stats that has the lowest index in the merges uh, dictionary because we want to do all the early merges before we work our way to the late merges. So again, there are many different ways to implement this, but I'm going to do something a little bit fancy here.

[51:11] Speaker A: So I'm going to be using the min over an iterator. In Python, when you call min on an iterator and stats here is a dictionary, we're going to be iterating the keys of this dictionary in Python. So we're looking at all the pairs inside stats, um, which are all the consecutive pairs. And we're going to be taking the consecutive pair inside tokens that has the minimum what? The min takes a key which gives us the function that is going to return a value over which we're going to do the min. And the one we care about is we're we care about taking merges and basically getting um, that pair's index.

[51:53] Speaker A: So basically for any pair inside stats, we are going to be looking into merges at what index it has. And we want to get the pair with the min number. So for an example, if there's a pair 101 and 32, we definitely want to get that pair. We want to identify it here and return it, and pair would become 101, 32 if it occurs. And the reason that I'm putting a float inf here as a fallback is that in the get function, when we call uh, when we basically consider a pair that doesn't occur in the merges, then that pair is not eligible to be merged, right? So if in the token sequence there's some pair that is not a merging pair, it cannot be merged, then uh, it doesn't actually occur here and it doesn't have an index and uh, it can't be merged, which we will denote as float inf. And 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. So, uh, so this is one way to do it.

[52:55] Speaker A: So basically, in one short, this returns the most eligible merging candidate pair uh, that occurs in the tokens. Now, one thing to be careful with here is this uh, function here might fail in the following way. If there's nothing to merge, then uh, then there's nothing in merges um, that is satisfied anymore. There's nothing to merge. Everything just returns float inf and then uh, the pair, I think will just become the very first element of stats. Um, but this pair is not actually a mergeable pair. It just becomes the first pair in stats arbitrarily because all these pairs evaluate to float inf for the merging criterion. So basically it could be that this this doesn't look succeed because there's no more merging pairs. So 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, we will break out. Um, nothing else can be merged.

[53:58] Speaker A: You may come up with a different implementation by the way. This is kind of like really uh, trying hard in Python. Um, but really we're just trying to find a pair that can be merged with a lowest index here. Now, if we did find a pair that is inside merges with the lowest index, then we can merge it. So we're going to look into the merges dictionary for that pair to look up the index, and we're going to now merge that into that index. So we're going to do tokens equals, we're going to replace the original tokens, we're going to be replacing the pair pair, and we're going to be replacing it with index idx. And this returns a new list of tokens where every occurrence of pair is replaced with idx. So we're doing a merge.

[54:46] Speaker A: And we're going to be continuing this until eventually nothing can be merged. We'll come out here and we'll break out. And here we just return tokens. And so that's the implementation I think. So hopefully this runs. Okay, cool. Um, yeah, and this looks uh, reasonable. So for example, 32 is a space in ASCII, so that's here. Um, so this looks like it worked. Great.

Code

# Step 6: Implement the merge function
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  # skip over the pair
        else:
            newids.append(ids[i])
            i += 1
    return newids

Code

# Test with simple example
test_ids = [5, 6, 6, 7, 9, 1]
result = merge(test_ids, (6, 7), 99)
print(f"Original: {test_ids}")
print(f"After merging (6, 7) -> 99: {result}")

Output: 93

Original: [5, 6, 6, 7, 9, 1]
After merging (6, 7) -> 99: [5, 6, 99, 9, 1]

Code

# Step 7: Apply merge to our actual tokens
# Merge the most frequent pair (101, 32) with token ID 256
tokens2 = merge(tokens, most_frequent_pair, new_token_id)

print(f"Original length: {len(tokens)}")
print(f"After merge length: {len(tokens2)}")
print(f"Reduction: {len(tokens) - len(tokens2)} tokens")

# Verify the merge worked
print(f"\nOccurrences of new token {new_token_id}: {tokens2.count(new_token_id)}")
print(f"Occurrences of old pair in original: {sum(1 for i in range(len(tokens)-1) if (tokens[i], tokens[i+1]) == most_frequent_pair)}")

# Verify old pair is gone
old_pair_count = sum(1 for i in range(len(tokens2)-1) if (tokens2[i], tokens2[i+1]) == most_frequent_pair)
print(f"Occurrences of old pair in new tokens: {old_pair_count}")

Output: 93

Original length: 398
After merge length: 386
Reduction: 12 tokens

Occurrences of new token 256: 12
Occurrences of old pair in original: 12
Occurrences of old pair in new tokens: 0

Code

# Step 8: Iterate the BPE algorithm
# Now we repeat: find most common pair, merge it, repeat...
# Let's do a few more iterations

current_tokens = tokens2
vocab_size = 257  # Started with 256, now have 257

print("BPE Training Progress:")
print(f"Step 0: {len(tokens)} tokens, vocab size: 256")
print(f"Step 1: {len(current_tokens)} tokens, vocab size: {vocab_size}")

# Do a few more iterations
for step in range(2, 6):  # Steps 2-5
    # Find most common pair
    stats = get_stats(current_tokens)
    if not stats:  # No more pairs to merge
        break
    
    most_frequent_pair = max(stats, key=stats.get)
    
    # Merge it
    current_tokens = merge(current_tokens, most_frequent_pair, vocab_size)
    
    print(f"Step {step}: {len(current_tokens)} tokens, vocab size: {vocab_size + 1}")
    print(f"  Merged pair: {most_frequent_pair} -> {vocab_size}")
    
    vocab_size += 1

print(f"\nFinal: {len(current_tokens)} tokens, vocab size: {vocab_size}")

Output: 277

BPE Training Progress:
Step 0: 398 tokens, vocab size: 256
Step 1: 386 tokens, vocab size: 257
Step 2: 377 tokens, vocab size: 258
  Merged pair: (100, 32) -> 257
Step 3: 368 tokens, vocab size: 259
  Merged pair: (116, 101) -> 258
Step 4: 360 tokens, vocab size: 260
  Merged pair: (115, 32) -> 259
Step 5: 352 tokens, vocab size: 261
  Merged pair: (105, 110) -> 260

Final: 352 tokens, vocab size: 261

Code

# Track the merges we made
merges = {
    256: (101, 32),  # 'e' + ' '
    257: (100, 32),  # 'd' + ' '  
    258: (116, 101), # 't' + 'e'
    259: (115, 32),  # 's' + ' '
    260: (105, 110)  # 'i' + 'n'
}

for token_id, (byte1, byte2) in merges.items():
    char1, char2 = chr(byte1), chr(byte2)
    print(f"Token {token_id}: ({byte1}, {byte2}) -> '{char1}' + '{char2}' = '{char1}{char2}'")

Output: 324

Token 256: (101, 32) -> 'e' + ' ' = 'e '
Token 257: (100, 32) -> 'd' + ' ' = 'd '
Token 258: (116, 101) -> 't' + 'e' = 'te'
Token 259: (115, 32) -> 's' + ' ' = 's '
Token 260: (105, 110) -> 'i' + 'n' = 'in'

Note

Training the Tokenizer

[34:58] Speaker A: Okay, now before we dive into the while loop, I wanted to add one more cell here where I went to the blog post, and instead of grabbing just the first paragraph or two, I took the entire blog post, and I stretched it out in a single line. And basically, just using longer text will allow us to have more representative statistics for the byte pairs, and we'll just get a more sensible result out of it, uh, because it's longer text. So here we have the raw text, we encode it into bytes using the UTF-8 encoding. And then here, as before, we are just changing it into a list of integers in Python just so it's easier to work with instead of the raw bytes object.

[35:36] Speaker A: And then this is the code that I came up with, uh, to actually do the merging in loop. These two functions here are identical to what we had above. I only included them here just so that you have the point of reference here. So, uh, these two are identical, and then this is the new code that I added. So the first thing we want to do is we want to decide on a final vocabulary size that we want our tokenizer to have. And as I mentioned, this is a hyperparameter, and you set it in some way depending on your best performance. So let's say for us, we're going to use 276 because that way we're going to be doing exactly 20 merges. And, uh, 20 merges because we already have 256, uh, tokens for the raw bytes. And to reach 276, we have to do 20 merges, uh, to add 20 new tokens.

[36:26] Speaker A: Here, uh, this is, uh, one way in Python to just create a copy of the list. So I'm taking the tokens list, and by wrapping it in a list, Python will construct a new list of all the individual elements. So this is just a copy operation. Then here, I'm creating a merges uh, dictionary. So this merges dictionary is going to maintain basically the child one, child two mapping to a new, uh, token. And so 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 like merge two of them at a time. And so it's not a tree, it's more like a forest, um, as we merge these elements.

[37:18] Speaker A: So for 20 merges, we're going to find the most commonly occurring pair. We're going to mint a new token integer for it. So i here will start at zero, so we'll start with 256. We're going to print that we're merging it, and we're going to replace all the occurrences of that pair with the new, newly minted token. And we're going to record that this pair of integers merged into this new integer. So running this gives us the following output.

[37:51] Speaker A: So we did 20 merges. And for example, the first merge was exactly as before, the 101, 32, uh, tokens merging into a new token 256. Now, keep in mind that the individual, uh, tokens 101 and 32 can still occur in the sequence after merging. It's only when they occur exactly consecutively that that becomes 256 now. Um, and in particular, the other thing to notice here is that the token 256, which is the newly minted token, is also eligible for merging. So here on the bottom, the 20th merge was a merge of 256 and 259 becoming 275. So every time we replace these tokens, they become eligible for merging in the next round of the iteration. So that's why we're building up a small sort of binary forest instead of a single individual tree.

[38:41] Speaker A: One thing we can take a look at as well is we can take a look at the compression ratio that we've achieved. So in particular, we started off with this tokens list. Um, so we started off with 24,000 bytes, and after merging 20 times, uh, we now have only 19,000, um, tokens. And so therefore, the compression ratio is simply just dividing the two is roughly 1.27. So that's the amount of compression we were able to achieve of this text with only 20 merges. Um, and of course, the more vocabulary elements you add, uh, the greater the compression ratio here would be.

Code

tokens = list(text.encode("utf-8"))
print(f"UTF-8 encoded bytes: {tokens[:50]}...")  # Show first 50 bytes
print(f"Length in bytes: {len(tokens)}")

Output: 264

UTF-8 encoded bytes: [85, 110, 105, 99, 111, 100, 101, 32, 105, 115, 32, 97, 32, 115, 116, 97, 110, 100, 97, 114, 100, 32, 102, 111, 114, 32, 101, 110, 99, 111, 100, 105, 110, 103, 32, 97, 110, 100, 32, 114, 101, 112, 114, 101, 115, 101, 110, 116, 105, 110]...
Length in bytes: 398

Code

# BPE training
vocab_size = 276  # hyperparameter: the desired final vocabulary size
num_merges = vocab_size - 256
tokens = list(text.encode("utf-8"))

for i in range(num_merges):
    # count up all the pairs
    stats = get_stats(tokens)
    # find the pair with the highest count
    pair = max(stats, key=stats.get)
    # mint a new token: assign it the next available id
    idx = 256 + i
    # replace all occurrences of pair in tokens with idx
    tokens = merge(tokens, pair, idx)
    # print progress
    print(f"merge {i+1}/{num_merges}: {pair} -> {idx} ({stats[pair]} occurrences)")

Output: 678

merge 1/20: (101, 32) -> 256 (12 occurrences)
merge 2/20: (100, 32) -> 257 (9 occurrences)
merge 3/20: (116, 101) -> 258 (9 occurrences)
merge 4/20: (115, 32) -> 259 (8 occurrences)
merge 5/20: (105, 110) -> 260 (8 occurrences)
merge 6/20: (99, 111) -> 261 (7 occurrences)
merge 7/20: (97, 110) -> 262 (6 occurrences)
merge 8/20: (101, 110) -> 263 (6 occurrences)
merge 9/20: (116, 32) -> 264 (6 occurrences)
merge 10/20: (116, 104) -> 265 (6 occurrences)
merge 11/20: (260, 103) -> 266 (5 occurrences)
merge 12/20: (114, 101) -> 267 (5 occurrences)
merge 13/20: (261, 100) -> 268 (4 occurrences)
merge 14/20: (97, 114) -> 269 (4 occurrences)
merge 15/20: (32, 111) -> 270 (4 occurrences)
merge 16/20: (101, 114) -> 271 (4 occurrences)
merge 17/20: (32, 263) -> 272 (3 occurrences)
merge 18/20: (272, 268) -> 273 (3 occurrences)
merge 19/20: (266, 32) -> 274 (3 occurrences)
merge 20/20: (258, 120) -> 275 (3 occurrences)

Note

[39:19] Speaker A: Finally, so that's kind of like, um, the training of the tokenizer, if you will. Now, one point that I wanted to make is that, and maybe this is a diagram that can help, um, kind of illustrate, is that the tokenizer is a completely separate object from the large language model itself. So everything in this lecture, we're not really touching the LLM itself. Uh, we're just training the tokenizer. That is a completely separate preprocessing stage usually. So the tokenizer will have its own training set, just like a large language model has a potentially different training set. So the tokenizer has a training set of documents on which you're going to train the tokenizer. And then, um, we're performing the Byte Pair Encoding algorithm as we saw above.

pasted_image

A diagram illustrating the data pipeline: 'Raw text (Unicode code point sequence)' goes into a 'Tokenizer', which outputs a 'token sequence' that is then fed into the 'LLM'.

[40:00] Speaker A: allows to train the vocabulary of this tokenizer. So it has its own training set, it has a pre-processing stage that you would run a single time in the beginning. Um, and the tokenizer is trained using byte-pair encoding algorithm. Once you have the tokenizer, once it's trained and you have the vocabulary and you have the merges, uh, we can do both encoding and decoding. So these two arrows here. So the tokenizer is a translation layer between raw text, which is, as we saw, the sequence of Unicode code points. It can take raw text and turn it into a token sequence. And vice versa, it can take a token sequence and translate it back into raw text.

[40:41] Speaker A: So now that we have trained the uh, tokenizer and we have these merges, we are going to turn to how we can do the encoding and the decoding step. If you give me text, here are the tokens, and vice versa, if you give me tokens, here's the text. Once we have that, we can translate between these two realms. And then the language model is going to be trained as a step two afterwards. And typically in a, in a sort of a state-of-the-art application, you might take all of your training data for the language model and you might run it through the tokenizer and sort of translate everything into a massive token sequence. And then you can throw away the raw text. You're just left with the tokens themselves. And those are stored on disk and that is what the large language model is actually reading when it's training on them. So that's one approach that you can take as a single massive pre-processing stage.

[41:27] Speaker A: Um, so, yeah, basically, I think the most important thing I want to get across is that this is a completely separate stage. It usually has its own entire uh, training set. You may want to have those training sets be different between the tokenizer and the large language model. So for example, when you're training the tokenizer, as I mentioned, we don't just care about the performance of English text, we care about uh, multi many different languages. And we also care about code or not code. So you may want to look into different kinds of mixtures of different kinds of languages and different amounts of code and things like that, uh, because the amount of different language that you have in your tokenizer training set will determine how many merges of it there will be. And therefore that determines the density with which uh, this type of data is um, sort of has in the token space.

[42:17] Speaker A: And so, roughly speaking, intuitively, if you add some amount of data, let's say you have a ton of Japanese data in your uh, tokenizer training set, then that means that more Japanese tokens will get merged and therefore Japanese will have shorter sequences. Uh, and that's going to be beneficial for the large language model, which has a finite context length on which it can work on in in the token space. Uh, so hopefully that makes sense. So we're now going to turn to encoding and decoding now that we have trained a tokenizer. So we have our merges and now how do we do encoding and decoding?

Note

Decoding

[42:48] Speaker A: Okay, so let's begin with decoding, which is this arrow over here. So given a token sequence, let's go through the tokenizer to get back a Python string object. So the raw text.

[42:58] Speaker A: So this is the function that we'd like to implement. Um, we're given the list of integers and we want to return a Python string. If you'd like, uh, try to implement this function yourself. It's a fun exercise. Otherwise, I'm going to start uh, pasting in my own solution.

[43:12] Speaker A: So there are many different ways to do it. Um, here's one way. I will create an uh, kind of pre-processing variable that I will call vocab. And vocab is a mapping or a dictionary in Python from the token uh, ID to the bytes object for that token. So we begin with the raw bytes for tokens from 0 to 255. And then we go in order of all the merges and we sort of uh, populate this vocab list by doing an addition here. So this is the basically the bytes representation of the first child followed by the second one. And remember these are bytes objects, so this addition here is an addition of two bytes objects, just concatenation. So that's what we get here.

[43:59] Speaker A: One tricky thing to be careful with by the way is that I'm iterating a dictionary in Python using a .items() and uh, it really matters that this runs in the order in which we inserted items into the merges dictionary. 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. But we are using a modern Python, so we're okay.

[44:28] Speaker A: And then here, uh, given the IDs, the first thing we're going to do is get the tokens. So the way I implemented this here is I'm taking, I'm iterating over all the IDs, I'm using vocab to look up their bytes, and then here, this is one way in Python to concatenate all these bytes together to create our tokens. And then these tokens here at this point are raw bytes. So I have to decode using UTF-8 now back into Python strings. So previously we called .encode() on a string object to get the bytes, and now we're doing its opposite. We're taking the bytes and calling a decode on the bytes object to get a string in Python. And then we can return text.

[45:16] Speaker A: So, um, this is how we can do it. Now, this actually has a uh, issue, um, in the way I implemented it, and this could actually throw an error. So try to think figure out why this code could actually result in an error if we plug in um, some sequence of IDs that is unlucky. So let me demonstrate the issue. When I try to decode just something like 97, I am going to get the letter a here back. So nothing too crazy happening. But when I try to decode 128 as a single element, the token 128 is what in string or in Python object? UnicodeDecodeError. UTF-8 can't decode byte um, 0x80, which is this in hex, at position zero, invalid start byte. What does that mean?

Code

# Track the merges we made
merges = {
    (101, 32) : 256,  # 'e' + ' '
    (100, 32) : 257,  # 'd' + ' '  
    (116, 101) : 258, # 't' + 'e'
    (115, 32) : 259,  # 's' + ' '
    (105, 110): 260  # 'i' + 'n'
}
# given ids (list of integers), return Python string
vocab = {idx: bytes([idx]) for idx in range(256)}
for (p0, p1), idx in merges.items():
    vocab[idx] = vocab[p0] + vocab[p1]

def decode(ids):
    # given ids, get tokens
    tokens = b"".join(vocab[idx] for idx in ids)
    # convert from bytes to string
    text = tokens.decode("utf-8")
    return text

Code

print(decode([97]))  # Should work fine

Output: 21

a

Code

try:print(decode([128]))  # This will cause UnicodeDecodeError
except Exception as e: print(str(e))

Output: 66

'utf-8' codec can't decode byte 0x80 in position 0: invalid start byte

Note

[46:03] Speaker A: Well, to understand what this means, we have to go back to our UTF-8 page uh, that I briefly showed earlier, and this is Wikipedia UTF-8. And basically there's a specific schema that UTF-8 bytes take. So in particular, if you have a multi-byte object for some of the Unicode characters, they have to have this special sort of envelope in how the encoding works. And so what's happening here is that invalid start byte, that's because 128, the binary representation of it is one followed by all zeros. So we have one and then all zero. And we see here that that doesn't conform to the format because one followed by all zero just doesn't fit any of these rules, so to speak. So it's an invalid start byte, which is byte one. This one must have a one following it, and then a zero following it, and then the content of your Unicode in Xs here.

[46:57] Speaker A: So basically we don't um, exactly follow the UTF-8 standard and this cannot be decoded. And so the way to fix this, um, is to use this errors equals in bytes.decode function of Python. And by default, errors is strict. So we will throw an error if um, it's not valid UTF-8 byte encoding. But there are many different things that you can put here on error handling. This is the full list of all the errors that you can use. And in particular, instead of strict, let's change it to replace. And that will replace with this special marker, this replacement character. So errors equals replace. And now we just get that character back.

[47:43] Speaker A: So basically 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 to basically uh, use errors equals replace. And this is what you will also find in the OpenAI um, code that they released as well. But basically whenever you see a this kind of a character in your output in that case, uh, something went wrong and the LLM output was not valid uh, sort of sequence of tokens.

bytes.decode(encoding='utf-8', errors='strict')

Return the bytes decoded to a str.

encoding defaults to 'utf-8'; see Standard Encodings for possible values.

errors controls how decoding errors are handled. If 'strict' (the default), a UnicodeError exception is raised. Other possible values are:

  • 'strict': Raise UnicodeError (or a subclass); this is the default
  • 'ignore': Ignore the character and continue with the next
  • 'replace': Replace with a suitable replacement marker; Python will use the official U+FFFD REPLACEMENT CHARACTER for the built-in codecs on decoding
  • 'xmlcharrefreplace': Replace with the appropriate XML character reference
  • 'backslashreplace': Replace with backslashed escape sequences

For performance reasons, the value of errors is not checked for validity unless an encoding error actually occurs.

Code

def decode(ids):
    # given ids (list of integers), return Python string
    tokens = b"".join(vocab[idx] for idx in ids)
    text = tokens.decode("utf-8", errors="replace")
    return text

Code

try:print(decode([128]))  # This should now print the replacement character without error
except Exception as e: print(str(e))

Output: 19

Note

Encoding

[48:22] Speaker A: Okay, and now we're going to go the other way. So we are going to implement this arrow right here, where we are going to be given a string and we want to encode it into tokens.

[48:32] Speaker A: So this is the signature of the function that we're interested in. And uh, this should basically print a list of integers of the tokens. So again, uh, try to maybe implement this yourself if you'd like a fun exercise. Uh, and pause here, otherwise I'm going to start putting in my solution. So again, there are many ways to do this. So, um, this is one of the ways that sort of I came up with. So the first thing we're going to do is we are going to take our text, encode it into UTF-8 to get the raw bytes. And then as before, we're going to call list on the bytes object to get a list of integers of those bytes. So those are the starting tokens, those are the raw bytes of our sequence.

[49:15] Speaker A: But now, of course, according to the merges dictionary above, and recall this was the merges, some of the bytes may be merged according to this lookup. And in addition to that, remember that the 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 um, for example, this merge over here relies on the 256 which got merged here. So we have to go in the order from top to bottom sort of if we are going to be merging anything.

[49:49] Speaker A: Now, we expect to be doing a few merges, so we're going to be doing while true. Um, and now we want to find a pair of bytes that is consecutive that we are allowed to merge according to this. In order to reuse some of the functionality that we've already written, I'm going to reuse the function uh, get_stats.

[50:10] Speaker A: So recall that get_stats uh, will give us the, will basically count up how many times every single pair occurs in our sequence of tokens and return that as a dictionary. And the dictionary was a mapping from all the different uh, byte pairs to the number of times that they occur, right? Uh, 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. And so I'm only going to be using basically the keys of this dictionary. I only care about the set of possible merge candidates, if that makes sense.

[50:44] Speaker A: Now we want to identify the pair that we're going to be merging at this stage of the loop. So what do we want? We want to find the pair or like the a key inside stats that has the lowest index in the merges uh, dictionary because we want to do all the early merges before we work our way to the late merges. So again, there are many different ways to implement this, but I'm going to do something a little bit fancy here.

[51:11] Speaker A: So I'm going to be using the min over an iterator. In Python, when you call min on an iterator and stats here is a dictionary, we're going to be iterating the keys of this dictionary in Python. So we're looking at all the pairs inside stats, um, which are all the consecutive pairs. And we're going to be taking the consecutive pair inside tokens that has the minimum what? The min takes a key which gives us the function that is going to return a value over which we're going to do the min. And the one we care about is we're we care about taking merges and basically getting um, that pair's index.

[51:53] Speaker A: So basically for any pair inside stats, we are going to be looking into merges at what index it has. And we want to get the pair with the min number. So for an example, if there's a pair 101 and 32, we definitely want to get that pair. We want to identify it here and return it, and pair would become 101, 32 if it occurs. And the reason that I'm putting a float inf here as a fallback is that in the get function, when we call uh, when we basically consider a pair that doesn't occur in the merges, then that pair is not eligible to be merged, right? So if in the token sequence there's some pair that is not a merging pair, it cannot be merged, then uh, it doesn't actually occur here and it doesn't have an index and uh, it can't be merged, which we will denote as float inf. And 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. So, uh, so this is one way to do it.

[52:55] Speaker A: So basically, in one short, this returns the most eligible merging candidate pair uh, that occurs in the tokens. Now, one thing to be careful with here is this uh, function here might fail in the following way. If there's nothing to merge, then uh, then there's nothing in merges um, that is satisfied anymore. There's nothing to merge. Everything just returns float inf and then uh, the pair, I think will just become the very first element of stats. Um, but this pair is not actually a mergeable pair. It just becomes the first pair in stats arbitrarily because all these pairs evaluate to float inf for the merging criterion. So basically it could be that this this doesn't look succeed because there's no more merging pairs. So 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, we will break out. Um, nothing else can be merged.

[53:58] Speaker A: You may come up with a different implementation by the way. This is kind of like really uh, trying hard in Python. Um, but really we're just trying to find a pair that can be merged with a lowest index here. Now, if we did find a pair that is inside merges with the lowest index, then we can merge it. So we're going to look into the merges dictionary for that pair to look up the index, and we're going to now merge that into that index. So we're going to do tokens equals, we're going to replace the original tokens, we're going to be replacing the pair pair, and we're going to be replacing it with index idx. And this returns a new list of tokens where every occurrence of pair is replaced with idx. So we're doing a merge.

[54:46] Speaker A: And we're going to be continuing this until eventually nothing can be merged. We'll come out here and we'll break out. And here we just return tokens. And so that's the implementation I think. So hopefully this runs. Okay, cool. Um, yeah, and this looks uh, reasonable. So for example, 32 is a space in ASCII, so that's here. Um, so this looks like it worked. Great.

Code

def encode(text):
    # given a string, return list of integers (the tokens)
    tokens = list(text.encode("utf-8"))
    while True:
        stats = get_stats(tokens)
        pair = min(stats, key=lambda p: merges.get(p, float("inf")))
        if pair not in merges:
            break # nothing else can be merged
        idx = merges[pair]
        tokens = merge(tokens, pair, idx)
    return tokens

Code

print(encode("hello world!"))

Output: 72

[104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 33]

Note

[55:11] Speaker A: Okay, so let's wrap up this section of the video at least. I wanted to point out that this is not quite the right implementation just yet because we are leaving out a special case. So in particular, if uh, we try to do this, this will give us an error. And the issue is that um, if we only have a single character or an empty string, then stats is empty and that causes an issue inside min. So one way to fight this is if length of tokens is at least two. Because if it's less than two, it's just a single token or no tokens, then let's just uh, there's nothing to merge, so we just return. So that would fix uh, that case.

Code

try: print(encode('h'))
except Exception as e: print(e)

Output: 28

min() iterable argument is empty

Code

def encode(text):
    # given a string, return list of integers (the tokens)
    tokens = list(text.encode("utf-8"))
    while True:
        stats = get_stats(tokens)
        if len(tokens) < 2:
            break  # nothing to merge
        pair = min(stats, key=lambda p: merges.get(p, float("inf")))
        if pair not in merges:
            break # nothing else can be merged
        idx = merges[pair]
        tokens = merge(tokens, pair, idx)
    return tokens

Code

encode('h')

Output: 21

[104]

Note

[55:45] Speaker A: Okay. And then second, I have a few test cases here for us as well. So first, let's make sure uh, about or let's note the following. If we take a string and we try to encode it and then decode it back, you would expect to get the same string back, right? Is that true for all strings?

[56:05] Speaker A: So I think uh, so here it is the case, and I think in general this is probably the case. Um, but notice that going backwards is not, is not, you're not going to have an identity going backwards because as I mentioned, uh, not all token sequences are valid UTF-8 uh, sort of byte streams. And so therefore you're some of them can't even be decodable. Um, so this only goes in one direction. But for that one direction, we can check uh, here. If we take the training text, which is the text that we trained the tokenizer on, we can make sure that when we encode and decode, we get the same thing back, which is true. And here I took some validation data. So I went to, I think this web page and I grabbed some text. So this is text that the tokenizer has not seen, and we can make sure that this also works. Uh, so that gives us some confidence that this was correctly implemented.

Code

# Test that encode/decode is identity for training text
text2 = decode(encode(text))
test_eq(text, text2)

Code

# Test on new validation text
valtext = "Many common characters, including numerals, punctuation, and other symbols, are unified within the standard"
test_eq(decode(encode(valtext)), valtext)

Note

[56:56] Speaker A: So those are the basics of the byte-pair encoding algorithm. We saw how we can take some training set, train a tokenizer. The parameters of this tokenizer really are just this dictionary of merges. And that basically creates a little binary forest on top of raw bytes. Once we have this, the merges table, we can both encode and decode between raw text and token sequences. So that's the the simplest setting of the tokenizer. 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. So we're going to go through the details of this complexification one at a time.

Note

Part 2:

Note

Forced Splits Using Regex Patterns (GPT Series)

[57:37] Speaker A: So let's get things off by looking at the GPT series. So in particular, I have the GPT-2 paper here. Um, and this paper is from 2019 or so, uh, so five years ago. And let's scroll down to input representation. This is where they talk about the tokenizer that they're using for GPT-2.

[57:54] Speaker A: Now, this is all fairly readable, so I encourage you to pause and um, read this yourself. But this is where they motivate the use of the byte-pair encoding algorithm on the byte level representation of UTF-8 encoding. So this is where they motivated and they talk about the vocabulary sizes and everything. Now, everything here is exactly as we've covered it so far, but things start to depart around here. So what they mention is that they don't just apply the naive algorithm as we have done it. And in particular, here's a motivating example. Suppose that you have common words like dog. What will happen is that dog, of course, occurs very frequently in the text, and it occurs right next to all kinds of punctuation as an example. So dog dot, dog exclamation mark, dog question mark, etc. And naively, you might imagine that the BPE algorithm could merge these to be single tokens. And then you end up with lots of tokens that are just like dog with a slightly different punctuation. And so it feels like you're clustering things that shouldn't be clustered. You're combining kind of semantics with punctuation.

"We observed BPE includes many versions of common words like 'dog' since they occur in many contexts (e.g., 'dog.', 'dog!', 'dog?', etc.). This results in a sub-optimal allocation of limited vocabulary slots and model capacity. To avoid this, we prevent BPE from merging across character categories for any byte sequence."

GPT-2 paper

[58:56] Speaker A: And this uh, feels suboptimal, and indeed they also say that this is suboptimal according to some of the experiments. So what they want to do is they want to top down in a manual way enforce that some types of um, characters should never be merged together. Um, so they want to enforce these merging rules on top of the byte-pair encoding algorithm. So let's take a look um, at their code and see how they actually enforce this and what kinds of merges they actually do perform.

[59:24] Speaker A: So I have the tab open here for GPT-2 under OpenAI on GitHub. And when we go into source, there is an encoder.py. Now, 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. Uh, so it feels kind of awkward to me that it's called encoder, but that is the tokenizer. And there's a lot going on here and we're going to step through it in detail at one point. For now, I just want to focus on this part here. They create a regex pattern here that looks very complicated, and we're going to go through it in a bit. Uh, but this is the core part that

Code

# GPT-2 Encoder with regex pattern
class Encoder:
    def __init__(self, encoder, bpe_merges, errors='replace'):
        self.encoder = encoder
        self.decoder = {v:k for k,v in self.encoder.items()}
        self.bpe_merges = dict(zip(bpe_merges, range(len(bpe_merges))))
        self.cache = {}
        self.pat = re.compile(r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""")

Note

[60:00] Andrej Karpathy: allows them to enforce rules uh for what parts of the text will never be merged for sure.

[60:06] Andrej Karpathy: Now notice that re.compile here is a little bit misleading because we're not just doing import re, which is the Python re module. We're doing import regex as re. And regex is a Python package that you can install, pip install regex. And it's basically an extension of re, so it's a bit more powerful re.

[60:25] Andrej Karpathy: So let's take a look at this pattern and what it's doing and why this is actually doing the separation that they are looking for.

[60:33] Andrej Karpathy: Okay, so I've copy pasted the pattern here to our Jupyter notebook where we left off. And let's take this pattern for a spin. So in the exact same way that their code does, we're going to call an re.findall for this pattern on any arbitrary string that we are interested in. So this is the string that we want to encode into tokens um to feed into an LLM like GPT-2. So what exactly is this doing?

[60:58] Andrej Karpathy: Well, re.findall will take this pattern and try to match it against this string. Um, the way this works is that you are going from left to right in the string and you're trying to match the pattern. And re.findall will get all the occurrences and organize them into a list. Now, when you look at the um when you look at this pattern, first of all, notice that this is a raw string, um and then these are three double quotes just to start the string. So really the string itself, this is the pattern itself, right?

[61:32] Andrej Karpathy: And notice that it's made up of a lot of ors. So see these vertical bars, those are ors in regex. And so you go from left to right in the pattern and try to match it against the string wherever you are. So we have hello and we're going to try to match it. Well, it's not apostrophe s, it's not apostrophe t, or any of these. But it is an optional space followed by dash p of uh sorry, slash p of L one or more times. What is slash p of L? It is coming to some documentation that I found. Um there might be other sources as well.

[62:08] Andrej Karpathy: Uh slash p of L is a letter, any kind of letter from any language. And hello is made up of letters, h e l l o, etc. So optional space followed by a bunch of letters, one or more letters, is going to match hello, but then the match ends because a white space is not a letter. So from there on begins a new sort of attempt to match against the string again. And starting in here, we're going to skip over all these again until we get to the exact same point again. And we see that there's an optional space, this is the optional space, followed by a bunch of letters, one or more of them, and so that matches. So when we run this, we get a list of two elements, hello and then space world.

Code

import regex as re

Code

pat = re.compile(r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""")

Code

# Test the regex pattern on simple text
text = "Hello world"
matches = pat.findall(text)
print(f"Text: '{text}'")
print(f"Matches: {matches}")
print(f"Number of chunks: {len(matches)}")

Output: 79

Text: 'Hello world'
Matches: ['Hello', ' world']
Number of chunks: 2

Code

# Test with more complex text including punctuation
text2 = "Hello world how are you?"
matches2 = pat.findall(text2)
print(f"Text: '{text2}'")
print(f"Matches: {matches2}")
print(f"Number of chunks: {len(matches2)}")

Output: 139

Text: 'Hello world how are you?'
Matches: ['Hello', ' world', ' how', ' are', ' you', '?']
Number of chunks: 6

Note

Pattern: 's|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+

Breaking it down:

  • 's|'t|'re|'ve|'m|'ll|'d -> Common contractions
  • ?\p{L}+ -> Optional space + one or more letters
  • ?\p{N}+ -> Optional space + one or more numbers
  • ?[^\s\p{L}\p{N}]+ -> Optional space + punctuation/symbols
  • \s+(?!\S)|\s+ -> Whitespace handling

Note

The regex pattern ensures that BPE merging respects natural language boundaries by splitting text into these categories:

What it captures:

  1. Contractions - Common English contractions like "don't", "we're", "I'll" are kept as single units
  2. Words - Letters from any language (including accented characters) are grouped together, optionally preceded by a space
  3. Numbers - Digits are grouped together, optionally preceded by a space
  4. Punctuation & Symbols - Non-letter, non-digit characters are grouped together, optionally preceded by a space
  5. Whitespace - Handles various whitespace patterns

Why this matters:

  • Prevents "dog" + "." from merging into a single token
  • Keeps semantic meaning (words) separate from punctuation
  • Allows BPE to work within each category, but not across categories
  • Results in more meaningful token boundaries that respect language structure

Code

pat.findall("I'll go! I don't know 123?   ")

Output: 168

['I', "'ll", ' go', '!', ' I', ' don', "'t", ' know', ' 123', '?', '   ']

Note

[63:01] Andrej Karpathy: Now, what is this doing and why is this important? We are taking our string and instead of directly encoding it um for tokenization, we are first splitting it up. And when you actually step through the code, and we'll do that in a bit more detail, what really it's doing on a high level is that it first splits your text into a list of texts, just like this one. And all these elements of this list are processed independently by the tokenizer, and all the results of that processing are simply concatenated. So hello, world, oh, I missed how. Hello, world, how are you? We have five elements of a list. All of these will independently go from text to a token sequence, and then that token sequence is going to be concatenated. It's all going to be joined up.

[63:52] Andrej Karpathy: And roughly speaking, what that does is you're only ever finding merges between the elements of this list. So you can only ever consider merges within every one of these elements individually. And um after you've done all the possible merging for all these elements individually, the results of all that will be joined um by concatenation. And so you are basically, what you're doing effectively is you are never going to be merging this e with this space because they are now parts of the separate elements of this list. And so you are saying we are never going to merge e space, um because we're breaking it up in this way. So basically using this regex pattern to chunk up the text is just one way of enforcing that some merges are not to happen. And we're going to go into more of this text and we'll see that what this is trying to do on a high level is we're trying to not merge across letters, across numbers, across punctuation, and so on. So let's see in more detail how that works. So let's continue now. We have slash p of n. If you go to the documentation, slash p of n is any kind of numeric character in any script. So it's numbers. So we have an optional space followed by numbers and those would be separated out. So letters and numbers are being separated. So if I do hello world 123, how are you? Then world will stop matching here because one is not a letter anymore. But one is a number, so this group will match for that and we'll get it as a separate entity.

[65:26] Andrej Karpathy: Uh, let's see how these apostrophes work. So here, if we have um uh slash b or I mean apostrophe v as an example, then apostrophe here is not a letter or a number. So hello will stop matching and then we will exactly match this with that. So that will come out as a separate thing. So why are they doing the apostrophes here? Honestly, I think that these are just like very common apostrophes uh that are used um typically. I don't love that they've done this because let me show you what happens when you have uh some Unicode apostrophes. Like for example, you can have if you have how's, then this will be separated out because of this matching. But if you use the Unicode apostrophe like this, then suddenly this does not work. And so this apostrophe will actually become its own thing now.

Code

# Step 1: Show how letters and numbers are separated
text = "Hello world123 how are you?"
matches = pat.findall(text)
print(f"Text: '{text}'")
print(f"Matches: {matches}")
print("Notice: 'world' and '123' are separate chunks")

Output: 181

Text: 'Hello world123 how are you?'
Matches: ['Hello', ' world', '123', ' how', ' are', ' you', '?']
Notice: 'world' and '123' are separate chunks

Code

# Step 2: Show how contractions work with standard apostrophes
text = "how's it going"
matches = pat.findall(text)
print(f"Text: '{text}'")
print(f"Matches: {matches}")
print("Notice: Standard apostrophe 's' is kept with the word")

Output: 130

Text: 'how's it going'
Matches: ['how', "'s", ' it', ' going']
Notice: Standard apostrophe 's' is kept with the word

Code

# Step 3: Show the Unicode apostrophe problem
text = "how\u2019s it going"  # Unicode apostrophe (different from standard ')
matches = pat.findall(text)
print(f"Text: '{text}'")
print(f"Matches: {matches}")
print("Notice: Unicode apostrophe becomes its own separate chunk!")

Output: 127

Text: 'how’s it going'
Matches: ['how', '’', 's', ' it', ' going']
Notice: Unicode apostrophe becomes its own separate chunk!

Note

[66:23] Andrej Karpathy: And so, um it basically hardcoded for this specific kind of apostrophe and uh otherwise they become completely separate tokens. In addition to this, you can go to the GPT-2 docs and here where they define the pattern, they say, should have added re.ignorecase so BPE merges can happen for capitalized versions of contractions. So what they're pointing out is that you see how this is apostrophe and then lowercase letters. Well, because they didn't do re.ignorecase, then um these rules will not separate out the apostrophes if it's uppercase.

[66:59] Andrej Karpathy: So how's would be like this. But if I did HOW'S from uppercase, then notice suddenly the apostrophe comes by itself.

Code

pat.findall("HOW'S it going?")

Output: 99

['HOW', "'", 'S', ' it', ' going', '?']

Note

[67:13] Andrej Karpathy: So the tokenization will work differently in uppercase and lowercase, inconsistently separating out these apostrophes. So this feels extremely gnarly and slightly gross. Um but that's that's how that works. Okay, so let's come back. After trying to match a bunch of apostrophe expressions, by the way, the other issue here is that these are quite language specific probably. So I don't know that all languages, for example, use or have these apostrophes, but that would be inconsistently tokenized as a result. Then we try to match letters, then we try to match numbers. And then if that doesn't work, we fall back to here. And what this is saying is again, optional space followed by something that is not a letter, number, or a space, and one or more of that. So what this is doing effectively is this is trying to match punctuation, roughly speaking, not letters and not numbers. So this group will try to trigger for that. So if I do something like this, then these parts here uh are not letters or numbers, but they will actually they are uh they will actually get caught here. And so they become its own group. So we've separated out the punctuation.

[68:18] Andrej Karpathy: And finally, this um this is also a little bit confusing. So this is matching white space, but this is using a negative look ahead assertion in regex. So what this is doing is it's matching white space up to but not including the last white space character. Why is this important? Um this is pretty subtle, I think. So you see how the white space is always included at the beginning of the word. So um space r, space u, etc. Suppose we have a lot of spaces here. What's going to happen here is that these spaces up to and not including the last character will get caught by this. And what that will do is it will separate out the spaces up to but not including the last character. So that the last character can come here and join with the uh space you. And the reason that's nice is because space you is the common token. So if I didn't have these extra spaces here, you would just have space you. And if I add tokens, if I add spaces, we still have a space you, but now we have all this extra white space.

Code

pat.findall("you!!!??")

Output: 48

['you', '!!!??']

Code

pat.findall("     you")

Output: 46

['    ', ' you']

Note

[69:22] Andrej Karpathy: So basically the GPT-2 tokenizer really likes to have a space letters or numbers. Um and it it prepends these spaces and this is just something that it does consistently. So that's what that is for. And then finally, we have all the the last fallback is um white space characters. Uh so um that would be just um if that doesn't get caught, then this thing will catch any trailing spaces and so on.

[69:50] Andrej Karpathy: I wanted to show one more real world example here. So if we have this string, which is a piece of Python code, and then we try to split it out, then this is the kind of output we get. So you'll notice that the list has many elements here and that's because we are splitting up fairly often uh every time sort of a category changes. Um so there will never be any merges within these elements. And um that's what you are seeing here.

Code

example = """
for i in range(1, 101):
    if i % 3 == 0 and i % 5 == 0:
        print("FizzBuzz")
    elif i % 3 == 0:
        print("Fizz")
    elif i % 5 == 0:
        print("Buzz")
    else:
        print(i)
"""

print(pat.findall(example))

Output: 916

['\n', 'for', ' i', ' in', ' range', '(', '1', ',', ' 101', '):', '\n   ', ' if', ' i', ' %', ' 3', ' ==', ' 0', ' and', ' i', ' %', ' 5', ' ==', ' 0', ':', '\n       ', ' print', '("', 'FizzBuzz', '")', '\n   ', ' elif', ' i', ' %', ' 3', ' ==', ' 0', ':', '\n       ', ' print', '("', 'Fizz', '")', '\n   ', ' elif', ' i', ' %', ' 5', ' ==', ' 0', ':', '\n       ', ' print', '("', 'Buzz', '")', '\n   ', ' else', ':', '\n       ', ' print', '(', 'i', ')', '\n']

Note

[70:14] Andrej Karpathy: Now, you might think that in order to train the tokenizer, uh OpenAI has used this to split up text into chunks and then run just the BPE algorithm within all the chunks. But that is not exactly what happened. And the reason is the following. Notice that we have the spaces here. Uh those spaces end up being entire elements. But these spaces never actually end up being merged by by OpenAI. And the way you can tell is that if you copy paste the exact same chunk here into tiktoken, um tiktokenizer, you see that all the spaces are kept independent and they're all token 220.

pasted_image

A screenshot of the Tiktokenizer web tool. The left panel shows the FizzBuzz Python code. The right panel shows the tokenized output, with individual space characters highlighted and identified as token 220.

[70:51] Andrej Karpathy: So, I think OpenAI at some point enforced some rule that these spaces would never be merged. And so, um there's some additional rules on top of just chunking and BPEing that OpenAI is not uh clear about. Now, the training code for the GPT-2 tokenizer was never released. So all we have is uh the code that I've already shown you. But this code here that they released is only the inference code for the tokens. So this is not the training code. You can't give it a piece of text and train a tokenizer. This is just the inference code which takes the merges that we have up above and applies them to a new piece of text. And so we don't know exactly how OpenAI trained um trained the tokenizer, but it wasn't as simple as chunk it up and BPE it, uh whatever it was.

Note

Tiktoken

[71:38] Andrej Karpathy: Next, I wanted to introduce you to the tiktoken library from OpenAI, which is the official library for tokenization from OpenAI. So this is tiktoken. Pip install tiktoken and then um you can do the tokenization inference. So this is again, not training code, this is only inference code for tokenization.

[71:58] Andrej Karpathy: Um I wanted to show you how you would use it. It's quite simple. And running this just gives us the GPT-2 tokens or the GPT-4 tokens. So this is the tokenizer used for GPT-4. As in particular, we see that the white space in GPT-2 uh remains unmerged, but in GPT-4, uh these white spaces merge as we also saw in this one, where here they're all unmerged, but if we go down to GPT-4, uh they become merged.

pasted_image

A screenshot of the Tiktokenizer web tool, now showing the tokenization for the 'cl100k_base' model (GPT-4). The multiple space characters in the FizzBuzz code are now merged into single tokens.

Code

import tiktoken

Code

# Compare GPT-2 vs GPT-4 tokenization
enc_gpt2 = tiktoken.get_encoding("gpt2")
enc_gpt4 = tiktoken.get_encoding("cl100k_base")

tokens_gpt2 = enc_gpt2.encode(example)
tokens_gpt4 = enc_gpt4.encode(example)

print(f"GPT-2 tokens: {len(tokens_gpt2)}")
print(f"GPT-4 tokens: {len(tokens_gpt4)}")

Output: 42

GPT-2 tokens: 109
GPT-4 tokens: 72

Code

decoded_gpt4 = [enc_gpt4.decode([token]) for token in tokens_gpt4] 
for i, token_str in enumerate(decoded_gpt4): 
    if token_str.strip() == '': print(f"Token {i}: {repr(token_str)} (all whitespace)")

Output: 463

Token 0: '\n' (all whitespace)
Token 8: ' ' (all whitespace)
Token 11: '   ' (all whitespace)
Token 15: ' ' (all whitespace)
Token 18: ' ' (all whitespace)
Token 23: ' ' (all whitespace)
Token 26: ' ' (all whitespace)
Token 29: '       ' (all whitespace)
Token 35: '   ' (all whitespace)
Token 39: ' ' (all whitespace)
Token 42: ' ' (all whitespace)
Token 45: '       ' (all whitespace)
Token 50: '   ' (all whitespace)
Token 54: ' ' (all whitespace)
Token 57: ' ' (all whitespace)
Token 60: '       ' (all whitespace)
Token 65: '   ' (all whitespace)
Token 68: '       ' (all whitespace)

Note

[72:25] Andrej Karpathy: Um now, in the GPT-4 uh tokenizer, they changed the regular expression that they use to chunk up text. So the way to see this is that if you come to your the tiktoken uh library, and then you go to this file, tiktoken_ext, openai_public. This is where sort of like the definition of all the different tokenizers that OpenAI maintains is. And so, uh necessarily to do the inference, they had to publish some of the details about the strings. So this is the string that we already saw for GPT-2. It is slightly different, but it is actually equivalent uh to what we discussed here. So this pattern that we discussed is equivalent to this pattern. Uh this one just uh executes a little bit faster. So here you see a little bit of a slightly different definition, but otherwise it's the same.

Code

# GPT-2 tokenizer pattern from tiktoken openai_public.py
def gpt2():
    mergeable_ranks = data_gym_to_mergeable_bpe_ranks(
        vocab_bpe_file="https://openaipublic.blob.core.windows.net/gpt-2/encodings/main/vocab.bpe",
        encoder_json_file="https://openaipublic.blob.core.windows.net/gpt-2/encodings/main/encoder.json",
        vocab_bpe_hash="1ce1664773c50f3e0cc8842619a93edc4624525b728b188a9e0be33b7726adc5",
        encoder_json_hash="196139668be63f3b5d6574427317ae82f612a97c5d1cdaf36ed2256dbf636783",
    )
    return {
        "name": "gpt2",
        "explicit_n_vocab": 50257,
        # The pattern in the original GPT-2 release is:
        # r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
        # This is equivalent, but executes faster:
        "pat_str": r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}++| ?\p{N}++| ?[^\s\p{L}\p{N}]++|\s++$|\s+(?!\S)|\s""",
        "mergeable_ranks": mergeable_ranks,
        "special_tokens": {"<|endoftext|>": 50256},
    }

Note

[73:12] Andrej Karpathy: We're going to go into special tokens in a bit. And then if you scroll down to cl100k, this is the GPT-4 tokenizer. You see that the pattern has changed. Um and this is kind of like the main, the major change in addition to a bunch of other special tokens which we'll go into in a bit again.

Code

# GPT-4 tokenizer pattern from tiktoken openai_public.py
def cl100k_base():
    mergeable_ranks = load_tiktoken_bpe(
        "https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken"
    )
    special_tokens = {
        "<|endoftext|>": 100257,
        "<|fim_prefix|>": 100258,
        "<|fim_middle|>": 100259,
        "<|fim_suffix|>": 100260,
        "<|endofprompt|>": 100276
    }
    return {
        "name": "cl100k_base", 
        "explicit_n_vocab": 100277,
        # Different pattern from GPT-2 - handles whitespace better
        "pat_str": r"""(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{2,}|[^\r\n\p{L}\p{N}]?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+""",
        "mergeable_ranks": mergeable_ranks,
        "special_tokens": special_tokens,
    }

Note

[73:30] Andrej Karpathy: Now, I'm not going to actually go into the full detail of the pattern change because honestly, this is mind numbing. Uh I would just advise that you uh pull out ChatGPT and the regex uh documentation and just step through it. But really the major changes are, number one, you see this i here, that means that the um case sensitivity, this is case insensitive match. And so the comment that we saw earlier on, oh, you should have used re.uppercase, uh basically, we're now going to be matching these apostrophe s, apostrophe d, apostrophe m, etc. Uh we're going to be matching them both in lowercase and in uppercase. So that's fixed. There's a bunch of different like handling of the white space that I'm not going to go into the full details of. And then one more thing here is you will notice that when they match the numbers, they only match one to three numbers. So they will never merge numbers that are in in more than three digits. Only up to three digits of numbers will ever be merged. And uh that's one change that they made as well to prevent uh tokens that are very, very long number sequences. Uh but again, we don't really know why they do any of this stuff uh because none of this is documented and uh it's just we just get the pattern. Uh so, um yeah. It is what it is. But those are some of the changes that uh GPT-4 has made. And of course, the vocabulary size went from roughly 50k to roughly 100k.

Note

The GPT-4 pattern: r"""(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{2,}|[^\r\n\p{L}\p{N}]?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+"""

Breaking it down:

  1. (?i:'s|'t|'re|'ve|'m|'ll|'d) - Case-insensitive contractions (fixes the uppercase problem!)
  2. [^\r\n\p{L}\p{N}]?\p{L}+ - Optional non-letter/non-digit/non-newline + letters
  3. \p{N}{2,} - Numbers with 2+ digits (changed from 1+ in GPT-2)
  4. [^\r\n\p{L}\p{N}]?[^\s\p{L}\p{N}]+[\r\n]* - Punctuation/symbols with optional newlines
  5. \s*[\r\n]+ - Newline handling with optional spaces
  6. \s+(?!\S)|\s+ - Whitespace handling (similar to GPT-2)

Key improvements over GPT-2:

  • ✅ Case-insensitive contractions ((?i:...))
  • ✅ Better newline handling
  • ✅ Numbers require 2+ digits (prevents single digit tokens)
  • ✅ More sophisticated whitespace merging

Code

# Step 1: Test case-insensitive contractions (GPT-4 vs GPT-2)
gpt4_pat = re.compile(r"""(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{2,}|[^\r\n\p{L}\p{N}]?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+""")

# Test uppercase contractions
test_text = "HOW'S IT GOING? how's it going?"
gpt2_result = pat.findall(test_text)
gpt4_result = gpt4_pat.findall(test_text)

print(f"Text: '{test_text}'")
print(f"GPT-2: {gpt2_result}")
print(f"GPT-4: {gpt4_result}")
print("Notice: GPT-4 keeps 'HOW'S' together, GPT-2 splits it!")

Output: 402

Text: 'HOW'S IT GOING? how's it going?'
GPT-2: ['HOW', "'", 'S', ' IT', ' GOING', '?', ' how', "'s", ' it', ' going', '?']
GPT-4: ['HOW', "'S", ' IT', ' GOING', '?', ' how', "'s", ' it', ' going', '?']
Notice: GPT-4 keeps 'HOW'S' together, GPT-2 splits it!

Code

# Step 2: Test number handling (2+ digits requirement)
test_numbers = "I have 1 apple, 12 oranges, and 123 bananas."
gpt2_result = pat.findall(test_numbers)
gpt4_result = gpt4_pat.findall(test_numbers)

print(f"Text: '{test_numbers}'")
print(f"GPT-2: {gpt2_result}")
print(f"GPT-4: {gpt4_result}")
print("Notice: GPT-4 drops single digits entirely (1 is missing), only captures multi-digits (12, 123)")

Output: 453

Text: 'I have 1 apple, 12 oranges, and 123 bananas.'
GPT-2: ['I', ' have', ' 1', ' apple', ',', ' 12', ' oranges', ',', ' and', ' 123', ' bananas', '.']
GPT-4: ['I', ' have', ' ', ' apple', ',', ' ', '12', ' oranges', ',', ' and', ' ', '123', ' bananas', '.']
Notice: GPT-4 drops single digits entirely (1 is missing), only captures multi-digits (12, 123)

Code

# Step 3: Test newline and whitespace handling
test_newlines = "Hello\nworld\n\n  \ntest"
gpt2_result = pat.findall(test_newlines)
gpt4_result = gpt4_pat.findall(test_newlines)

print(f"Text: {repr(test_newlines)}")
print(f"GPT-2: {gpt2_result}")
print(f"GPT-4: {gpt4_result}")
print("Notice: GPT-4 merges more newline sequences together")

Output: 238

Text: 'Hello\nworld\n\n  \ntest'
GPT-2: ['Hello', '\n', 'world', '\n\n  ', '\n', 'test']
GPT-4: ['Hello', '\n', 'world', '\n\n  \n', 'test']
Notice: GPT-4 merges more newline sequences together

Note

The Official encoder.py

[74:58] Andrej Karpathy: The next thing I would like to do very briefly is to take you through the GPT-2 encoder.py that OpenAI has released. This is the file that I've already mentioned to you briefly. Now, this file is uh fairly short and should be relatively understandable to you at this point. Um starting at the bottom here, they are loading two files, encoder.json and vocab.bpe. And they do some light processing on it and then they call this encoder object, which is the tokenizer.

[75:28] Andrej Karpathy: Now, if you'd like to inspect these two files, which together constitute their saved tokenizer, then you can do that with a piece of code like this. Um this is where you can download these two files and you can inspect them if you'd like. And what you will find is that this encoder, as they call it in their code, is exactly equivalent to our vocab.

Code

# !wget https://openaipublic.blob.core.windows.net/gpt-2/encodings/main/vocab.bpe
# !wget https://openaipublic.blob.core.windows.net/gpt-2/encodings/main/encoder.json

Code

import json

Code

with open('vocab.bpe', 'r', encoding="utf-8") as f: bpe_data = f.read()
with open('encoder.json', 'r') as f: encoder = json.load(f)

Code

type(bpe_data), type(encoder)

Output: 22

(str, dict)

Code

len(encoder.keys()), [o for i,o in zip(range(10), iter(encoder.items()))]

Output: 225

(50257,
 [('!', 0),
  ('"', 1),
  ('#', 2),
  ('$', 3),
  ('%', 4),
  ('&', 5),
  ("'", 6),
  ('(', 7),
  (')', 8),
  ('*', 9)])

Code

print(bpe_data.splitlines()[10000:10010])

Output: 184

['Ġm it', 'Ġexper iments', 'i pt', 'Col or', 'ind u', 'Up date', 'Ġ9 3', 'A g', 'Ġ å', 'anc ouver']

Note

[75:48] Andrej Karpathy: So remember here where we have this vocab object which allowed us to decode very efficiently. And basically it took us from the integer to the bytes uh for that integer. So our vocab is exactly their encoder. And then their vocab.bpe, confusingly, is actually our merges. So their BPE merges, which is based on the data inside vocab.bpe, ends up being equivalent to our merges. So, uh basically they are saving and loading the two uh variables that for us are also critical, the merges variable and the vocab variable. Using just these two variables, you can represent a tokenizer and you can both do encoding and decoding once you've trained this tokenizer.

[76:36] Andrej Karpathy: Now, the only thing that um is actually slightly confusing inside what OpenAI does here is that in addition to this encoder and the decoder, they also have something called a byte encoder and a byte decoder. And this is actually unfortunately just kind of a spurious implementation detail. It isn't actually deep or interesting in any way. So I'm going to skip the discussion of it. But what OpenAI does here for a reason that I don't fully understand is that not only have they this tokenizer which can encode and decode, but they have a whole separate layer here in addition that is used serially with the tokenizer. And so you first do um byte encode and then encode, and then you do decode and then byte decode. So that's the loop and they are just stacked serial on top of each other. And it's not that interesting, so I won't cover it and you can step through it if you'd like. Otherwise, this file, if you ignore the byte encoder and the byte decoder, will be algorithmically very familiar with you. And the meat of it here is the what they call BPE function, and you should recognize this loop here, which is very similar to our own while loop, where they're trying to identify the bigram, uh a pair that they should be merging next. And then here, just like we had, they have a for loop trying to merge this pair. Uh so they will go over all of the sequence and they will merge the pair whenever they find it. And they keep repeating that until they run out of possible merges in the in the text. So that's the meat of this file. And uh there's an encode and a decode function just like we've implemented it. So long story short, what I want you to take away at this point is that 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. And what we've built up above, if you understand it, is algorithmically what is necessary to actually build a BPE tokenizer, train it, and then both encode and decode.

Note

# GPT-2 BPE function - the main while loop that Andrej describes
def bpe(self, token):
    if token in self.cache:
        return self.cache[token]
    word = tuple(token)
    pairs = get_pairs(word)

    if not pairs:
        return token

    while True:
        # This is the loop Andrej mentions - finding the bigram (pair) to merge
        bigram = min(pairs, key = lambda pair: self.bpe_ranks.get(pair, float('inf')))
        if bigram not in self.bpe_ranks:
            break  # Nothing else can be merged
        first, second = bigram
        new_word = []
        i = 0
        # This is the for loop Andrej describes - merging the pair throughout the sequence
        while i < len(word):
            try:
                j = word.index(first, i)
                new_word.extend(word[i:j])
                i = j
            except:
                new_word.extend(word[i:])
                break

            if word[i] == first and i < len(word)-1 and word[i+1] == second:
                new_word.append(first+second)  # Merge the pair
                i += 2
            else:
                new_word.append(word[i])
                i += 1
        new_word = tuple(new_word)
        word = new_word
        if len(word) == 1:
            break
        else:
            pairs = get_pairs(word)  # Recalculate pairs for next iteration
    word = ' '.join(word)
    self.cache[token] = word
    return word

This is the actual GPT-2 BPE implementation that Andrej references! Key points:

  • Main while loop: Keeps finding the most eligible pair to merge
  • Bigram selection: Uses min() with bpe_ranks.get(pair, float('inf')) - exactly as Andrej describes
  • Merge loop: The inner while loop replaces all occurrences of the selected pair
  • Caching: Results are cached for efficiency
  • Algorithmic similarity: This matches our implementation above, just with some optimizations

Note

Special Tokens

[78:26] Andrej Karpathy: The next topic I would like to turn to is that of special tokens. So, in addition to tokens that are coming from, you know, raw bytes and the BPE merges, we can insert all kinds of tokens that we are going to use to delimit different parts of the data or introduce to create a special structure of the token streams. So, in uh if you look at this encoder object from OpenAI's GPT-2 right here, we mentioned this is very similar to our vocab. You'll notice that the length of this is 50,257.

[78:57] Andrej Karpathy: Where are the tokens? As I mentioned, there are 256 raw byte tokens. And then OpenAI actually did 50,000 merges. So those become the other tokens. But this would have been 50,256. So what is the 57th token? And there is basically one special token. And that one special token, you can see, is called end of text. So this is a special token and it's the very last token. And this token is used to delimit documents in the training set.

[79:54] Andrej Karpathy: So, when we're creating the training data, we have all these documents and we tokenize them and we get a stream of tokens.

[80:00] Andrej Karpathy: tokens. Those tokens only range from 0 to 50,256. And then in between those documents, we put special end of text token. And we insert that token in between documents.

[80:14] Andrej Karpathy: And 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. That said, the language model has to learn this from data. It it needs to learn that this token usually means that it should wipe its sort of memory of what came before. And what came before this token is not actually informative to what comes next. But we are expecting the language model to just like learn this, but we are giving it this special sort of delimiter of these documents.

Code

# Find the token with the highest ID (should be the special token)
max_id = max(encoder.values())
special_token = [k for k, v in encoder.items() if v == max_id][0]
print(f"Special token: '{special_token}' with ID: {max_id}")

Output: 58

Special token: '<|endoftext|>' with ID: 50256

Note

[80:44] Andrej Karpathy: We can go here to Tiktokenizer, and um, this is the GPT-2 tokenizer. Uh, our code that we've been playing with before. So we can add here, right? Hello world, how are you? And we're getting different tokens.

[80:56] Andrej Karpathy: But now you can see what what happens if I put end of text. You see how until I finished it, these are all different tokens. End of text, still different tokens. And now when I finish it, suddenly we get token 50,256.

pasted_image

The Tiktokenizer website showing the input 'Hello world how are you <|endoftext|>' and its corresponding token IDs, with the final token being 50256.

[81:14] Andrej Karpathy: And the reason this works is because 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. Um, we did not see these special instructions for handling special tokens in the encoder.py. It's absent there.

[81:36] Andrej Karpathy: But if you go to the tiktoken library, which is uh implemented in Rust, you will find all kinds of special case handling for these special tokens that you can register, uh create, add to the vocabulary, and then it looks for them and it uh 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 uh byte-pair encoding.

Note

// From tiktoken/src/lib.rs - Special Token Handling
impl CoreBPE {
    fn new_internal(
        encoder: HashMap<Vec<u8>, Rank>,
        special_tokens_encoder: HashMap<String, Rank>,  // Special tokens mapping
        pattern: &str,
    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
        let regex = Regex::new(pattern)?;

        // This is the key part Andrej mentions - creating a special regex
        // that matches all special tokens
        let special_regex = {
            let parts = special_tokens_encoder
                .keys()
                .map(|s| fancy_regex::escape(s))  // Escape special token strings
                .collect::<Vec<_>>();
            Regex::new(&parts.join("|"))?       // Join with OR operator
        };

        let decoder: HashMap<Rank, Vec<u8>> =
            encoder.iter().map(|(k, v)| (*v, k.clone())).collect();

        let special_tokens_decoder: HashMap<Rank, Vec<u8>> = 
            special_tokens_encoder
                .iter()
                .map(|(k, v)| (*v, k.as_bytes().to_vec()))
                .collect();

        ...

        Ok(Self {
            encoder,
            special_tokens_encoder,    // Store special tokens
            decoder,
            special_tokens_decoder,    // Store special token decoder
            regex_tls: (0..MAX_NUM_THREADS).map(|_| regex.clone()).collect(),
            special_regex_tls: (0..MAX_NUM_THREADS)
                .map(|_| special_regex.clone())  // Thread-local special regex
                .collect(),
            sorted_token_bytes,
        })
    }

    pub fn encode_with_special_tokens(&self, text: &str) -> Vec<Rank> {
        let allowed_special = self.special_tokens();
        self.encode(text, &allowed_special).unwrap().0
    }
}

Key points from Andrej's explanation:

  • Special regex creation: Creates a separate regex that matches all special tokens by escaping them and joining with | (OR)
  • Separate handling: Special tokens bypass normal BPE processing entirely
  • Thread-local storage: Uses thread-local regex instances for performance
  • Direct token swapping: When special tokens are found, they're directly mapped to their token IDs

Source: tiktoken/src/lib.rs

Note

Special Tokens in Fine-Tuning

[82:01] Andrej Karpathy: So these special tokens are used pervasively, uh not just in uh basically base language modeling of predicting the next token in a sequence, but especially when it gets to later to the fine-tuning stage and all the chat GPT sort of aspects of it. Uh because we don't just want to delimit documents, we want to delimit entire conversations between an assistant and a user. So if I refresh this Tiktokenizer page, the default example that they have here is using not sort of base model encoders, but fine-tuned model uh sort of tokenizers.

[82:34] Andrej Karpathy: Um, so for example, using the GPT-3.5 Turbo scheme, these here are all special tokens. I am start, I am end, etc. Uh this is short for imaginary monologue underscore start, by the way. But you can see here that there's a sort of start and end of every single message, and there can be many other tokens, uh lots of tokens, um in use to delimit these conversations and kind of keep track of the flow of the messages here.

pasted_image

The Tiktokenizer website in chat mode for GPT-3.5-turbo, showing special tokens like <|im_start|>system and <|im_end|> used to structure a conversation.

[83:02] Andrej Karpathy: Now let's go back to the tiktoken library. And here when you scroll to the bottom, they talk about how you can extend tiktoken and how you can you can create basically, you can fork uh the um CL100K base tokenizers in GPT-4. And for example, you can extend it by adding more special tokens. And these are totally up to you. You can come up with any arbitrary tokens and add them with the new ID afterwards. And the tiktoken library will uh correctly swap them out uh when it sees this in the strings.

Code

# Extending tiktoken with custom special tokens
import tiktoken

cl100k_base = tiktoken.get_encoding("cl100k_base")

# In production, load the arguments directly instead of accessing private attributes
# See openai_public.py for examples of arguments for specific encodings
enc = tiktoken.Encoding(
    # If you're changing the set of special tokens, make sure to use a different name
    # It should be clear from the name what behaviour to expect.
    name="cl100k_im",
    pat_str=cl100k_base._pat_str,
    mergeable_ranks=cl100k_base._mergeable_ranks,
    special_tokens={
        **cl100k_base._special_tokens,
        "<|im_start|>": 100264,
        "<|im_end|>": 100265,
    }
)

Code

cl100k_base._special_tokens

Output: 177

{'<|endoftext|>': 100257,
 '<|fim_prefix|>': 100258,
 '<|fim_middle|>': 100259,
 '<|fim_suffix|>': 100260,
 '<|endofprompt|>': 100276}

Code

enc.encode('<|im_start|>Hello world<|im_end|>', allowed_special={'<|im_start|>', '<|im_end|>'})

Output: 40

[100264, 9906, 1917, 100265]

Code

enc._special_tokens

Output: 240

{'<|endoftext|>': 100257,
 '<|fim_prefix|>': 100258,
 '<|fim_middle|>': 100259,
 '<|fim_suffix|>': 100260,
 '<|endofprompt|>': 100276,
 '<|im_start|>': 100264,
 '<|im_end|>': 100265}

Note

[83:33] Andrej Karpathy: Now, we can also go back to this file which we looked at previously. And I mentioned that the GPT-2 in tiktoken, openai_public.py, we have the vocabulary, we have the pattern for splitting, and then here we are registering the single special token in GPT-2, which was the end of text token, and we saw that it has this ID.

[83:53] Andrej Karpathy: In GPT-4, when they defined this here, you see that the pattern has changed as we've discussed, but also the special tokens have changed in this tokenizer. So we of course have the end of text, just like in GPT-2, but we also see three, sorry, four additional tokens here. FIM prefix, middle, and suffix. What is FIM? FIM is short for fill in the middle. And if you'd like to learn more about this idea, it comes from this paper.

# GPT-2 Special Tokens (from openai_public.py)
def gpt2():
    # ... other tokenizer configuration ...
    return {
        "name": "gpt2",
        "explicit_n_vocab": 50257,
        "pat_str": r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}++| ?\p{N}++| ?[^\s\p{L}\p{N}]++|\s++$|\s+(?!\S)|\s""",
        "mergeable_ranks": mergeable_ranks,
        "special_tokens": {"<|endoftext|>": 50256},  # Only one special token
    }

# GPT-4 Special Tokens (cl100k_base from openai_public.py) 
def cl100k_base():
    # ... other tokenizer configuration ...
    special_tokens = {
        "<|endoftext|>": 100257,    # Same as GPT-2 but different ID
        "<|fim_prefix|>": 100258,   # Fill-in-the-middle: prefix
        "<|fim_middle|>": 100259,   # Fill-in-the-middle: middle  
        "<|fim_suffix|>": 100260,   # Fill-in-the-middle: suffix
        "<|endofprompt|>": 100276   # End of prompt marker
    }
    return {
        "name": "cl100k_base",
        "explicit_n_vocab": 100277,
        "pat_str": r"""(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{2,}|[^\r\n\p{L}\p{N}]?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+""",
        "mergeable_ranks": mergeable_ranks,
        "special_tokens": special_tokens,
    }

Key differences:

  • GPT-2: Only has <|endoftext|> (ID: 50256)
  • GPT-4: Has 5 special tokens including FIM (Fill-in-the-Middle) tokens for code completion tasks
  • Vocabulary growth: From 50,257 tokens (GPT-2) to 100,277 tokens (GPT-4)

[84:18] Andrej Karpathy: Um, and I'm not going to go into detail in this video, it's beyond this video. And then there's uh one additional uh sort of token here. So that's that encoding as well. The FIM (Fill-in-the-Middle) paper: Efficient Training of Language Models to Fill in the Middle

[84:30] Andrej Karpathy: So it's very common, basically to train a language model, and then if you'd like, uh you can add special tokens. Now, when you add special tokens, you of course have to um do some model surgery to the transformer and all the parameters involved in that transformer. Because you are basically adding an integer and you want to make sure that for example, your embedding matrix for the vocabulary tokens has to be extended by adding a row. And typically this row would be initialized uh with small random numbers or something like that, uh because we need to have a vector that now stands for that token.

[85:03] Andrej Karpathy: In addition to that, you have to go to the final layer of the transformer and you have to make sure that that projection at the very end into the classifier uh is 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.

Note

The minbpe Exercise

[85:28] Andrej Karpathy: Okay, so at this point you should have everything you need in order to build your own GPT-4 tokenizer. Now, in the process of developing this lecture, I've done that and I've published the code under this repository minbpe.

[85:40] Andrej Karpathy: So minbpe looks like this right now as I'm recording, but um the minbpe repository will probably change quite a bit because I intend to continue working on it. Um, in addition to the minbpe repository, I've published this uh exercise progression that you can follow. So if you go to exercise.md here, this is sort of me breaking up the task ahead of you into four steps that sort of uh build up to what can be a GPT-4 tokenizer. And so feel free to follow these steps exactly and uh follow a little bit of the guidance that I've laid out here. And anytime you feel stuck, just reference the minbpe repository here.

Note

Build your own GPT-4 Tokenizer!

This exercise progression will guide you through building a complete GPT-4 style tokenizer step by step. Each step builds upon the previous one, gradually adding complexity until you have a fully functional tokenizer that matches OpenAI's tiktoken library.

Note

Step 1: Basic BPE Implementation

Write the BasicTokenizer class with the following three core functions:

  • def train(self, text, vocab_size, verbose=False)
  • def encode(self, text)
  • def decode(self, ids)

Your Task:

  • Train your tokenizer on whatever text you like and visualize the merged tokens
  • Do they look reasonable?
  • One default test you may wish to use is the text file tests/taylorswift.txt

What you're building: The simplest possible BPE tokenizer that works directly on raw text without any preprocessing.

Code

# Do you work in solveit here

Note

Step 2: Add Regex Preprocessing (GPT-2/GPT-4 Style)

Convert your BasicTokenizer into a RegexTokenizer that:

  • Takes a regex pattern and splits the text exactly as GPT-4 would
  • Processes the parts separately as before, then concatenates the results
  • Retrain your tokenizer and compare the results before and after

Use the GPT-4 pattern:

GPT4_SPLIT_PATTERN = 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*[\r\n]|\s+(?!\S)|\s+"""

Expected Result: You should see that you will now have no tokens that go across categories (numbers, letters, punctuation, more than one whitespace).

Code

# Do you work in solveit here

Note

Step 3: Load GPT-4 Merges

Now we want to load the GPT-4 tokenizer merges and exactly reproduce the GPT-4 tokenizer. This step is the most complex because we need to recover the original merges from the GPT-4 tokenizer.

The Challenge:

  • GPT-4 applies a byte permutation to the raw bytes before BPE
  • We need to "recover" the original merges from the final tokenizer
  • Use the recover_merges() function to extract merges from tiktoken

Your Task:

  • Load the GPT-4 tokenizer using tiktoken
  • Recover the merges and handle the byte shuffle
  • Verify your tokenizer matches tiktoken exactly on test cases

Expected Result: Your RegexTokenizer should now tokenize exactly like GPT-4's cl100k_base encoding.

Code

# Do you work in solveit here

Note

Step 4: Handle Special Tokens (Optional)

Add support for special tokens like <|endoftext|> to match tiktoken's behavior completely.

Your Task:

  • Extend your tokenizer to handle special tokens
  • Implement the allowed_special parameter
  • Test with GPT-4's special tokens: <|endoftext|>, <|fim_prefix|>, etc.

Key Features:

  • Special tokens bypass normal BPE processing
  • They get assigned specific token IDs outside the regular vocabulary
  • Handle the allowed_special and disallowed_special parameters

Expected Result: Your tokenizer can now handle special tokens exactly like tiktoken, including proper error handling for disallowed special tokens.

Code

# Do you work in solveit here

Note

Step 5: Advanced - Explore SentencePiece (Stretch Goal)

This is the most advanced step - understanding how other tokenizers like Llama 2 work differently from GPT's byte-level BPE.

The Key Difference:

  • GPT-style: Byte-level BPE (works on UTF-8 bytes)
  • Llama-style: Unicode code point BPE (works on Unicode characters)

Your Challenge:

  • Study how SentencePiece tokenization differs from byte-level BPE
  • Understand why Llama 2 can handle non-English languages more efficiently
  • (Optional) Try implementing a SentencePiece-style tokenizer

Learning Goals:

  • Appreciate the trade-offs between different tokenization approaches
  • Understand why different models make different tokenization choices
  • See how tokenization affects model performance on different languages

Resources: Check the SentencePiece paper and the Llama 2 tokenizer for reference.

Code

# Do you work in solveit here

Note

[86:17] Andrej Karpathy: So either the tests could be useful or the minbpe repository itself. I tried to keep the code fairly clean and understandable. And so, um, feel free to reference it whenever um you get stuck.

[86:31] Andrej Karpathy: In addition to that, basically, once you write it, you should be able to reproduce this behavior from tiktoken. So getting the GPT-4 tokenizer, you can take uh you can encode this string and you should get these tokens. And then you can encode and decode the exact same string to recover it. And in addition to all that, you should be able to implement your own train function, which tiktoken library does not provide. It's again, only inference code. But you should be able to write your own train, minbpe does it as well. And that will allow you to train your own token vocabularies.

[87:01] Andrej Karpathy: So here's some of the code inside minbpe, minbpe, uh shows the token vocabularies that you might obtain. So on the left uh here, we have the GPT-4 merges. Uh so the first 256 are raw individual bytes. And then here I am visualizing the merges that GPT-4 performed during its training. So the very first merge that GPT-4 did was merge two spaces into a single token for, you know, two spaces. And that is the token 256.

[87:31] Andrej Karpathy: And so this is the order in which things merged during GPT-4 training. And this is the merge order that um we obtained in minbpe by training a tokenizer. And in this case, I trained it on a Wikipedia page of Taylor Swift. Uh not because I'm a Swifty, but because that is one of the longest um Wikipedia pages apparently that's available. But she is pretty cool. And um, what was I going to say? Yeah, so you can compare these two uh vocabularies and so as an example, um, here GPT-4 merged I M to become in, and we've done the exact same thing on this token 259. Here, space T becomes spacey, and that happened for us a little bit later as well. So the difference here is again, to my understanding, only a difference of the training set. So as an example, because I see a lot of white space, I expect that GPT-4 probably had a lot of Python code in its training set. I'm not sure. Uh for the tokenizer. And uh 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. And when you train your own, you're probably going to get something similar depending on what you train it on.

Note

Key Insights from the minbpe Exercise:

What You Should Be Able to Do:

  • Reproduce tiktoken behavior exactly - Your tokenizer should encode/decode strings identically to GPT-4's cl100k_base
  • Implement your own training function - Unlike tiktoken (inference-only), you can train custom vocabularies
  • Compare different training datasets - See how training data affects the learned merges

Vocabulary Comparison Insights: Looking at the side-by-side comparison in the image:

Left (GPT-4 Official):

  • First 256 tokens: Raw individual bytes
  • Token 256: Two spaces merged (indicates lots of code/structured text in training)
  • Shows heavy whitespace merging patterns

Right (Taylor Swift Wikipedia):

  • Same algorithm, different training data
  • Less whitespace merging (typical prose text)
  • Similar patterns but different priorities

Key Observations:

  • Same algorithm, different results - BPE produces vocabularies that reflect the training data
  • Training data matters - GPT-4's heavy whitespace merging suggests Python code in training set
  • Merge order reveals priorities - Most frequent patterns get merged first
  • Reproducible patterns - Both show similar merges like "IM" → "in" and "space+T" → "space+T"

The Power of Custom Training: You can now train tokenizers optimized for your specific domain - whether that's code, medical text, or any specialized content!

Note

SentencePiece

[88:42] Andrej Karpathy: Okay, so we are now going to move on from tiktoken and the way that OpenAI tokenizes its strings. And we're going to discuss one more very commonly used library for working with tokenization in LLMs, and that is SentencePiece. So SentencePiece is uh very commonly used in language models because unlike tiktoken, it can do both training and inference, and it's quite efficient at both. It supports a number of algorithms for training uh vocabularies, but one of them is the byte-pair encoding algorithm that we've been looking at. So it supports it.

[89:13] Andrej Karpathy: Now, SentencePiece is used both by Llama and Mistral series and many other models as well. It is on GitHub under google/sentencepiece.

[89:22] Andrej Karpathy: And the big difference with SentencePiece, and we're going to look at an example because this is kind of hard and subtle to explain, is that they think different about the order of uh operations here. So in the case of tiktoken, we first take our code points in a string, we encode them using UTF-8 to bytes, and then we're merging bytes. It's fairly straightforward.

[89:46] Andrej Karpathy: For SentencePiece, um it works directly on the level of the code points themselves. So it looks at whatever code points are available in your training set, and then it starts merging those code points. And um the BPE is running on the level of code points. And if you happen to run out of code points, so there are maybe some rare uh code points that just don't come up too often, and the rarity is determined by this character coverage hyperparameter, then these uh code points will either get mapped to a special unknown token, like unk, or if you have the byte fallback option turned on, then they will take those rare code points, they will encode them using UTF-8, and then the individual bytes of that encoding will be translated into tokens. And there are these special byte tokens that basically get added to the vocabulary. So it uses BPE on on the code points, and then it falls back to bytes for rare code points. Um, and so that's kind of our difference. Personally, I find the tiktoken way significantly cleaner, uh but it's kind of like a subtle but pretty major difference between the way they approach tokenization.

Note

tiktoken vs SentencePiece: The Key Difference

tiktoken (GPT approach):

  1. Text → UTF-8 bytes → BPE on bytes
  2. Always works on byte level (0-255)

SentencePiece (Llama approach):

  1. Text → Unicode code points → BPE on code points
  2. Falls back to bytes only for rare characters

Why it matters:

  • tiktoken: Handles all languages equally but may be less efficient for non-English
  • SentencePiece: More efficient for languages with many unique characters (Chinese, Japanese)

Code

# Compare tiktoken vs SentencePiece on Chinese text
chinese_text = "你好世界"  # "Hello World" in Chinese

print(f"Text: {chinese_text}")
print(f"UTF-8 bytes: {chinese_text.encode('utf-8')}")
print(f"Unicode code points: {[ord(c) for c in chinese_text]}")

# tiktoken approach: work on bytes
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
tiktoken_tokens = enc.encode(chinese_text)
print(f"tiktoken tokens: {tiktoken_tokens} (count: {len(tiktoken_tokens)})")

Output: 160

Text: 你好世界
UTF-8 bytes: b'\xe4\xbd\xa0\xe5\xa5\xbd\xe4\xb8\x96\xe7\x95\x8c'
Unicode code points: [20320, 22909, 19990, 30028]
tiktoken tokens: [57668, 53901, 3574, 244, 98220] (count: 5)

Code

# SentencePiece approach: work on code points (if we had it installed)
# !pip install sentencepiece  # Uncomment to install

# For comparison, let's see the difference in approach:
print("tiktoken approach:")
print("1. Characters → UTF-8 bytes → BPE merges bytes")
for char in chinese_text:
    utf8_bytes = char.encode('utf-8')
    print(f"  '{char}' → {utf8_bytes} → separate tokens for each byte")

print("\nSentencePiece approach:")
print("2. Characters → Unicode code points → BPE merges code points")
for char in chinese_text:
    code_point = ord(char)
    print(f"  '{char}' → U+{code_point:04X} → can merge whole characters")

Output: 402

tiktoken approach:
1. Characters → UTF-8 bytes → BPE merges bytes
  '你' → b'\xe4\xbd\xa0' → separate tokens for each byte
  '好' → b'\xe5\xa5\xbd' → separate tokens for each byte
  '世' → b'\xe4\xb8\x96' → separate tokens for each byte
  '界' → b'\xe7\x95\x8c' → separate tokens for each byte

SentencePiece approach:
2. Characters → Unicode code points → BPE merges code points
  '你' → U+4F60 → can merge whole characters
  '好' → U+597D → can merge whole characters
  '世' → U+4E16 → can merge whole characters
  '界' → U+754C → can merge whole characters

Note

Training a SentencePiece Model

[90:51] Andrej Karpathy: Let's work with a concrete example because otherwise this is kind of hard to um to get your head around. So let's work with a concrete example. This is how we can import SentencePiece. And then here we're going to take, I think I took like the description of SentencePiece and I just created like a little toy dataset. It really likes to have a file, so I created a toy.txt file with this content.

[91:13] Andrej Karpathy: Now, what's kind of a little bit crazy about SentencePiece is that there's a ton of options and configurations. And the reason this is so is because SentencePiece has been around, I think for a while, and it really tried to handle a large diversity of things. And um because it's been around, I think it has quite a bit of accumulated historical baggage uh as well. And so in particular, there's like a ton of configuration arguments. This is not even all of it.

[91:38] Andrej Karpathy: You can go to here to see all the training options. Um, and uh there's also quite useful documentation when you look at the raw protobuf uh that is used to represent the trainer spec and so on. Um, many of these options are irrelevant to us. So maybe to point out one example, dash dash shrinking factor. Uh this shrinking factor is not used in the byte-pair encoding algorithm. So this is just an argument that is irrelevant to us. Um, it applies to a different training algorithm.

Note

Key SentencePiece Options for BPE Training:

Essential BPE Parameters:

  • --model_type=bpe - Use byte-pair encoding (default is "unigram")
  • --vocab_size=8000 - Final vocabulary size (e.g., 8000, 16000, 32000)
  • --input=file.txt - Training text file
  • --model_prefix=model_name - Output model prefix (creates .model and .vocab files)

Important for Different Languages:

  • --character_coverage=0.9995 - For languages with rich character sets (Japanese/Chinese)
  • --character_coverage=1.0 - For languages with small character sets (English/European)

Special Tokens:

  • --bos_id=1 - Beginning of sentence token ID
  • --eos_id=2 - End of sentence token ID
  • --unk_id=0 - Unknown token ID
  • --pad_id=-1 - Padding token ID (-1 disables)

Advanced Options:

  • --byte_fallback=true - Use byte fallback for rare characters
  • --split_digits=true - Split numbers into individual digits
  • --user_defined_symbols=["<mask>"] - Add custom special tokens

Note: Many options (like --shrinking_factor) apply only to other algorithms (unigram) and are irrelevant for BPE training.

Note

[92:09] Andrej Karpathy: Now, what I tried to do here is I tried to set up SentencePiece in a way that is very, very similar, as far as I can tell, to maybe identical hopefully, to the way that Llama 2 was trained. So the way they trained their own um their own tokenizer. And the way I did this was basically going to take the tokenizer.model file that Meta released, and you can um open it using the proto protobuf uh sort of file that you can generate. And then you can inspect all the options, and I tried to copy over all the options that look relevant.

[92:42] Andrej Karpathy: So here we set up the input. It's raw text in this file. Here it's going to be the output, so it's going to be prefix tok400.model and .vocab. We're saying that we're going to use the BPE algorithm and we want a vocab size of 400. Then there's a ton of configurations here for um for basically preprocessing and normalization rules as they're called. Normalization used to be very prevalent, I would say before LLMs in natural language processing. So in machine translation and uh 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 white space, etc.

[93:22] Andrej Karpathy: And in language models, it's preferred 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 um in a raw form. So you're basically trying to turn off a lot of this if you can. The other thing that SentencePiece does is that it has this concept of sentences. So SentencePiece, it's back, it kind of was developed, I think early in the days where there was um an idea that they you're training a tokenizer on a bunch of independent sentences. So it has a lot of like how many sentences you're going to train on, what is the maximum sentence length. Um, shuffling sentences. And so for it, sentences are kind of like the individual training examples.

[94:06] Andrej Karpathy: But again, in the context of LLMs, I find that this is like a very spurious and weird distinction. Like sentences are just like, don't touch the raw data. Sentences happen to exist, but in the raw datasets, there are a lot of like in-betweens. Like what exactly is a sentence? What isn't a sentence? Um, and so I think like it's really hard to define what an actual sentence is if you really like dig into it. And there could be different concepts of it in different languages or something like that. So why even introduce the concept? It it doesn't honestly make sense to me. I would just prefer to treat a file as a giant uh stream of bytes.

[94:41] Andrej Karpathy: It has a lot of treatment around rare word characters, and when I say word, I mean code points. We're going to come back to this in a second. And it has a lot of other rules for um basically splitting digits, splitting white space and numbers and how you deal with that. So this is something like merge rules. So I think this is a little bit similar to tiktoken using the regular expression to split up categories. There's like kind of the equivalent of it is quintated in SentencePiece where you can also, for example, split up the digits, uh and uh so on.

[95:16] Andrej Karpathy: There's a few more things here that I'll come back to in a bit. And then there are some special tokens that you can indicate. And it hardcodes the unk token, the beginning of sentence, end of sentence, and a pad token. Um, and the unk token must exist for my understanding. And then some system things. So we can train. And when when I press train, it's going to create this file tok400.model and tok400.vocab. I can then load the model file and I can inspect the vocabulary of it.

[95:47] Andrej Karpathy: And so we trained a vocab size 400 on this text here. And these are the individual pieces, the individual tokens that SentencePiece will create. So in the beginning, we see that we have the unk token with the ID zero. Then we have the beginning of sequence, end of sequence, one and two. And then we said that the pad ID is negative one, so we chose not to use it. So there's no pad ID here.

[96:14] Andrej Karpathy: Then these are individual byte tokens. So here we saw that byte fallback in Llama was turned on, so it's true. So what follows are going to be the 256 byte tokens. And these are their IDs.

Code

# Please upload the colab notebook to solveit https://colab.research.google.com/drive/1y0KnCFZvGVf_odSfcNAws6kcDD7HsI0L
# Colab notebook url redirects to login page, so we've downloaded and uploaded it to solveit manually
!ls -l Tokenization.ipynb

Output: 58

-rw-r--r-- 1 root root 154580 Sep  5 09:42 Tokenization.ipynb

Code

# Create toy training data
with open("toy.txt", "w", encoding="utf-8") as f:
    f.write("SentencePiece is an unsupervised text tokenizer and detokenizer mainly for Neural Network-based text generation systems where the vocabulary size is predetermined prior to the neural model training. SentencePiece implements subword units (e.g., byte-pair-encoding (BPE) [Sennrich et al.]) and unigram language model [Kudo.]) with the extension of direct training from raw sentences. SentencePiece allows us to make a purely end-to-end system that does not depend on language-specific pre/postprocessing.")

Code

# pip install sentencepiece

Code

# NOTE: after pip install sentencepiece can't be imported, requires a restarting the dialogue env
import sentencepiece as spm

Code

# Train a SentencePiece BPE model
# These settings match those used for training Llama 2

options = dict(
    # Input spec
    input="toy.txt",
    input_format="text",
    # Output spec
    model_prefix="tok400", # output filename prefix
    # Algorithm spec - BPE algorithm
    model_type="bpe",
    vocab_size=400,
    # Normalization (turn off to keep raw data)
    normalization_rule_name="identity", # turn off normalization
    remove_extra_whitespaces=False,
    input_sentence_size=200000000, # max number of training sentences
    max_sentence_length=4192, # max number of bytes per sentence
    seed_sentencepiece_size=1000000,
    shuffle_input_sentence=True,
    # Rare word treatment
    character_coverage=0.99995,
    byte_fallback=True,
    # Merge rules
    split_digits=True,
    split_by_unicode_script=True,
    split_by_whitespace=True,
    split_by_number=True,
    max_sentencepiece_length=16,
    add_dummy_prefix=True,
    allow_whitespace_only_pieces=True,
    # Special tokens
    unk_id=0, # the UNK token MUST exist
    bos_id=1, # the others are optional, set to -1 to turn off
    eos_id=2,
    pad_id=-1,
    # Systems
    num_threads=os.cpu_count(), # use ~all system resources
)

spm.SentencePieceTrainer.train(**options);

Output: 9,567

sentencepiece_trainer.cc(78) LOG(INFO) Starts training with : 
trainer_spec {
  input: toy.txt
  input_format: text
  model_prefix: tok400
  model_type: BPE
  vocab_size: 400
  self_test_sample_size: 0
  character_coverage: 0.99995
  input_sentence_size: 200000000
  shuffle_input_sentence: 1
  seed_sentencepiece_size: 1000000
  shrinking_factor: 0.75
  max_sentence_length: 4192
  num_threads: 96
  num_sub_iterations: 2
  max_sentencepiece_length: 16
  split_by_unicode_script: 1
  split_by_number: 1
  split_by_whitespace: 1
  split_digits: 1
  pretokenization_delimiter: 
  treat_whitespace_as_suffix: 0
  allow_whitespace_only_pieces: 1
  required_chars: 
  byte_fallback: 1
  vocabulary_output_piece_score: 1
  train_extremely_large_corpus: 0
  seed_sentencepieces_file: 
  hard_vocab_limit: 1
  use_all_vocab: 0
  unk_id: 0
  bos_id: 1
  eos_id: 2
  pad_id: -1
  unk_piece: <unk>
  bos_piece: <s>
  eos_piece: </s>
  pad_piece: <pad>
  unk_surface:  ⁇ 
  enable_differential_privacy: 0
  differential_privacy_noise_level: 0
  differential_privacy_clipping_threshold: 0
}
normalizer_spec {
  name: identity
  add_dummy_prefix: 1
  remove_extra_whitespaces: 0
  escape_whitespaces: 1
  normalization_rule_tsv: 
}
denormalizer_spec {}
trainer_interface.cc(355) LOG(INFO) SentenceIterator is not specified. Using MultiFileSentenceIterator.
trainer_interface.cc(186) LOG(INFO) Loading corpus: toy.txt
trainer_interface.cc(411) LOG(INFO) Loaded all 1 sentences
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <unk>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <s>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: </s>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x00>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x01>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x02>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x03>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x04>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x05>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x06>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x07>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x08>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x09>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x0A>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x0B>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x0C>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x0D>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x0E>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x0F>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x10>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x11>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x12>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x13>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x14>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x15>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x16>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x17>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x18>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x19>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x1A>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x1B>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x1C>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x1D>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x1E>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x1F>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x20>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x21>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x22>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x23>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x24>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x25>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x26>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x27>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x28>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x29>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x2A>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x2B>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x2C>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x2D>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x2E>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x2F>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x30>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x31>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x32>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x33>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x34>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x35>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x36>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x37>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x38>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x39>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x3A>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x3B>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x3C>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x3D>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x3E>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x3F>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x40>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x41>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x42>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x43>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x44>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x45>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x46>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x47>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x48>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x49>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x4A>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x4B>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x4C>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x4D>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x4E>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x4F>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x50>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x51>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x52>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x53>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x54>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x55>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x56>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x57>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x58>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x59>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x5A>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x5B>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x5C>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x5D>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x5E>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x5F>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x60>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x61>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x62>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x63>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x64>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x65>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x66>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x67>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x68>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x69>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x6A>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x6B>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x6C>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x6D>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x6E>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x6F>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x70>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x71>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x72>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x73>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x74>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x75>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x76>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x77>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x78>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x79>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x7A>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x7B>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x7C>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x7D>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x7E>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x7F>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x80>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x81>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x82>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x83>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x84>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x85>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x86>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x87>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x88>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x89>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x8A>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x8B>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x8C>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x8D>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x8E>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x8F>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x90>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x91>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x92>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x93>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x94>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x95>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x96>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x97>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x98>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x99>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x9A>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x9B>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x9C>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x9D>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x9E>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0x9F>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xA0>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xA1>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xA2>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xA3>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xA4>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xA5>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xA6>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xA7>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xA8>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xA9>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xAA>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xAB>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xAC>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xAD>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xAE>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xAF>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xB0>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xB1>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xB2>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xB3>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xB4>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xB5>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xB6>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xB7>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xB8>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xB9>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xBA>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xBB>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xBC>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xBD>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xBE>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xBF>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xC0>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xC1>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xC2>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xC3>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xC4>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xC5>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xC6>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xC7>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xC8>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xC9>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xCA>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xCB>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xCC>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xCD>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xCE>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xCF>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xD0>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xD1>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xD2>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xD3>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xD4>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xD5>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xD6>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xD7>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xD8>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xD9>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xDA>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xDB>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xDC>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xDD>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xDE>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xDF>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xE0>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xE1>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xE2>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xE3>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xE4>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xE5>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xE6>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xE7>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xE8>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xE9>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xEA>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xEB>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xEC>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xED>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xEE>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xEF>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xF0>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xF1>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xF2>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xF3>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xF4>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xF5>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xF6>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xF7>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xF8>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xF9>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xFA>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xFB>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xFC>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xFD>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xFE>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <0xFF>
trainer_interface.cc(432) LOG(INFO) Normalizing sentences...
trainer_interface.cc(541) LOG(INFO) all chars count=504
trainer_interface.cc(562) LOG(INFO) Alphabet size=39
trainer_interface.cc(563) LOG(INFO) Final character coverage=1
trainer_interface.cc(594) LOG(INFO) Done! preprocessed 1 sentences.
trainer_interface.cc(600) LOG(INFO) Tokenizing input sentences with whitespace: 1
trainer_interface.cc(611) LOG(INFO) Done! 58
bpe_model_trainer.cc(159) LOG(INFO) Updating active symbols. max_freq=18 min_freq=1
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=3 size=20 all=283 active=244 piece=ed
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=2 size=40 all=305 active=266 piece=.]
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=2 size=60 all=324 active=285 piece=ken
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=2 size=80 all=334 active=295 piece=▁model
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=1 size=100 all=338 active=299 piece=lo
bpe_model_trainer.cc(159) LOG(INFO) Updating active symbols. max_freq=1 min_freq=0
trainer_interface.cc(689) LOG(INFO) Saving model: tok400.model
trainer_interface.cc(701) LOG(INFO) Saving vocabs: tok400.vocab

Code

# Load and inspect the trained model
sp = spm.SentencePieceProcessor()
sp.load('tok400.model')

# Show the vocabulary - first few entries
vocab = [[sp.id_to_piece(idx), idx] for idx in range(sp.get_piece_size())]
print("First 20 tokens:")
for token, idx in vocab[:20]:
    print(f"  {idx}: '{token}'")
    
print(f"\nTotal vocabulary size: {len(vocab)}")

Output: 574

First 20 tokens:
  0: '<unk>'
  1: '<s>'
  2: '</s>'
  3: '<0x00>'
  4: '<0x01>'
  5: '<0x02>'
  6: '<0x03>'
  7: '<0x04>'
  8: '<0x05>'
  9: '<0x06>'
  10: '<0x07>'
  11: '<0x08>'
  12: '<0x09>'
  13: '<0x0A>'
  14: '<0x0B>'
  15: '<0x0C>'
  16: '<0x0D>'
  17: '<0x0E>'
  18: '<0x0F>'
  19: '<0x10>'

Total vocabulary size: 400

Code

# Test the SentencePiece tokenizer
test_text = "hello 안녕하세요"
ids = sp.encode(test_text)
pieces = [sp.id_to_piece(idx) for idx in ids]

print(f"Text: '{test_text}'")
print(f"Token IDs: {ids}")
print(f"Token pieces: {pieces}")
print(f"Decoded: '{sp.decode(ids)}'")

# Notice how Korean characters become byte tokens due to byte_fallback=True

Output: 562

Text: 'hello 안녕하세요'
Token IDs: [362, 378, 361, 372, 358, 362, 239, 152, 139, 238, 136, 152, 240, 152, 155, 239, 135, 187, 239, 157, 151]
Token pieces: ['▁', 'h', 'e', 'l', 'lo', '▁', '<0xEC>', '<0x95>', '<0x88>', '<0xEB>', '<0x85>', '<0x95>', '<0xED>', '<0x95>', '<0x98>', '<0xEC>', '<0x84>', '<0xB8>', '<0xEC>', '<0x9A>', '<0x94>']
Decoded: 'hello 안녕하세요'

Note

Byte Fallback in SentencePiece

What is byte fallback? When SentencePiece encounters a rare character (Unicode code point) that's not in the vocabulary, instead of mapping it to <unk>, it:

  1. Converts the character to its UTF-8 bytes
  2. Maps each byte to a special byte token (<0x00> through <0xFF>)

Example:

  • Korean character '안' → UTF-8 bytes: 0xEC 0x95 0x88
  • Becomes 3 tokens: <0xEC>, <0x95>, <0x88>

Key benefits:

  • No information loss - can perfectly reconstruct original text
  • Universal coverage - handles any language/character
  • Graceful degradation - rare characters just use more tokens

Vocabulary impact:

  • All 256 byte tokens are automatically added to vocabulary
  • Takes up 256 slots of your vocab_size
  • Remaining slots used for learned BPE merges

vs tiktoken: SentencePiece tries character-level first, falls back to bytes. tiktoken always works at byte-level.

Note

[96:32] Andrej Karpathy: And then at the bottom, after the byte tokens, come the merges. And these are the parent nodes in the merges. So we're not seeing the children, we're just seeing the parents and their ID. And then after the merges comes eventually the individual tokens and their IDs. And so these are the individual tokens, so these are the individual code point tokens, if you will, and they come at the end.

[96:59] Andrej Karpathy: So that is the ordering with which SentencePiece sort of represents its vocabularies. It starts with special tokens, then the byte tokens, then the merge tokens, and then the individual code point tokens. And all these raw code point tokens are the ones that it encountered in the training set. So those individual code points are all the the entire set of code points that occurred here.

[97:27] Andrej Karpathy: And then those that are extremely rare, as determined by character coverage, so if a code point occurred only a single time out of like a million um sentences or something like that, then it would be ignored and it would not be added to our uh vocabulary. Once we have a vocabulary, we can encode into IDs and we can um sort of get a list. And then here I am also decoding the individual tokens back into little pieces as they call it.

Code

# Show the SentencePiece vocabulary structure
print("SentencePiece Vocabulary Structure:")
print("=" * 40)

# 1. Special tokens (first few)
print("1. Special tokens:")
for i in range(3):
    print(f"  {i}: '{sp.id_to_piece(i)}'")

print("\n2. Byte tokens (next 256):")
print("  3-258: <0x00> through <0xFF>")
for i in [3, 4, 5, 257, 258]:  # Show first few and last few
    print(f"  {i}: '{sp.id_to_piece(i)}'")

Output: 291

SentencePiece Vocabulary Structure:
========================================
1. Special tokens:
  0: '<unk>'
  1: '<s>'
  2: '</s>'

2. Byte tokens (next 256):
  3-258: <0x00> through <0xFF>
  3: '<0x00>'
  4: '<0x01>'
  5: '<0x02>'
  257: '<0xFE>'
  258: '<0xFF>'

Code

# 3. Merge tokens (BPE learned merges)
print("\n3. Merge tokens (BPE merges):")
print("  259-399: Learned BPE merges")
for i in range(259, min(269, sp.get_piece_size())):  # Show first 10 merges
    print(f"  {i}: '{sp.id_to_piece(i)}'")

# 4. Individual code point tokens
print("\n4. Individual code point tokens:")
print("  These are raw Unicode characters from training data")
# Find where individual tokens start (after merges)
for i in range(350, min(400, sp.get_piece_size())):
    piece = sp.id_to_piece(i)
    if len(piece) == 1 and not piece.startswith('<'):  # Single character, not a byte token
        print(f"  {i}: '{piece}'")
        if i > 360:  # Just show a few examples
            break

Output: 295


3. Merge tokens (BPE merges):
  259-399: Learned BPE merges
  259: 'en'
  260: '▁t'
  261: 'ce'
  262: 'in'
  263: 'ra'
  264: '▁a'
  265: 'de'
  266: 'er'
  267: '▁s'
  268: 'ent'

4. Individual code point tokens:
  These are raw Unicode characters from training data
  361: 'e'

Note

[97:56] Andrej Karpathy: So let's take a look at what happened here. Hello space 안녕하세요. So these are the token IDs we got back. And when we look here, uh a few things sort of uh jump to mind. Number one, take a look at these characters. The Korean characters, of course, were not part of the training set. So SentencePiece is encountering code points that it has not seen during training time, and those code points do not have a token associated with them. So suddenly these are unk tokens, unknown tokens.

[98:30] Andrej Karpathy: But because byte fallback is true, instead, SentencePiece falls back to bytes. And so it takes this, it encodes it with UTF-8, and then it uses these tokens to represent uh those bytes. And that's what we are getting sort of here. This is the UTF-8 uh encoding, and it is shifted by three uh because of these um special tokens here that have IDs earlier on. So that's what happened here.

[99:00] Andrej Karpathy: Now, one more thing that um, well, first before I go on, with respect to the byte fallback, let me remove byte fallback. If this is false, what's going to happen? Let's retrain. So the first thing that happened is all the byte tokens disappeared, right? And now we just have the merges, and we have a lot more merges now because we have a lot more space because we're not taking up space in the vocab size uh with all the bytes.

[99:26] Andrej Karpathy: And now if we encode this, we get a zero. So this entire string here suddenly, there's no byte fallback, so this is unknown, and unknown is unk. And so this is zero because the unk token is token zero. And you have to keep in mind that this would feed into your uh language model. So what is the language model supposed to do when all kinds of different things that are unrecognized because they are rare just end up mapping into unk? It's not exactly the property that you want. So that's why I think Llama correctly uh used byte fallback.

Code

# Train SentencePiece WITHOUT byte fallback
options_no_fallback = options.copy()
options_no_fallback['byte_fallback'] = False
options_no_fallback['model_prefix'] = "tok400_no_fallback"

spm.SentencePieceTrainer.train(**options_no_fallback);

Output: 1,962

sentencepiece_trainer.cc(78) LOG(INFO) Starts training with : 
trainer_spec {
  input: toy.txt
  input_format: text
  model_prefix: tok400_no_fallback
  model_type: BPE
  vocab_size: 400
  self_test_sample_size: 0
  character_coverage: 0.99995
  input_sentence_size: 200000000
  shuffle_input_sentence: 1
  seed_sentencepiece_size: 1000000
  shrinking_factor: 0.75
  max_sentence_length: 4192
  num_threads: 96
  num_sub_iterations: 2
  max_sentencepiece_length: 16
  split_by_unicode_script: 1
  split_by_number: 1
  split_by_whitespace: 1
  split_digits: 1
  pretokenization_delimiter: 
  treat_whitespace_as_suffix: 0
  allow_whitespace_only_pieces: 1
  required_chars: 
  byte_fallback: 0
  vocabulary_output_piece_score: 1
  train_extremely_large_corpus: 0
  seed_sentencepieces_file: 
  hard_vocab_limit: 1
  use_all_vocab: 0
  unk_id: 0
  bos_id: 1
  eos_id: 2
  pad_id: -1
  unk_piece: <unk>
  bos_piece: <s>
  eos_piece: </s>
  pad_piece: <pad>
  unk_surface:  ⁇ 
  enable_differential_privacy: 0
  differential_privacy_noise_level: 0
  differential_privacy_clipping_threshold: 0
}
normalizer_spec {
  name: identity
  add_dummy_prefix: 1
  remove_extra_whitespaces: 0
  escape_whitespaces: 1
  normalization_rule_tsv: 
}
denormalizer_spec {}
trainer_interface.cc(355) LOG(INFO) SentenceIterator is not specified. Using MultiFileSentenceIterator.
trainer_interface.cc(186) LOG(INFO) Loading corpus: toy.txt
trainer_interface.cc(411) LOG(INFO) Loaded all 1 sentences
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <unk>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: <s>
trainer_interface.cc(427) LOG(INFO) Adding meta_piece: </s>
trainer_interface.cc(432) LOG(INFO) Normalizing sentences...
trainer_interface.cc(541) LOG(INFO) all chars count=504
trainer_interface.cc(562) LOG(INFO) Alphabet size=39
trainer_interface.cc(563) LOG(INFO) Final character coverage=1
trainer_interface.cc(594) LOG(INFO) Done! preprocessed 1 sentences.
trainer_interface.cc(600) LOG(INFO) Tokenizing input sentences with whitespace: 1
trainer_interface.cc(611) LOG(INFO) Done! 58
bpe_model_trainer.cc(159) LOG(INFO) Updating active symbols. max_freq=18 min_freq=1
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=3 size=20 all=283 active=244 piece=ed
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=2 size=40 all=305 active=266 piece=.]
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=2 size=60 all=324 active=285 piece=ken
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=2 size=80 all=334 active=295 piece=▁model
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=1 size=100 all=338 active=299 piece=lo
bpe_model_trainer.cc(159) LOG(INFO) Updating active symbols. max_freq=1 min_freq=0
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=1 size=120 all=347 active=308 piece=▁v
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=1 size=140 all=354 active=315 piece=rat
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=1 size=160 all=352 active=313 piece=lary
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=1 size=180 all=348 active=309 piece=igram
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=1 size=200 all=342 active=303 piece=▁where
bpe_model_trainer.cc(159) LOG(INFO) Updating active symbols. max_freq=1 min_freq=0
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=1 size=220 all=327 active=288 piece=determined
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=0 size=240 all=308 active=269 piece=aw
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=0 size=260 all=288 active=249 piece=ie
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=0 size=280 all=268 active=229 piece=od
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=0 size=300 all=248 active=209 piece=tw
bpe_model_trainer.cc(159) LOG(INFO) Updating active symbols. max_freq=0 min_freq=0
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=0 size=320 all=228 active=189 piece=▁i
bpe_model_trainer.cc(268) LOG(INFO) Added: freq=0 size=340 all=208 active=169 piece=ely
trainer_interface.cc(689) LOG(INFO) Saving model: tok400_no_fallback.model
trainer_interface.cc(701) LOG(INFO) Saving vocabs: tok400_no_fallback.vocab

Code

# Load the no-fallback model and compare vocabularies
sp_no_fallback = spm.SentencePieceProcessor()
sp_no_fallback.load('tok400_no_fallback.model')

print(f"With byte fallback: {sp.get_piece_size()} tokens")
print(f"Without byte fallback: {sp_no_fallback.get_piece_size()} tokens")

# Show that byte tokens are gone
print("\nFirst 10 tokens (no fallback):")
for i in range(10):
    print(f"  {i}: '{sp_no_fallback.id_to_piece(i)}'")

Output: 267

With byte fallback: 400 tokens
Without byte fallback: 400 tokens

First 10 tokens (no fallback):
  0: '<unk>'
  1: '<s>'
  2: '</s>'
  3: 'en'
  4: '▁t'
  5: 'ce'
  6: 'in'
  7: 'ra'
  8: '▁a'
  9: 'de'

Code

# Test encoding Korean text without byte fallback
test_text = "hello 안녕하세요"

# With byte fallback
ids_with_fallback = sp.encode(test_text)
print(f"With fallback: {ids_with_fallback}")
print(f"Decoded: '{[sp.id_to_piece(id) for id in ids_with_fallback]}'")

# Without byte fallback  
ids_no_fallback = sp_no_fallback.encode(test_text)
print(f"\nWithout fallback: {ids_no_fallback}")
print(f"Decoded: '{[sp_no_fallback.id_to_piece(id) for id in ids_no_fallback]}'")

# Korean characters become UNK (token 0)

Output: 672

With fallback: [362, 378, 361, 372, 358, 362, 239, 152, 139, 238, 136, 152, 240, 152, 155, 239, 135, 187, 239, 157, 151]
Decoded: '['▁', 'h', 'e', 'l', 'lo', '▁', '<0xEC>', '<0x95>', '<0x88>', '<0xEB>', '<0x85>', '<0x95>', '<0xED>', '<0x95>', '<0x98>', '<0xEC>', '<0x84>', '<0xB8>', '<0xEC>', '<0x9A>', '<0x94>']'

Without fallback: [362, 378, 252, 102, 362, 0]
Decoded: '['▁', 'h', 'el', 'lo', '▁', '<unk>']'

Note

Why Korean text becomes one UNK token:

SentencePiece preprocesses text into chunks before tokenization:

  • "hello 안녕하세요" → chunks: ["hello", " ", "안녕하세요"]
  • Each chunk is tokenized separately
  • Since no Korean characters are in vocabulary, entire chunk "안녕하세요" → single <unk> token

Key insight: SentencePiece doesn't go character-by-character. It processes meaningful text chunks, so unknown chunks become single UNK tokens regardless of their length.

Code

# Test different Korean text patterns
test_cases = [
    "안녕하세요",           # Single chunk
    "안녕 하세요",          # Space in middle
    "안녕, 하세요",         # Comma separator
]

for text in test_cases:
    ids = sp_no_fallback.encode(text)
    pieces = [sp_no_fallback.id_to_piece(id) for id in ids]
    print(f"'{text}' → {ids} → {pieces}")

Output: 315

'안녕하세요' → [362, 0] → ['▁', '<unk>']
'안녕 하세요' → [362, 0, 362, 0] → ['▁', '<unk>', '▁', '<unk>']
'안녕, 하세요' → [362, 0, 395, 362, 0] → ['▁', '<unk>', ',', '▁', '<unk>']

Note

[100:00] Andrej Karpathy: fallback true, because we definitely want to feed these unknown or rare code points into the model in some, some manner.

[100:08] Andrej Karpathy: The next thing I want to show you is the following. Notice here when we are decoding all the individual tokens, you see how spaces, uh, space here ends up being this bold underline. 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.

[100:31] Andrej Karpathy: But notice this, why do we have an extra space in the front of hello? Uh, what where is this coming from? Well, it's coming from this option here, um, add dummy prefix is true.

[100:48] Andrej Karpathy: And when you go to the documentation, add dummy whitespace at the beginning of text in order to treat "world" in "world" and "hello world" in the exact same way.

Note

// Add dummy whitespace at the beginning of text in order to
// treat "world" in "world" and "hello world" in the exact same way.
optional bool add_dummy_prefix = 26 [default = true];

Purpose: Ensures consistent tokenization by making sure words are treated the same whether they appear at the beginning of text or in the middle. Without this, "world" alone vs "world" in "hello world" might tokenize differently due to the presence/absence of leading whitespace.

Code

sp.encode('world'), sp.encode('a world')

Output: 58

([313, 269, 372, 370], [264, 313, 269, 372, 370])

Code

sp.id_to_piece(313)

Output: 33

'▁w'

Note

[100:57] Andrej Karpathy: So what this is trying to do is the following. If we go back to our tiktokenizer, "world" as a token by itself has a different ID than "space world". So we have, this is 1917, but this is 14, etc. So these are two different tokens for the language model, and the language model has to learn from data that they are actually kind of a very similar 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. Um, and it has to learn that they are roughly the same.

pasted_image

The tiktokenizer web app interface. The input text contains 'world' on one line and 'hello world' on the next. The output shows four tokens with their corresponding IDs, demonstrating that 'world' (14957) and ' world' (1917) are tokenized differently.

[101:34] Andrej Karpathy: So this add dummy prefix is trying to fight that a little bit. And the way that works is that it basically, uh, adds a dummy prefix. So for, as a, as a part of preprocessing, it will take this string and it will add a space. It will do this. And that's done in an effort to make this world and that world the same. They will both be "space world". So that's one other kind of preprocessing option that is turned on, and Llama 2 also, uh, uses this option.

Note

SentencePiece Summary

[102:07] Andrej Karpathy: And that's I think everything that I want to say from my preview of SentencePiece and how it is different. Um, maybe here what I've done is I just, uh, put in the raw protocol buffer representation basically of the tokenizer that Llama 2 trained. So feel free to sort of inspect through this, and if you would like, uh, your tokenization to look identical to that of the Meta, uh, Llama 2, then you would be copy-pasting these settings as I've tried to do up above. And, uh, yeah, that's, I think that's it for this section.

pasted_image

A Jupyter Notebook cell displaying the raw protocol buffer output for the Llama 2 tokenizer. It shows a long list of configuration parameters under 'normalizer_spec' and 'trainer_spec'.

[102:38] Andrej Karpathy: I think my summary for SentencePiece from all this is, number one, I think that there's a lot of historical baggage in SentencePiece. A lot of concepts that I think are slightly confusing and I think potentially, um, contain footguns, like this concept of a sentence and its maximum length and stuff like that. Um, otherwise it is fairly commonly used in the industry, um, because it is efficient and can do both training and inference. Uh, 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. And unfortunately, I have to say it's not very well documented. So it took me a lot of time working with this myself, um, and just visualizing things and trying to really understand what is happening here because, um, documentation unfortunately is, in my opinion, not, not super amazing. But it is a very nice repo that is available to you if you'd like to train your own tokenizer right now.

Code

!ls -l Tokenization.ipynb

Note

Llama 2 Tokenizer Configuration (from protobuf):

normalizer_spec {
  name: "identity"
  precompiled_charsmap: ""
  add_dummy_prefix: true
  remove_extra_whitespaces: false
  normalization_rule_tsv: ""
}

trainer_spec {
  input: "/large_experiments/theorem/datasets/MERGED/all.test1.merged"
  model_prefix: "spm_model_32k_200M_charcov099995_allowWSO__v2"
  model_type: BPE
  vocab_size: 32000
  self_test_sample_size: 0
  input_format: "text"
  character_coverage: 0.99995
  input_sentence_size: 200000000
  seed_sentencepiece_size: 1000000
  shrinking_factor: 0.75
  num_threads: 80
  num_sub_iterations: 2
  max_sentence_length: 4192
  shuffle_input_sentence: true
  max_sentencepiece_length: 16
  split_by_unicode_script: true
  split_by_whitespace: true
  split_by_number: true
  treat_whitespace_as_suffix: false
  split_digits: true
  allow_whitespace_only_pieces: true
  vocabulary_output_piece_score: true
  hard_vocab_limit: true
  use_all_vocab: false
  byte_fallback: true
  required_chars: ""
  unk_id: 0
  bos_id: 1
  eos_id: 2
  pad_id: -1
  unk_surface: " ⁇ "
  unk_piece: "<unk>"
  bos_piece: "<s>"
  eos_piece: "</s>"
  pad_piece: "<pad>"
  train_extremely_large_corpus: false
  enable_differential_privacy: false
  differential_privacy_noise_level: 0.0
  differential_privacy_clipping_threshold: 0
}

This shows the exact configuration Meta used to train Llama 2's tokenizer, including all the preprocessing options, vocabulary settings, and special token definitions that Andrej discusses in the video.

Note

vocab size

[103:28] Andrej Karpathy: Okay, let me now switch gears again as we're starting to slowly wrap up here. I want to revisit this issue in a bit more detail of how we should set the vocab size or some of the considerations around it. So for this, I'd like to go back to the model architecture that we developed in the last video when we built the GPT from scratch.

[103:45] Andrej Karpathy: So this here was, uh, the file that we built in the previous video, and we defined the transformer model. And let's specifically look at vocab size and where it appears in this file. So here we define the vocab size. Uh, at this time it was 65 or something like that, extremely small number. So this will grow much larger.

[104:03] Andrej Karpathy: You'll see that vocab size doesn't come up too much in most of these layers. The only place that it comes up to is in exactly these two places here. So when we define the language model, there's the token embedding table, which is this two-dimensional array where the vocab size is basically the number of rows. And, uh, each vocabulary element, each token, has a vector that we're going to train using backpropagation. That vector is of size n_embed, which is the number of channels in the transformer. And basically as vocab size increases, this embedding table, as I mentioned earlier, is going to also grow. We're going to be adding rows.

[104:38] Andrej Karpathy: In addition to that, at the end of the transformer, there's this lm_head layer, which is a linear layer. And you'll notice that that layer is used at the very end to produce the logits, uh, which become the probabilities for the next token in the sequence. And so intuitively, we're trying to produce a probability for every single token that might come next at every point in time of that transformer. And 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 here in this linear layer for this final layer in the transformer.

Note

# From gpt.py - Vocabulary size definition
vocab_size = len(chars)  # Based on unique characters in text

File: gpt.py - Character-level vocabulary size

This shows how vocab_size is initially set based on the number of unique characters in the training text (e.g., 65 for Shakespeare dataset).

Note

# From gpt.py - Token embedding table
class GPTLanguageModel(nn.Module):
    def __init__(self):
        super().__init__()
        # Token embedding table - vocab_size rows, n_embed columns
        self.token_embedding_table = nn.Embedding(vocab_size, n_embed)
        self.position_embedding_table = nn.Embedding(block_size, n_embed)
        # ... other layers ...
        # Final linear layer - projects to vocab_size logits
        self.lm_head = nn.Linear(n_embed, vocab_size)

File: gpt.py - Model architecture

This shows the two places where vocab_size matters:

  1. Token embedding: Maps token IDs to vectors (vocab_size → n_embed)
  2. Language model head: Maps final hidden states to logits (n_embed → vocab_size)

Note

# From gpt.py - Forward pass using lm_head
def forward(self, idx, targets=None):
    B, T = idx.shape
    
    # Token and position embeddings
    tok_emb = self.token_embedding_table(idx)  # (B,T,C)
    pos_emb = self.position_embedding_table(torch.arange(T, device=device))  # (T,C)
    x = tok_emb + pos_emb  # (B,T,C)
    
    # Transformer blocks
    x = self.blocks(x)  # (B,T,C)
    x = self.ln_f(x)  # (B,T,C)
    
    # Final projection to vocabulary
    logits = self.lm_head(x)  # (B,T,vocab_size)
    
    # ... loss calculation ...

File: gpt.py - Forward pass

The lm_head layer produces logits for each token position, with one probability for every token in the vocabulary. As vocab_size grows, this final computation becomes more expensive.

Note

[105:12] Andrej Karpathy: So why can't vocab size be infinite? Why can't it grow to infinity? Well, number one, your token embedding table is going to grow. Uh, your linear layer is going to grow. So we're going to be doing a lot more computation here because this lm_head layer will become more computationally expensive. Number two, because we have more parameters, we could be worried that we are going to be under-training some of these parameters.

[105:36] Andrej Karpathy: So intuitively, if you have a very large vocabulary size, say we have a million, uh, 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, uh, for each individual token. And you might be worried that basically the vectors associated with every token will be under-trained as a result because they just don't come up too often and don't participate in the forward-backward pass.

[106:03] Andrej Karpathy: In addition to that, as your vocab size grows, you're going to start shrinking your sequences a lot, right? And that's really nice because that means that we're going to be attending to more and more text. So that's nice. But also you might be worried that too large of chunks are being squished into single tokens. And so the model just doesn't have as much sort of time to think per sort of, um, some number of characters in a text, or you can think about it that way, right? So 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.

[106:36] Andrej Karpathy: And so these are some of the considerations you're thinking about when you're designing the vocab size. As I mentioned, 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.

Note

How can I increase vocab size?

[106:49] Andrej Karpathy: And the next consideration I want to briefly talk about is what if we want to take a pre-trained model and we want to extend the vocab size? And this is done fairly commonly actually. So for example, when you're doing fine-tuning with ChatGPT, um, 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 the user and the 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.

[107:24] Andrej Karpathy: So if you want to be adding a token, that's totally possible, right? All we have to do is we have to resize this embedding, so we have to add rows. We would initialize these, uh, parameters from scratch, which would be small random numbers. And then we have to extend the weight inside this linear. Uh, so we have to start making dot products, um, with the associated parameters as well to basically calculate the probabilities for these new tokens.

[107:47] Andrej Karpathy: So both of these are just the resizing operation. It's a very mild, uh, model surgery and can be done fairly easily. And 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. Um, and so you can freeze arbitrary parts of it or you can train arbitrary parts of it and that's totally up to you. So basically minor surgery required if you'd like to introduce new tokens.

Note

Learning to Compress Prompts with Gist Tokens

[108:11] Andrej Karpathy: And finally, I'd like to mention that actually 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. So just to give you the sense of the design space, but this could be an entire video just by itself. Uh, this is a paper on learning to compress prompts with what they call gist tokens.

[108:31] Andrej Karpathy: And the rough idea is, suppose that you're using language models in a setting that requires very long prompts. Well, these long prompts just slow everything down because you have to encode them and then you have to use them and then you're attending over them and it's just, um, you know, heavy to have very large prompts. So instead, what they do here in this paper is they introduce new tokens and, um, imagine basically having a few new tokens, you put them in a sequence, and then you train the model by distillation.

pasted_image

A figure from the 'Gist Tokens' paper comparing three methods: Prompting, Finetuning/Distillation, and Gisting. The Gisting diagram shows a short sequence of special 'gist tokens' being used to achieve the same result as a much longer prompt.

[109:01] Andrej Karpathy: So you are keeping the entire model frozen and you're only training the representations of the new tokens, their embeddings. And you're optimizing over the new tokens such that the behavior of the language model is identical, uh, to the model that has a very long prompt that works for you. And 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 like a stand-in for that very long prompt and have an almost identical performance. And so this is one, um, technique in 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, uh, token embeddings. So that's just one example, but this could again be like an entire video, but just to give you a sense that there's a whole design space here that is potentially worth exploring in the future.

Code

# Step 1: Download the paper PDF
import requests
with open("gist_tokens.pdf", "wb") as f: f.write(requests.get("https://arxiv.org/pdf/2304.08467.pdf").content)

Code

ls -l gist_tokens.pdf

Output: 60

-rw-r--r-- 1 solveit solveit 1557189 Sep  5 15:46 gist_tokens.pdf

Code

import pypdf
with open('gist_tokens.pdf', 'rb') as file: text = "".join([page.extract_text() for page in pypdf.PdfReader(file).pages])
with open('gist_tokens.txt', 'w') as f: f.write(text)

Code

!head gist_tokens.txt

Output: 216

Learning to Compress Prompts with Gist Tokens
Jesse Mu, Xiang Lisa Li, Noah Goodman
Stanford University
muj@cs.stanford.edu, {xlisali,ngoodman}@stanford.edu
Abstract
Prompting is the primary way to utilize the multitask capabilities of language
models (LMs), but prompts occupy valuable space in the input context window, and
repeatedly encoding the same prompt is computationally inefficient. Finetuning
and distillation methods allow for specialization of LMs without prompting, but
require retraining the model for each task. To avoid this trade-off entirely, we

Note

Supporting Quotes from the Gist Tokens Paper:

My answer about training gist tokens for categories of prompts rather than individual prompts is supported by these key quotes:

1. Meta-learning approach for generalization:

"But where prefix-tuning requires learning prefixes via gradient descent for each task, gisting adopts a meta-learning approach, where we simply predict the gist prefixes zero-shot given only the prompt, allowing for generalization to unseen instructions without any additional training."

2. Training across a distribution of tasks:

"However, we differ from this prior work in that we are not interested in distilling just a single task, but in amortizing the cost of distillation across a distribution of tasks T. That is, given a task t ∼ T, instead of obtaining the distilled model via gradient descent, we use G to simply predict the gist tokens (≈ parameters) of the distilled model"

3. Single model handles multiple task types:

"A dataset with a large variety of tasks (prompts) is crucial to learn gist models that can generalize. To obtain the largest possible set of tasks for instruction finetuning, we create a dataset called Alpaca+, which combines... 104,664 unique tasks t"

4. Reusable across similar prompts:

"Since gist tokens are much shorter than the full prompt, gisting allows arbitrary prompts to be compressed, cached, and reused for compute efficiency."

Key insight: The paper trains one model that learns to compress any prompt into gist tokens, rather than training separate tokens for each individual prompt. The gist tokens are predicted dynamically based on the input prompt content.

Note

Taming Transformers for High-Resolution Image Synthesis (a.k.a VQGAN)

[109:58] Andrej Karpathy: The next thing I want to briefly address is that I think recently 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. So be it images, videos, audio, etc. And how do you feed in all these modalities and potentially predict these modalities from a transformer? Uh, do you have to change the architecture in some fundamental way? And I think 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 identical in an identical manner.

[110:35] Andrej Karpathy: So here for example, there was an early paper that has a nice graphic for how you can take an image and you can truncate it into integers. Um, and these, uh, sometimes, uh, so these would basically become the tokens of images as an example. And, uh, these tokens can be, uh, hard tokens where you, uh, force them to be integers. They can also be soft tokens where you, uh, sort of don't require, uh, these to be discrete, but you do force these representations to go through a bottleneck, like in autoencoders.

pasted_image

A diagram illustrating the VQGAN architecture. An image of a dog is passed through a CNN Encoder, quantized into a codebook, and then fed into a Transformer. The output is passed through a CNN Decoder to generate a 'realfake' image.

Note

OpenAI Sora

[111:05] Andrej Karpathy: Uh, also in this technical report that came out from OpenAI Sora, which I think really, um, uh, blew the mind of many people and inspired a lot of people in terms of what's possible. They have a graphic here and they talk briefly about how LLMs have text tokens, Sora has visual patches. So again, they came up with a way to truncate videos into basically tokens with their own vocabularies. And then you can either process discrete tokens, say with autoregressive models, or even soft tokens with diffusion models. And, uh, all of that is sort of, uh, being actively worked on and designed on and is beyond the scope of this video, but just something I wanted to mention briefly.

pasted_image

A screenshot from the OpenAI Sora technical report. A paragraph is highlighted which states, 'Whereas LLMs have text tokens, Sora has visual patches.' Below, a diagram shows a video frame being broken down into a 3D grid of patches.

Note

Tokenization is at the heart of the weirdness of LLMs

[111:42] Andrej Karpathy: Okay, now that we have gone 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.

[111:55] Andrej Karpathy: So first of all, why can't my LLM spell words very well or do other spell-related tasks? So fundamentally, this is because, as we saw, these characters are chunked up into tokens, and some of these tokens are actually fairly long. So as an example, I went to the GPT-4 vocabulary and I looked at, uh, one of the longer tokens. So .DefaultCellStyle turns out to be a single individual token. So that's a lot of characters for a single token.

pasted_image

The tiktokenizer web app showing that the string '.DefaultCellStyle' is treated as a single token with ID 98518 by the cl100k_base tokenizer.

[112:22] Andrej Karpathy: 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, uh, single token. So I asked, how many letters L are there in the word .DefaultCellStyle? And of course, my prompt is intentionally done that way. And you see how .DefaultCellStyle will be a single token. So this is what the model sees. So my suspicion is that it wouldn't be very good at this, and indeed it is not. It doesn't actually know how many L's are in there. It thinks there are three, and actually there are four. So I'm not getting this wrong myself. So that didn't go extremely well.

pasted_image

A screenshot of a conversation with ChatGPT 4. The user asks, 'How many letters 'l' are there in the word '.DefaultCellStyle'?' ChatGPT incorrectly replies, 'The word '.DefaultCellStyle' contains three 'l' letters.'

[113:01] Andrej Karpathy: Let's look at another kind of, uh, character-level task. So for example, here I asked, uh, GPT-4 to reverse the string .DefaultCellStyle. And it tried to use a code interpreter, and I stopped it and I said, just do it, just try it. And, uh, it gave me jumble. So it doesn't actually really know how to reverse this string going from right to left. Uh, so it gave a wrong result.

[113:26] Andrej Karpathy: So again, like working with this, working on the hypothesis that maybe this is due to tokenization, I tried a different approach. I said, okay, let's reverse the exact same string, but take the following approach. Step one, just print out every single character separated by spaces, and then as a step two, reverse that list. And it again tried to use a tool, but when I stopped it, it, uh, first, uh, produced all the characters, and that was actually correct. And then it reversed them, and that was correct once it had this. So somehow it can't reverse it directly, but when you go just first, uh, you know, listing it out in order, it can do that somehow. And then it can, once it's, uh, 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. So that is kind of interesting.

pasted_image

A ChatGPT conversation showing a successful two-step string reversal. First, the model correctly lists each character of '.DefaultCellStyle' separated by spaces. Then, it correctly reverses that list of characters.

[114:15] Andrej Karpathy: So let's continue now. Why are LLMs worse at, uh, non-English languages? And I briefly covered this already, but basically, um, 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, uh, is not sufficiently trained on non-English data. And so here, for example, "Hello how are you?" is five tokens, and its translation is 15 tokens. So this is a three times blow-up. And so, for example, "annyeonghaseyo" is, uh, 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. It is a typical greeting, like "hello", and that ends up being three tokens, whereas our "hello" is a single token. And so basically everything is a lot more bloated and diffused, and this is I think partly the reason that the model works worse on other languages.

pasted_image

The tiktokenizer web app comparing an English phrase and its Korean translation. 'Hello how are you?' is 5 tokens, while the Korean equivalent '안녕하세요 어떻게 지내세요?' is 15 tokens, resulting in a total of 20 tokens.

[115:08] Andrej Karpathy: Coming back, why is LLM bad at simple arithmetic? Um, that has to do with the tokenization of numbers. And so, um, you'll notice that, for example, addition is very sort of like, uh, there's an algorithm that is like 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.

pasted_image

A slide titled 'Addition Using Standard Algorithm'. It shows the addition of 1,296 and 3,457, with a carry-over '1' highlighted. The steps listed are: 1. Add the ones, 2. Add the tens, 3. Add the hundreds.

[115:33] Andrej Karpathy: But, uh, these numbers are represented completely arbitrarily based on whatever happened to merge or not merge during the tokenization process. There's an entire blog post about this that I think is quite good, "Integer tokenization is insane". And this person basically systematically explores the tokenization of numbers in, I believe this is GPT-2. And so they notice that, for example, for the, for, uh, four-digit numbers, you can take a look at whether it is, uh, a single token or whether it is two tokens that is a 1-3 or a 2-2 or a 3-1 combination. And so all the different numbers are all the different combinations. And you can imagine that this is all completely arbitrarily so. And the model, unfortunately, sometimes sees, uh, four, um, a token for, for all four digits, sometimes for three, sometimes for two, sometimes for one, and it's in an arbitrary, uh, 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. And so that's why, for example, we saw that Meta, when they trained the Llama 2 algorithm and they used SentencePiece, they made sure to split up all the, um, all the digits as an example for, uh, Llama 2. And this is partly to improve, uh, simple arithmetic kind of performance.

pasted_image

A visualization from the 'Integer tokenization is insane' blog post. It's a heatmap showing how 4-digit numbers are composed into tokens by the GPT-2 tokenizer. Different colors represent different compositions (e.g., unique, 1-3 split, 2-2 split, 3-1 split), revealing a non-uniform and somewhat chaotic pattern.

[116:48] Andrej Karpathy: And finally, why is GPT-2 not as good in Python? Again, this is partly a modeling issue on in the architecture and the dataset and the strength of the model, but it's also partly tokenization because 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. So that's almost like a tokenization bug for GPT-2, and that was later fixed with GPT-4.

[117:19] Andrej Karpathy: Okay, so here's another fun one. My LLM abruptly halts when it sees the string <|endoftext|>. So here's, um, here's a very strange behavior. Print the string <|endoftext|>. That's what I told GPT-4. And it says, "Could you please specify the string?" And I'm telling it, "Give me <|endoftext|>." And it seems like there's an issue. It's not seeing <|endoftext|>. And then I give it <|endoftext|> is the string, and then here's the string, and then it just doesn't print it.

pasted_image

A ChatGPT-4 conversation where the user repeatedly tries to get the model to print the string '<|endoftext|>', but the model acts confused and fails to output the string.

[117:46] Andrej Karpathy: So obviously something is breaking here with respect to the handling of the special token. And I didn't actually know what OpenAI is doing under the hood here and whether they are potentially parsing this as an, um, as an actual token instead of this just being <|endoftext|> as like individual sort of pieces of it without the special token handling logic. And so it might be that someone when they're calling .encode, uh, they are passing in the allowed_special and they are allowing <|endoftext|> as a special character in the user prompt. But the user prompt, of course, is, is a sort of, um, attacker-controlled text. So you would hope that they don't really parse or use special tokens or, you know, uh, from that kind of input. But it appears that there's something definitely going wrong here. And, um, so your knowledge of these special tokens ends up being an attack surface potentially. And so if you'd like to confuse, uh, LLMs, then just, um, try to give them some special tokens and see if you're breaking something by chance.

[118:46] Andrej Karpathy: Okay, so this next one is another fun one. Uh, the trailing whitespace issue. So if you come to Playground and, uh, we come here to gpt-3.5-turbo-instruct. So this is not a chat model, this is a completion model. So think of it more like, it's a lot more closer to a base model. It does completion. It will continue the token sequence.

[119:08] Andrej Karpathy: So here's a tagline for an ice cream shop, and we want to continue the sequence. And so we can submit and get a bunch of tokens. Okay, no problem. But now, suppose I do this, but instead of pressing submit here, I do, "Here's a tagline for an ice cream shop space." So I have a space here before I click submit. We get a warning. "Your text ends in a trailing space, which causes worse performance due to how the API splits text into tokens."

pasted_image

The OpenAI Playground showing a prompt that ends with a space. A yellow warning box appears below, stating: 'Warning: Your text ends in a trailing space, which causes worse performance due to how the API splits text into tokens.'

[119:37] Andrej Karpathy: So what's happening here? It still gave us a, uh, sort of completion here, but let's take a look at what's happening. So here's a tagline for an ice cream shop. And then what does this look like in the actual training data? Suppose you found the completion in the training document somewhere on the internet and the LLM trained on this data. So maybe it's something like, "Oh yeah." Maybe that's the completion.

pasted_image

The tiktokenizer web app showing the prompt 'Here is a tag line for an ice cream shop' without a trailing space. The token count is 11.

Note

The Problem with Trailing Spaces and Partial Tokens

[120:00] Andrej Karpathy: terrible tagline. But notice here that when I create O, you see that because there's the the space character is always a prefix to these tokens in GPT. So it's not an O token, it's a space O token. The space is part of the O, and together they are token 8840. That's space O.

pasted_image

The Tiktokenizer web app showing the phrase 'Here is a tag line for an ice cream shop: Oh yeah' tokenized. The token for ' Oh' is highlighted, and its corresponding number, 8840, is shown below.

[120:21] Andrej Karpathy: So what's happening here is that when I just have it like this and I let it complete the next token, it can sample the space O token. But instead, if I have this and I add my space, then what I'm doing here when I encode this string is I have basically, here's a tagline for an ice cream, uh, shop, and this space at the very end becomes a token 220.

pasted_image

The Tiktokenizer web app showing the phrase 'Here is a tag line for an ice cream shop: ' with a trailing space. The resulting tokens are displayed below, with the final token, 220, corresponding to the space.

[120:44] Andrej Karpathy: And so we've added token 220, and this token otherwise would be part of the tagline because if there actually is a tagline here, so space O is a token. And so this is throwing 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 distribution and now arbitrary bad things happen. And it's just a very rare example for it to see something like that. And, uh, that's why we get the warning.

[121:27] Andrej Karpathy: So the fundamental issue here is, of course, that, um, the LLM is on top of these tokens, and these tokens are text chunks, they're not characters in the way you and I would think of them. They are, these are the atoms of what the LLM is seeing, and there's a bunch of weird stuff that comes out of it. Let's go back to our, uh, default cell style. I bet you that the model has never in its training set seen default cell sty without le in there.

pasted_image

The Tiktokenizer web app showing the text '.DefaultCellSty' tokenized into four separate tokens: [13678, 3683, 626, 88]. [121:54] Andrej Karpathy: It's always seen this as a single group because, uh, this is some kind of a function in, um, I'm guess, I don't actually know what this is part of, it's some kind of API. But I bet you that it's never seen this combination of tokens, uh, in its training data because, or I think it would be extremely rare.

[122:12] Andrej Karpathy: So I took this and I copy-pasted it here, and I had, I tried to complete from it, and it immediately gave me a big error. And it said, the model predicted a completion that begins with a stop sequence, resulting in no output. Consider adjusting your prompt or stop sequences. So what happens here when I click submit is that immediately the model emitted an, sort of like end of text token, I think, or something like that. It basically predicted the stop sequence immediately, so it had no completion. And so this is where I'm getting a warning again because we're off the data distribution and the model is just, uh, predicting, it's totally arbitrary things. It's just really confused, basically. This is, this is giving it brain damage. It's never seen this before. It's shocked and it's predicting end of text or something.

pasted_image

The OpenAI Playground with the prompt '.DefaultCellSty' and an error message below: 'The model predicted a completion that begins with a stop sequence, resulting in no output.' [122:55] Andrej Karpathy: I tried it again here, and it, in this case, it completed it, but then for some reason, this request may violate our usage policies. This was flagged. Um, basically something just like goes wrong, and this is like jank. You can just feel the jank because the model is like extremely unhappy with just this, and it doesn't know how to complete it because it's never occurred in the training set. In the training set, it always appears like this and becomes a single token.

pasted_image

The OpenAI Playground showing a completion for the prompt '.DefaultCellSty' along with a warning: 'This request may violate our usage policies. The request was flagged because it may violate our usage policies.'

[123:20] Andrej Karpathy: So these kinds of issues where tokens are either you sort of like complete the first character of the next token, or you are sort of, you have long tokens that you then have just some of the characters of, all of these are kind of like issues with partial tokens, is how I would describe it. And if you actually dig into the tiktoken repository, you go to the Rust code and search for unstable, and you'll see, um, encode unstable native, unstable tokens, and a lot of like special case handling. None of this stuff about unstable tokens is documented anywhere, but there's a ton of code dealing with unstable tokens. And unstable tokens is exactly kind of like what I'm describing here.

[124:02] Andrej Karpathy: What you would like out of a completion API is something a lot more fancy. Like if we're putting in default cell sty, 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 actually trying to append, we're trying to consider lots of tokens, um, that if we were, or I guess like, we're trying to search over characters that if we re-tokenized would be of high probability, if that makes sense. Um, so that we can actually add a single individual character, uh, instead of just like adding the next full token that comes after this partial token list. So this is very tricky to describe, and I invite you to maybe like look through this. It ends up being an extremely gnarly and hairy kind of topic. It, and it comes from tokenization fundamentally. So, um, maybe I can even spend an entire video talking about unstable tokens sometime in the future. [124:53] Andrej Karpathy: Okay, and I'm really saving the best for last. My favorite one by far is this SolidGoldMagikarp.

[125:00] Andrej Karpathy: It's just, okay, so this comes from this blog post, uh, SolidGoldMagikarp. And, uh, this is, um, internet famous now for those of us in LLMs. And basically, I, I would invite you to, uh, read this blog post in full. But basically what this person was doing is this person went to the, um, token embedding table and clustered the tokens based on their embedding representation. And this person noticed that there's a cluster of tokens that look really strange. So there's a cluster here, petertodd, StreamerBot, SolidGoldMagikarp, signupmessage, like really weird tokens in, uh, basically in this embedding cluster.

pasted_image

A screenshot from the LessWrong blog post showing a cluster of unusual tokens, including 'attRot', 'StreamerBot', 'SolidGoldMagikarp', and 'signupmessage'. [125:41] Andrej Karpathy: And so where are these tokens and where do they even come from? Like what is SolidGoldMagikarp? It makes no sense. And then they found a bunch of these tokens. And then they noticed that actually the plot thickens here because if you ask the model about these tokens, like you ask it, uh, some very benign question like, please can you repeat back to me the string SolidGoldMagikarp? Uh, then you get a variety of basically totally broken LLM behavior. So either you get evasion, so, I'm sorry, I can't hear you, or you get a bunch of hallucinations as a response. Um, you can even get back like insults. So you ask it, uh, about StreamerBot and it, uh, tells the, and the model actually just calls you names. Uh, or it kind of comes up with like weird humor. But you're actually breaking the model by asking about these very simple strings like attRot and SolidGoldMagikarp.

pasted_image

A table from the LessWrong blog post categorizing the LLM's bizarre responses to weird tokens, with categories like 'evasion', 'hallucinatory completions', 'inter-referential hallucinations', and 'insults'.

[126:32] Andrej Karpathy: So like, what the hell is happening? And there's a variety of here documented behaviors. Uh, there's a bunch of tokens, not just SolidGoldMagikarp that have that kind of behavior. And so basically there's a bunch of like 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, uh, really strange behaviors, including sort of ones that violate typical safety guidelines, uh, and the alignment of the model, like it's swearing back at you. So what is happening here and how can this possibly be true?

[127:03] Andrej Karpathy: Well, this again comes down to tokenization. So what's happening here is that SolidGoldMagikarp, if you actually dig into it, is a Reddit user. So there's a u/SolidGoldMagikarp. And probably what happened here, even though I, I don't know that it has been like really definitively explored, but what is thought to have happened is that the tokenization dataset was very different from the training dataset for the actual language model. So in the tokenization dataset, there was a ton of Reddit data potentially, where the user SolidGoldMagikarp was mentioned in the text. Because SolidGoldMagikarp was a very common, um, sort of, uh, person who was posting a lot, uh, this would be a string that occurs many times in a tokenization dataset. Because it occurs many times in the tokenization dataset, these tokens would end up getting merged into a single individual token for that single Reddit user, SolidGoldMagikarp. So they would have a dedicated token in the vocabulary of, what is this, 50,000 tokens in GPT-2, that is devoted to that Reddit user.

[128:04] Andrej Karpathy: And then what happens is the tokenization dataset has those strings, but then later when you train the model, the language model itself, um, this data from Reddit was not present. And so therefore, in the entire training set for the language model, SolidGoldMagikarp never occurs. That token never appears in the training set for the actual language model later. So this 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. It's completely untrained. It's kind of like unallocated memory in a typical binary program written in C or something like that. So it's unallocated memory. And then at test time, if you evoke 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 is completely undefined, never before seen in the training behavior. And so any of these kind of like weird tokens would evoke this behavior because fundamentally the model is, um, is, uh, out of sample, out of distribution.

Note

Token Efficiency: YAML vs. JSON

[129:16] Andrej Karpathy: Okay, and the very last thing I wanted to just briefly mention and point out, although I think a lot of people are quite aware of this, is that different kinds of formats and different representations and different languages and so on might be more or less efficient with GPT tokenizers, uh, or any tokenizer for any other LLM for that matter. So for example, JSON is actually really dense in tokens, and YAML is a lot more efficient in tokens.

pasted_image

The Tiktokenizer web app showing a JSON object on the left and its tokenized representation on the right, with a total token count of 214.

[129:38] Andrej Karpathy: Um, so for example, this, our, these are the same in JSON and in YAML. The JSON is 116 and the YAML is 99. So quite a bit of an improvement. And so in the token economy where you are paying, uh, per token in many ways, and you are paying in the context length and you're paying in, um, dollar amount for, uh, the cost of processing all this kind of structured data when you have to, uh, so prefer to use YAML over JSONs. And in general, kind of like the tokenization density is something that you have to, uh, sort of 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 and so on.

pasted_image

The Tiktokenizer web app showing a YAML object on the left and its tokenized representation on the right, with a total token count of 99.

Note

Final Recommendations

[130:20] Andrej Karpathy: Okay, so that concludes my fairly long video on tokenization. I know it's dry, I know it's annoying, I know it's irritating. I personally really dislike this stage. But what I do have to say at this point is don't brush it off. There's a lot of footguns, sharp edges here, security issues, uh, AI safety issues, as we saw with plugging in unallocated memory into, uh, language models. So, um, it's worth understanding this stage. Um, that said, I will say that eternal glory goes to anyone who can get rid of it. Uh, I showed you one possible paper that tried to, uh, do that, and I think, I hope a lot more can follow over time.

Note

Write Text Version

Note

Previously we've created an enriched transcript version of a "Andrej Karpathy's Let's build the GPT Tokenizer" video. Now we will create a natural text version of the transcript text while keeping all the artifacts such as images, hyperlinks, code snippets, note messages, code messages etc..

Prompt

Your task is to create a text version of the provided transcript.

We will start by creating the structure for our text. Below is an example:

<example_plan> Format: Tutorial Blog Post Title: "Building a Video Transcription Pipeline with AI"

Introduction

  • Brief overview of the video transcription problem
  • What we'll cover in this tutorial

Setting Up the Environment

  • Required dependencies and installations
  • Initial configuration steps
  • pasted_image

Core Transcription Process

Single Track Audio Processing

  • How the transcribe_singletrack function works
  • Code example and parameters
  • pasted_image

Multi-track Audio Handling

  • Dealing with multiple speakers
  • Zoom meeting transcription specifics

Image Processing Pipeline

Frame Selection

  • Best frame selection algorithm
  • Bounding box detection
  • pasted_image

Image Filtering

  • Quality assessment criteria
  • Duplicate detection

Post-processing Steps

Fixing Transcription Errors

  • Common mistranscription patterns
  • Context-aware corrections

Adding Hyperlinks

  • GitHub repository linking
  • External resource linking

Conclusion

  • Summary of the complete pipeline
  • Next steps and improvements </example_plan>

Your plan should follow a similar structure to the example above, with:

  • Clear format selection (tutorial, blog post, documentation, etc.)
  • Hierarchical outline with ## and ### headers
  • Brief descriptions of what each section will cover
  • Indication of the images that will be used, so that we don't miss any

While writing the text step by step make sure to follow the rules below:

  • The text version should be a complete description of everything discussed in the transcription. It should be largely chronological.
  • Do not overuse headers, speaker names, or sentences like "Speaker A said this", "Speaker B told that", etc...
  • Make sure to include all the key concepts, explanations, technical details. Use ALL the code snippets or examples, hyperlinks, URLs, image markdown references.

I would like write this as a technical tutorial from Andrej Karpathy's POV as if he is speaking to the reader. Now start with the plan.

Assistant: 1,627

Looking at this comprehensive transcript about tokenization, I'll create a structured plan for the text version:

Format: Technical Tutorial/Deep Dive Title: "Let's Build the GPT Tokenizer: A Complete Guide to Tokenization in LLMs"

Introduction: Why Tokenization Matters

  • Opening with why tokenization is the least favorite but crucial part
  • The fundamental problem: feeding text into transformers
  • Brief preview of all the weird LLM behaviors caused by tokenization
  • Tiktokenizer interface showing tokenization examples

From Characters to Tokens: The Basics

Simple Character-Level Tokenization

  • Review of the naive approach from "Let's build GPT from scratch"
  • Code example of character tokenization with Shakespeare dataset
  • The embedding table concept
  • Why character-level isn't sufficient

The Unicode and UTF-8 Foundation

  • What are Unicode code points
  • UTF-8 encoding: from text to bytes
  • Comparison of UTF-8, UTF-16, UTF-32
  • UTF-8 encoding table from Wikipedia
  • Why we prefer UTF-8 for tokenization

The Byte Pair Encoding (BPE) Algorithm

Understanding BPE Fundamentals

  • The compression concept
  • Step-by-step BPE example
  • Implementation of get_stats and merge functions
  • Training a tokenizer from scratch

Building the Core Functions

  • Training function implementation
  • Encoding: from text to tokens
  • Decoding: from tokens back to text
  • Handling edge cases and UTF-8 decoding errors

GPT-2 and GPT-4 Tokenizers

Regex-Based Pre-tokenization

  • The GPT-2 regex pattern breakdown
  • Why OpenAI prevents certain merges
  • GPT-2 tokenization of Python code
  • Case sensitivity issues and improvements in GPT-4

The tiktoken Library

  • Using OpenAI's official tokenization library
  • Differences between GPT-2 and GPT-4 patterns
  • Vocabulary size evolution: 50k to 100k tokens

SentencePiece and Alternative Approaches

How SentencePiece Differs

  • Code points vs bytes approach
  • Byte fallback mechanism
  • Tokenizer architecture diagram
  • Configuration for Llama 2 tokenizer

Training Configuration

  • Key parameters and options
  • The add_dummy_prefix quirk
  • Llama 2 protobuf configuration

Special Tokens and Extensions

Understanding Special Tokens

  • The <|endoftext|> token and document delimitation
  • Chat model special tokens
  • ChatGPT special tokens in Tiktokenizer
  • Adding custom special tokens

Vocabulary Size Considerations

  • Impact on model architecture
  • Trade-offs in token vocabulary size
  • Extending pre-trained models with new tokens

Advanced Topics

Multi-modal Tokenization

  • Images as tokens (VQGAN example)
  • VQGAN architecture
  • Sora's visual patches
  • Sora technical report

Gist Tokens and Prompt Compression

  • Compressing long prompts into special tokens
  • Gist tokens paper diagram

Common Tokenization Issues and Pitfalls

Why LLMs Struggle with Certain Tasks

  • Spelling and character-level tasks
  • ChatGPT failing at spelling .DefaultCellStyle
  • String reversal difficulties
  • Successful two-step string reversal

Language and Arithmetic Challenges

  • Non-English language inefficiency
  • Korean text tokenization comparison
  • Number tokenization chaos
  • Integer tokenization heatmap

The Infamous Edge Cases

  • Trailing whitespace warnings
  • OpenAI Playground trailing space warning
  • Partial tokens and unstable behavior
  • The SolidGoldMagikarp mystery
  • SolidGoldMagikarp cluster discovery

Token Efficiency

  • JSON vs YAML comparison
  • JSON tokenization
  • YAML tokenization

Building Your Own Tokenizer

The minbpe Exercise

  • Four-step progression to GPT-4 tokenizer
  • Basic BPE implementation
  • Adding regex preprocessing
  • Loading GPT-4 merges
  • Handling special tokens

Conclusion

  • Summary of tokenization's importance
  • The dream of tokenization-free models
  • Final recommendations and warnings

Now I'll begin writing the text version following this plan. Would you like me to proceed with writing the full tutorial?

Raw

Format: Technical Tutorial/Deep Dive
Title: "Let's Build the GPT Tokenizer: A Complete Guide to Tokenization in LLMs"

## Introduction: Why Tokenization Matters
- Opening with why tokenization is the least favorite but crucial part
- The fundamental problem: feeding text into transformers
- Brief preview of all the weird LLM behaviors caused by tokenization
- ![Tiktokenizer interface showing tokenization examples](attachment:image_8_cropped_1.jpg)

## From Characters to Tokens: The Basics
### Simple Character-Level Tokenization
- Review of the naive approach from "Let's build GPT from scratch"
- Code example of character tokenization with Shakespeare dataset
- The embedding table concept
- Why character-level isn't sufficient

### The Unicode and UTF-8 Foundation
- What are Unicode code points
- UTF-8 encoding: from text to bytes
- Comparison of UTF-8, UTF-16, UTF-32
- ![UTF-8 encoding table from Wikipedia](attachment:image_14_cropped_1.jpg)
- Why we prefer UTF-8 for tokenization

## The Byte Pair Encoding (BPE) Algorithm
### Understanding BPE Fundamentals
- The compression concept
- Step-by-step BPE example
- Implementation of get_stats and merge functions
- Training a tokenizer from scratch

### Building the Core Functions
- Training function implementation
- Encoding: from text to tokens
- Decoding: from tokens back to text
- Handling edge cases and UTF-8 decoding errors

## GPT-2 and GPT-4 Tokenizers
### Regex-Based Pre-tokenization
- The GPT-2 regex pattern breakdown
- Why OpenAI prevents certain merges
- ![GPT-2 tokenization of Python code](attachment:image_9_cropped_1.jpg)
- Case sensitivity issues and improvements in GPT-4

### The tiktoken Library
- Using OpenAI's official tokenization library
- Differences between GPT-2 and GPT-4 patterns
- Vocabulary size evolution: 50k to 100k tokens

## Building Your Own Tokenizer
### The minbpe Exercise
- Four-step progression to GPT-4 tokenizer
- Basic BPE implementation
- Adding regex preprocessing
- Loading GPT-4 merges
- Handling special tokens

## SentencePiece and Alternative Approaches
### How SentencePiece Differs
- Code points vs bytes approach
- Byte fallback mechanism
- ![Tokenizer architecture diagram](attachment:image_32_cropped_1.jpg)
- Configuration for Llama 2 tokenizer

### Training Configuration
- Key parameters and options
- The add_dummy_prefix quirk
- ![Llama 2 protobuf configuration](attachment:image_88_1.jpg)

## Special Tokens and Extensions
### Understanding Special Tokens
- The <|endoftext|> token and document delimitation
- Chat model special tokens
- ![ChatGPT special tokens in Tiktokenizer](attachment:image_70_cropped_1.jpg)
- Adding custom special tokens

### Vocabulary Size Considerations
- Impact on model architecture
- Trade-offs in token vocabulary size
- Extending pre-trained models with new tokens

## Advanced Topics
### Multi-modal Tokenization
- Images as tokens (VQGAN example)
- ![VQGAN architecture](attachment:image_95_cropped_1.jpg)
- Sora's visual patches
- ![Sora technical report](attachment:image_96_cropped_1.jpg)

### Gist Tokens and Prompt Compression
- Compressing long prompts into special tokens
- ![Gist tokens paper diagram](attachment:image_94_cropped_1.jpg)

## Common Tokenization Issues and Pitfalls
### Why LLMs Struggle with Certain Tasks
- Spelling and character-level tasks
- ![ChatGPT failing at spelling .DefaultCellStyle](attachment:image_98_cropped_1.jpg)
- String reversal difficulties
- ![Successful two-step string reversal](attachment:image_99_1.jpg)

### Language and Arithmetic Challenges
- Non-English language inefficiency
- ![Korean text tokenization comparison](attachment:image_100_cropped_1.jpg)
- Number tokenization chaos
- ![Integer tokenization heatmap](attachment:image_102_cropped_1.jpg)

### The Infamous Edge Cases
- Trailing whitespace warnings
- ![OpenAI Playground trailing space warning](attachment:image_104_cropped_1.jpg)
- Partial tokens and unstable behavior
- The SolidGoldMagikarp mystery
- ![SolidGoldMagikarp cluster discovery](attachment:image_113_cropped_1.jpg)

### Token Efficiency
- JSON vs YAML comparison
- ![JSON tokenization](attachment:image_116_cropped_1.jpg)
- ![YAML tokenization](attachment:image_117_1.jpg)

## Conclusion
- Summary of tokenization's importance
- The dream of tokenization-free models
- Final recommendations and warnings

Prompt

Ok let's write the ## Introduction: Why Tokenization Matters section:

  • Don't forget to include all the artifacts (hyperlinks, code snippets, note messages, quotes, etc..) and images from the enriched transcript that should go into this section.
  • Don't forget to cite which section from the enriched transcript you've used during writing the text so that we can always cross check. You can include your citations at the end.
  • Don't forget to include code and note messages that is interleaved throughout the enriched transcript, follow a similar interleaved order keeping in mind that this is ought to be an interactive tutorial where users read text and run code.

Assistant: 2,128

Let's Build the GPT Tokenizer: A Complete Guide to Tokenization in LLMs

Introduction: Why Tokenization Matters

Hi everyone. So in this tutorial, I'd like us to cover the process of tokenization in large language models. Now, I have a sad face here, and that's because 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 are a lot of hidden footguns to be aware of. And a lot of oddness with large language models typically traces back to tokenization.

So what is tokenization? In my previous video, "Let's build GPT from scratch," we actually already did tokenization, but we did a very naive, simple version of it. When you go to the Google Colab for that video, you'll see that we loaded our training set - the Shakespeare dataset. This dataset is just a large string in Python, just text. And the question is: how do we plug text into large language models?

In that simple example, we created a vocabulary of 65 possible characters that we saw occur in this string. These were the possible characters, and we created a lookup table for converting from every possible character (a little string piece) into a token (an integer). So we tokenized the string "hi there" and received a sequence of tokens. We took the first 1,000 characters of our dataset and encoded it into tokens. Because this was character level, we received 1,000 tokens in a sequence.

Let me show you exactly what we did in that previous video with the character-level approach:

import torch
text = 'This is some text dataset hello, and hi some words!'
# get the unique characters that occur in this text
chars = sorted(list(set(text)))
vocab_size = len(chars)
print(''.join(chars))
print(vocab_size)
# create a mapping from characters to integers
stoi = { ch:i for i,ch in enumerate(chars) }
itos = { i:ch for i,ch in enumerate(chars) }
encode = lambda s: [stoi[c] for c in s] # encoder: take a string, output a list of integers
decode = lambda l: ''.join([itos[i] for i in l]) # decoder: take a list of integers, output a string

print(encode("hii there"))
print(decode(encode("hii there")))
# let's now encode the entire text dataset and store it into a torch.Tensor
data = torch.tensor(encode(text), dtype=torch.long)
print(data.shape, data.dtype)
print(data[:1000]) # the first 1000 characters we'll look like this

But here's the thing - tokenization is at the heart of a lot of weirdness in large language models, and I want to make sure we've motivated it sufficiently for why we are doing all this and why this is so gross. Let me give you a brief taste of some of the complexities that come from tokenization:

Tokenization Issues in LLMs - Why you should care:

• Why can't LLM spell words? Tokenization.

• Why can't LLM do string processing tasks like reversing a string? Tokenization.

• Why is LLM bad at non-English languages? Tokenization.

• Why is LLM bad at simple arithmetic? Tokenization.

• Why did GPT-2 have more than necessary trouble coding in Python? Tokenization.

• Why did my LLM abruptly halt when it sees the string "<|endoftext|>"? Tokenization.

• Why should I prefer YAML over JSON with LLMs? Tokenization.

• What is the root of suffering? Tokenization.

I'll loop back around to these at the end of the video, but for now, let me skip over it a little bit and show you the tiktokenizer web app. I have it loaded here, and what I like about this web app is that tokenization is running live in your browser in JavaScript. So you can just type stuff - "hello world" - and the whole string re-tokenizes.

A web application called 'Tiktokenizer'. The left pane is a text editor, and the right pane shows the tokenized output. The text includes examples of English, arithmetic, Korean, and Python code.

What we see on the left is the string that you put in. On the right, we're currently using the GPT-2 tokenizer. We see that this string that I pasted here is currently tokenizing into 300 tokens. And here they are shown explicitly in different colors for every single token. For example, this word "Tokenization" became two tokens - token 30,642 and 1,634. The token " is" is token 318. Be careful, on the bottom you can show whitespace, and keep in mind that there are spaces and newline characters in here, but you can hide them for clarity. The token " at" is token 379. The token " the" is 262, etc. Notice here that the space is part of that token chunk.

Now here I put in some arithmetic. So we see that the token 127 plus and then token 6, space 6, followed by 77. What's happening here is that 127 is feeding in as a single token into the large language model, but the number 677 will actually feed in as two separate tokens. The large language model has to sort of take account of that and process it correctly in its network. And see here, 804 will be broken up into two tokens. It's all completely arbitrary. 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 comes out of the tokenizer.

The naive character-level tokenization we did before isn't sufficient for real LLMs. In practice, state-of-the-art language models use much more complicated schemes for constructing these token vocabularies. We're not dealing on a character level; we're dealing on a chunk level. And the way these character chunks are constructed is using algorithms such as the byte-pair encoding algorithm, which we're going to go into in detail and cover in this video.

Citations:

  • Introduction content from enriched transcript sections [00:00] to [01:18]
  • Tokenization issues list from [04:50] to [05:50]
  • Tiktokenizer demonstration from [05:50] to [08:15]
  • Examples of tokenization complexity from [07:16] to [08:15]

Prompt

Any reason why you didn't add the code cells starting right after timestamp [01:18] # get the unique characters that occur in this text chars = sorted(list(set(text)))... ? Do you plan to include it in a different section?

Prompt

Ok I've updated your previous response accordingly, and added code snippets which can be run by users interactively. Anthing else is missing from the cited parts or is it safe to proceed?

Assistant: 355

Looking at the Introduction section I wrote, I believe I've captured the main elements from the cited transcript sections, but let me check if there are any missing pieces:

What I included:

  • The "sad face" opening and why tokenization is least favorite but necessary
  • Reference to the previous "Let's build GPT from scratch" video
  • The Shakespeare dataset character-level example with working code
  • The comprehensive list of LLM weirdness caused by tokenization
  • The tiktokenizer web app demonstration with the image
  • Examples of arithmetic tokenization issues
  • The transition from character-level to chunk-level tokenization

Potentially missing elements:

  1. The embedding table concept - I mentioned lookup tables but didn't explain how tokens feed into transformers via embedding tables
  2. The specific "hi there" tokenization example that was mentioned
  3. The vocabulary size (65 characters) and the specific token examples from the original

Would you like me to add these missing details, or are you satisfied with the current coverage and ready to proceed to the next section? The embedding table concept in particular might be important since it bridges tokenization to the actual model architecture.

Prompt

LGTM.

Note

Let's Build the GPT Tokenizer: A Complete Guide to Tokenization in LLMs

Prompt

Ok let's write the ## From Characters to Tokens: The Basics -> ### Simple Character-Level Tokenization section:

  • Don't forget to include all the artifacts (hyperlinks, code snippets, note messages, quotes, etc..) and images from the enriched transcript that should go into this section.
  • Don't forget to cite which section from the enriched transcript you've used during writing the text so that we can always cross check. You can include your citations at the end.
  • Don't forget to include code and note messages that is interleaved throughout the enriched transcript, follow a similar interleaved order keeping in mind that this is ought to be an interactive tutorial where users read text and run code.

Prompt

Ok let's write the ## From Characters to Tokens: The Basics -> ### The Unicode and UTF-8 Foundation section:

  • Don't forget to include all the artifacts (hyperlinks, code snippets, note messages, quotes, etc..) and images from the enriched transcript that should go into this section.
  • Don't forget to cite which section from the enriched transcript you've used during writing the text so that we can always cross check. You can include your citations at the end.
  • Don't forget to include code and note messages that is interleaved throughout the enriched transcript, follow a similar interleaved order keeping in mind that this is ought to be an interactive tutorial where users read text and run code.

Prompt

Ok let's write the ## The Byte Pair Encoding (BPE) Algorithm -> ### Understanding BPE Fundamentals section:

  • Don't forget to include all the artifacts (hyperlinks, code snippets, note messages, quotes, etc..) and images from the enriched transcript that should go into this section.
  • Don't forget to cite which section from the enriched transcript you've used during writing the text so that we can always cross check. You can include your citations at the end.
  • Don't forget to include code and note messages that is interleaved throughout the enriched transcript, follow a similar interleaved order keeping in mind that this is ought to be an interactive tutorial where users read text and run code.

Prompt

Looks like we couldn't complete all the required parts of ### Understanding BPE Fundamentals and were cut off somewhere here:

- Implementation of get_stats and merge functions
- Training a tokenizer from scratch

Should we continue from [48:22] and include the missing subsections shown above?

Prompt

Let's complete this first, write missing parts until our next major section: ### Building the Core Functions

Prompt

Ok let's write the ## The Byte Pair Encoding (BPE) Algorithm -> ### Building the Core Functions section:

  • Don't forget to include all the artifacts (hyperlinks, code snippets, note messages, quotes, etc..) and images from the enriched transcript that should go into this section.
  • Don't forget to cite which section from the enriched transcript you've used during writing the text so that we can always cross check. You can include your citations at the end.
  • Don't forget to include code and note messages that is interleaved throughout the enriched transcript, follow a similar interleaved order keeping in mind that this is ought to be an interactive tutorial where users read text and run code.

Prompt

Please continue from [42:48] and write:

  • Decoding: from tokens back to text
  • Encoding: from text to tokens
  • Handling edge cases and UTF-8 decoding errors

Prompt

Ok let's write the ## GPT-2 and GPT-4 Tokenizers -> ### Regex-Based Pre-tokenization section:

  • Don't forget to include all the artifacts (hyperlinks, code snippets, note messages, quotes, etc..) and images from the enriched transcript that should go into this section.
  • Don't forget to cite which section from the enriched transcript you've used during writing the text so that we can always cross check. You can include your citations at the end.
  • Don't forget to include code and note messages that is interleaved throughout the enriched transcript, follow a similar interleaved order keeping in mind that this is ought to be an interactive tutorial where users read text and run code.

Prompt

Ok let's write the ## GPT-2 and GPT-4 Tokenizers -> ### The tiktoken Library section:

  • Don't forget to include all the artifacts (hyperlinks, code snippets, note messages, quotes, etc..) and images from the enriched transcript that should go into this section.
  • Don't forget to cite which section from the enriched transcript you've used during writing the text so that we can always cross check. You can include your citations at the end.
  • Don't forget to include code and note messages that is interleaved throughout the enriched transcript, follow a similar interleaved order keeping in mind that this is ought to be an interactive tutorial where users read text and run code.

Prompt

Ok let's write the ## Building Your Own Tokenizer section:

  • Don't forget to include all the artifacts (hyperlinks, code snippets, note messages, quotes, etc..) and images from the enriched transcript that should go into this section.
  • Don't forget to cite which section from the enriched transcript you've used during writing the text so that we can always cross check. You can include your citations at the end.
  • Don't forget to include code and note messages that is interleaved throughout the enriched transcript, follow a similar interleaved order keeping in mind that this is ought to be an interactive tutorial where users read text and run code

Prompt

Ok let's write the ## SentencePiece and Alternative Approaches -> ### How SentencePiece Differs section:

  • Don't forget to include all the artifacts (hyperlinks, code snippets, note messages, quotes, etc..) and images from the enriched transcript that should go into this section.
  • Don't forget to cite which section from the enriched transcript you've used during writing the text so that we can always cross check. You can include your citations at the end.
  • Don't forget to include code and note messages that is interleaved throughout the enriched transcript, follow a similar interleaved order keeping in mind that this is ought to be an interactive tutorial where users read text and run code.

Prompt

Ok let's write the ## SentencePiece and Alternative Approaches -> ### Training Configuration section:

  • Don't forget to include all the artifacts (hyperlinks, code snippets, note messages, quotes, etc..) and images from the enriched transcript that should go into this section.
  • Don't forget to cite which section from the enriched transcript you've used during writing the text so that we can always cross check. You can include your citations at the end.
  • Don't forget to include code and note messages that is interleaved throughout the enriched transcript, follow a similar interleaved order keeping in mind that this is ought to be an interactive tutorial where users read text and run code.

Prompt

Ok let's write the ## Special Tokens and Extensions -> ### Understanding Special Tokens section:

  • Don't forget to include all the artifacts (hyperlinks, code snippets, note messages, quotes, etc..) and images from the enriched transcript that should go into this section.
  • Don't forget to cite which section from the enriched transcript you've used during writing the text so that we can always cross check. You can include your citations at the end.
  • Don't forget to include code and note messages that is interleaved throughout the enriched transcript, follow a similar interleaved order keeping in mind that this is ought to be an interactive tutorial where users read text and run code.

Prompt

Ok let's write the ## Special Tokens and Extensions -> ### Vocabulary Size Considerations section:

  • Don't forget to include all the artifacts (hyperlinks, code snippets, note messages, quotes, etc..) and images from the enriched transcript that should go into this section.
  • Don't forget to cite which section from the enriched transcript you've used during writing the text so that we can always cross check. You can include your citations at the end.
  • Don't forget to include code and note messages that is interleaved throughout the enriched transcript, follow a similar interleaved order keeping in mind that this is ought to be an interactive tutorial where users read text and run code.

Prompt

Ok let's write the ## Advanced Topics -> ### Multi-modal Tokenization and ### Gist Tokens and Prompt Compression sections:

  • Don't forget to include all the artifacts (hyperlinks, code snippets, note messages, quotes, etc..) and images from the enriched transcript that should go into this section.
  • Don't forget to cite which section from the enriched transcript you've used during writing the text so that we can always cross check. You can include your citations at the end.
  • Don't forget to include code and note messages that is interleaved throughout the enriched transcript, follow a similar interleaved order keeping in mind that this is ought to be an interactive tutorial where users read text and run code.

Prompt

Ok let's write the ## Common Tokenization Issues and Pitfalls -> ### Why LLMs Struggle with Certain Tasks sections:

  • Don't forget to include all the artifacts (hyperlinks, code snippets, note messages, quotes, etc..) and images from the enriched transcript that should go into this section.
  • Don't forget to cite which section from the enriched transcript you've used during writing the text so that we can always cross check. You can include your citations at the end.
  • Don't forget to include code and note messages that is interleaved throughout the enriched transcript, follow a similar interleaved order keeping in mind that this is ought to be an interactive tutorial where users read text and run code.

Prompt

Ok let's write the ## Common Tokenization Issues and Pitfalls -> ### The Infamous Edge Cases and ### Token Efficiency sections:

  • Don't forget to include all the artifacts (hyperlinks, code snippets, note messages, quotes, etc..) and images from the enriched transcript that should go into this section.
  • Don't forget to cite which section from the enriched transcript you've used during writing the text so that we can always cross check. You can include your citations at the end.
  • Don't forget to include code and note messages that is interleaved throughout the enriched transcript, follow a similar interleaved order keeping in mind that this is ought to be an interactive tutorial where users read text and run code.

Prompt

Let's write ## Conclusion and finish it.

Note

Conclusion and Final Recommendations

Okay, so that concludes my fairly long video on tokenization. I know it's dry, I know it's annoying, I know it's irritating. I personally really dislike this stage. But what I do have to say at this point is don't brush it off. There's a lot of footguns, sharp edges here, security issues, AI safety issues, as we saw with plugging in unallocated memory into language models. So, it's worth understanding this stage. That said, I will say that eternal glory goes to anyone who can get rid of it. I showed you one possible paper that tried to do that, and I think, I hope a lot more can follow over time.

So those are the basics of the byte-pair encoding algorithm and all its modern complexities. We've covered:

  • How tokenization evolved from simple character-level encoding to sophisticated BPE algorithms
  • The crucial role of UTF-8 encoding and why we work with bytes
  • How GPT-2 and GPT-4 use regex patterns to enforce merge boundaries
  • The differences between tiktoken and SentencePiece approaches
  • Special tokens and their role in structuring LLM inputs
  • Why tokenization causes so many weird LLM behaviors

Key Takeaways:

  1. Tokenization is foundational - It's the bridge between human text and neural networks. Every quirk in tokenization cascades into model behavior.

  2. Different tokenizers for different purposes - GPT models optimize for English and code, while models like Llama try to be more multilingual. Choose wisely based on your use case.

  3. Token efficiency matters - In production systems, you're paying per token. Understanding tokenization helps you optimize prompts and choose better data formats (YAML over JSON!).

  4. Edge cases are everywhere - From trailing spaces to partial tokens to unallocated embeddings, tokenization is full of sharp edges that can break your applications.

  5. The dream lives on - While we're stuck with tokenization for now, the dream of tokenization-free language models that work directly on bytes remains an active area of research.

I hope this deep dive has given you the knowledge to debug those weird LLM behaviors, optimize your token usage, and maybe even build your own tokenizer. Remember: when your LLM does something bizarre, it's probably tokenization.

Good luck, and may your tokens always be efficiently encoded! 🚀

Note

Prep Final Post

Note

  • Blog post: merge all the note messages
  • Solveit Dialogue: After merging all note message split at each fenced code snippet into separate runnable code msgs.

Code

duplicate_msgs_between("# Let's Build the GPT Tokenizer: A Complete Guide to Tokenization in LLMs", "## Conclusion and Final Recommendations", ['prompt'])

Note

Let's Build the GPT Tokenizer: A Complete Guide to Tokenization in LLMs

Introduction: Why Tokenization Matters

Hi everyone. So in this tutorial, I'd like us to cover the process of tokenization in large language models. Now, I have a sad face here, and that's because 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 are a lot of hidden footguns to be aware of. And a lot of oddness with large language models typically traces back to tokenization.

So what is tokenization? In my previous video, "Let's build GPT from scratch," we actually already did tokenization, but we did a very naive, simple version of it. When you go to the Google Colab for that video, you'll see that we loaded our training set - the Shakespeare dataset. This dataset is just a large string in Python, just text. And the question is: how do we plug text into large language models?

In that simple example, we created a vocabulary of 65 possible characters that we saw occur in this string. These were the possible characters, and we created a lookup table for converting from every possible character (a little string piece) into a token (an integer). So we tokenized the string "hi there" and received a sequence of tokens. We took the first 1,000 characters of our dataset and encoded it into tokens. Because this was character level, we received 1,000 tokens in a sequence.

Let me show you exactly what we did in that previous video with the character-level approach:

import torch
text = 'This is some text dataset hello, and hi some words!'
# get the unique characters that occur in this text
chars = sorted(list(set(text)))
vocab_size = len(chars)
print(''.join(chars))
print(vocab_size)
# create a mapping from characters to integers
stoi = { ch:i for i,ch in enumerate(chars) }
itos = { i:ch for i,ch in enumerate(chars) }
encode = lambda s: [stoi[c] for c in s] # encoder: take a string, output a list of integers
decode = lambda l: ''.join([itos[i] for i in l]) # decoder: take a list of integers, output a string

print(encode("hii there"))
print(decode(encode("hii there")))
# let's now encode the entire text dataset and store it into a torch.Tensor
data = torch.tensor(encode(text), dtype=torch.long)
print(data.shape, data.dtype)
print(data[:1000]) # the first 1000 characters we'll look like this

But here's the thing - tokenization is at the heart of a lot of weirdness in large language models, and I want to make sure we've motivated it sufficiently for why we are doing all this and why this is so gross. Let me give you a brief taste of some of the complexities that come from tokenization:

Tokenization Issues in LLMs - Why you should care:

• Why can't LLM spell words? Tokenization.

• Why can't LLM do string processing tasks like reversing a string? Tokenization.

• Why is LLM bad at non-English languages? Tokenization.

• Why is LLM bad at simple arithmetic? Tokenization.

• Why did GPT-2 have more than necessary trouble coding in Python? Tokenization.

• Why did my LLM abruptly halt when it sees the string "<|endoftext|>"? Tokenization.

• Why should I prefer YAML over JSON with LLMs? Tokenization.

• What is the root of suffering? Tokenization.

I'll loop back around to these at the end of the video, but for now, let me skip over it a little bit and show you the tiktokenizer web app. I have it loaded here, and what I like about this web app is that tokenization is running live in your browser in JavaScript. So you can just type stuff - "hello world" - and the whole string re-tokenizes.

pasted_image

What we see on the left is the string that you put in. On the right, we're currently using the GPT-2 tokenizer. We see that this string that I pasted here is currently tokenizing into 300 tokens. And here they are shown explicitly in different colors for every single token. For example, this word "Tokenization" became two tokens - token 30,642 and 1,634. The token " is" is token 318. Be careful, on the bottom you can show whitespace, and keep in mind that there are spaces and newline characters in here, but you can hide them for clarity. The token " at" is token 379. The token " the" is 262, etc. Notice here that the space is part of that token chunk.

Now here I put in some arithmetic. So we see that the token 127 plus and then token 6, space 6, followed by 77. What's happening here is that 127 is feeding in as a single token into the large language model, but the number 677 will actually feed in as two separate tokens. The large language model has to sort of take account of that and process it correctly in its network. And see here, 804 will be broken up into two tokens. It's all completely arbitrary. 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 comes out of the tokenizer.

The naive character-level tokenization we did before isn't sufficient for real LLMs. In practice, state-of-the-art language models use much more complicated schemes for constructing these token vocabularies. We're not dealing on a character level; we're dealing on a chunk level. And the way these character chunks are constructed is using algorithms such as the byte-pair encoding algorithm, which we're going to go into in detail and cover in this video.

From Characters to Tokens: The Basics

Simple Character-Level Tokenization

Now, later we saw that the way we plug these tokens into the language model is by using an embedding table. And so basically, if we have 65 possible tokens, then this embedding table is going to have 65 rows. And roughly speaking, we're taking the integer associated with every single token, we're using that as a lookup into this table, and we're plucking out the corresponding row. And this row is trainable parameters that we're going to train using backpropagation. And this is the vector that then feeds into the transformer, and that's how the transformer sort of perceives every single token.

So here we had a very naive tokenization process that was a character-level tokenizer. But in practice, state-of-the-art language models, people use a lot more complicated schemes, unfortunately, for constructing these token vocabularies. So we're not dealing on a character level, we're dealing on a chunk level. And the way these character chunks are constructed is using algorithms such as, for example, the byte-pair encoding algorithm, which we're going to go into in detail and cover in this video.

I'd like to briefly show you the paper that introduced byte-level encoding as a mechanism for tokenization in the context of large language models. And I would say that that's probably the GPT-2 paper. If you scroll down to the section "Input Representation," this is where they cover tokenization, the kind of properties that you'd like the tokenization to have. And they conclude here that they're going to have a tokenizer where you have a vocabulary of 50,257 possible tokens. And the context size is going to be 1,024 tokens. So in the attention layer of the transformer neural network, every single token is attending to the previous tokens in the sequence, and it's going to see up to 1,024 tokens. So tokens are this fundamental unit, the atom of large language models, if you will. And everything is in units of tokens, everything is about tokens. And tokenization is the process for translating strings or text into sequences of tokens and vice versa.

When you go into the Llama 2 paper as well, I can show you that when you search "token," you're going to get 63 hits. And that's because tokens are, again, pervasive. So here they mentioned that they trained on 2 trillion tokens of data and so on. So we're going to build our own tokenizer. Luckily, the byte-pair encoding algorithm is not that super complicated, and we can build it from scratch ourselves and we'll see exactly how this works.

Before we dive into code, I'd like to give you a brief taste of some of the complexities that come from the tokenization because I just want to make sure that we've motivated it sufficiently for why we are doing all this and why this is so gross. So, 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. A lot of the issues that may look like just issues with the neural architecture or the large language model itself are actually issues with the tokenization and fundamentally trace back to it.

If you've noticed any issues with large language models can't, you know, not able to do spelling tasks very easily, that's usually due to tokenization. Simple string processing can be difficult for the large language model to perform natively. Non-English languages can work much worse, and to a large extent, this is due to tokenization. Sometimes LLMs are bad at simple arithmetic, also can be traced to tokenization. GPT-2 specifically would have had quite a bit more issues with Python than future versions of it due to tokenization. There's a lot of other issues. Maybe you've seen weird warnings about a trailing whitespace. This is a tokenization issue. If you had asked GPT earlier about "SolidGoldMagikarp" and what it is, you would see the LLM go totally crazy and it would start going off about completely unrelated tangent topic. Maybe you've been told to use YAML over JSON with structured data. All that has to do with tokenization. So basically, tokenization is at the heart of many issues.

The Unicode and UTF-8 Foundation

Okay, so let's now start writing some code. So, remember what we want to do. We want to take strings and feed them into language models. For that, we need to somehow tokenize strings into some integers in some fixed vocabulary. And then we will use those integers to make a lookup into a lookup table of vectors and feed those vectors into the transformer as an input.

Now, the reason this gets a little bit tricky, of course, is that we don't just want to support the simple English alphabet. We want to support different kinds of languages. So this is "annyeonghaseyo" in Korean, which is hello. And we also want to support many kinds of special characters that we might find on the internet, for example, emoji. So, how do we feed this text into transformers?

text = "안녕하세요 👋 hello world 🤗"
print(text)

Well, what is this text anyway in Python? So if you go to the documentation of a string in Python, you can see that strings are immutable sequences of Unicode code points. Okay, what are Unicode code points? We can go to Wikipedia Unicode page. So Unicode code points are defined by the Unicode Consortium as part of the Unicode standard. And what this is really is that it's just a definition of roughly 150,000 characters right now. And roughly speaking, what they look like and what integers represent those characters. So this is 150,000 characters across 161 scripts as of right now. So if you scroll down here, you can see that the standard is very much alive. The latest standard 15.1 is September 2023.

Unicode, formally The Unicode Standard, is a text encoding standard maintained by the Unicode Consortium designed to support the use of text written in all of the world's major writing systems. Version 15.1 of the standard defines 149,813 characters and 161 scripts used in various ordinary, literary, academic, and technical contexts.

And basically, this is just a way to define lots of types of characters, like for example, all these characters across different scripts. So, the way we can access the Unicode code point given a single character is by using the ord function in Python. So for example, I can pass in ord of 'h', and I can see that for the single character 'h', the Unicode code point is 104. Okay? But this can be arbitrarily complicated. So we can take, for example, our emoji here, and we can see that the code point for this one is 128,000. Or we can take "an," and this is 50,000. Now, keep in mind, you can't plug in strings here because this doesn't have a single code point. It only takes a single Unicode code point character and tells you its integer.

# Get Unicode code point for English character
print(f"ord('h') = {ord('h')}")

# Get Unicode code point for emoji
print(f"ord('🤗') = {ord('🤗')}")

# Get Unicode code point for Korean character
print(f"ord('안') = {ord('안')}")

So in this way, we can look up all the characters of this specific string and their code points:

# Get Unicode code points for each character in the string
text = "안녕하세요 👋 hello world 🤗"
L([ord(x) for x in text])

Now, see here, we've already turned, the raw code points already have integers. So why can't we simply just use these integers and not have any tokenization at all? Why can't we just use this natively as is and just use the code point?

Well, one reason for that, of course, is that the vocabulary in that case would be quite long. So in this case, for Unicode, this is a vocabulary of 150,000 different code points. But more worryingly than that, I think, the Unicode standard is very much alive and it keeps changing. And so it's not kind of a stable representation necessarily that we may want to use directly. So for these reasons, we need something a bit better.

So to find something better, we turn to encodings. So if you go to the Wikipedia page here, we see that the Unicode Consortium defines three types of encodings: UTF-8, UTF-16, and UTF-32. These encodings are the way by which we can take Unicode text and translate it into binary data or byte strings. UTF-8 is by far the most common. So this is the UTF-8 page. Now, this Wikipedia page is actually quite long, but what's important for our purposes is that UTF-8 takes every single code point and it translates it to a byte string. And this byte string is between one to four bytes. So it's a variable-length encoding. So depending on the Unicode point, according to the schema, you're going to end up with between one to four bytes for each code point.

pasted_image

On top of that, there's UTF-8, UTF-16, and UTF-32. UTF-32 is nice because it is fixed length instead of variable length, but it has many other downsides as well. So the full kind of spectrum of pros and cons of all these different three encodings are beyond the scope of this video. I'd just like to point out that I enjoyed this blog post, and this blog post at the end of it also has a number of references that can be quite useful. One of them is "UTF-8 Everywhere Manifesto." And this manifesto describes the reason why UTF-8 is significantly preferred and a lot nicer than the other encodings and why it is used a lot more prominently on the internet. One of the major advantages that's just to give you a sense is that UTF-8 is the only one of these that is backward compatible to the much simpler ASCII encoding of text. But I'm not going to go into the full detail in this video.

Suffice it to say that we like the UTF-8 encoding. And let's try to take this string and see what we get if we encode it into UTF-8. The string class in Python actually has .encode, and you can give it the encoding, which is, let's say, UTF-8. Now, what we get out of this is not very nice because this is the bytes, this is a bytes object, and it's not very nice in the way that it's printed. So I personally like to take it through a list because then we actually get the raw bytes of this encoding. So this is the raw bytes that represent this string according to the UTF-8 encoding.

text = "안녕하세요 👋 hello world 🤗"

# UTF-8 encoding
utf8_bytes = list(text.encode('utf-8'))
print(f"UTF-8: {utf8_bytes}")

# UTF-16 encoding  
utf16_bytes = list(text.encode('utf-16'))
print(f"UTF-16: {utf16_bytes}")

# UTF-32 encoding  
utf32_bytes = list(text.encode('utf-32'))
print(f"UTF-32: {utf32_bytes}")

We can also look at UTF-16. We get a slightly different byte stream. And here we start to see one of the disadvantages of UTF-16. You see how we have zero, zero something, zero something, zero something. We're starting to get a sense that this is a bit of a wasteful encoding. And indeed, for simple ASCII characters or English characters here, we just have this structure of zero something, zero something, and it's not exactly nice. Same for UTF-32. When we expand this, we can start to get a sense of the wastefulness of this encoding for our purposes. You see a lot of zeros followed by something. And so, this is not desirable.

So, suffice it to say that we would like to stick with UTF-8 for our purposes. However, if we just use UTF-8 naively, these are byte streams. So that would imply a vocabulary length of only 256 possible tokens. But this vocabulary size is very, very small. What this is going to do if we just were to use it naively is that all of our text would be stretched out over very, very long sequences of bytes.

And so, what this does is that certainly the embedding table is going to be tiny, and the prediction at the top at the final layer is going to be very tiny, but our sequences are very long. And remember that we have pretty finite context length in the attention that we can support in a transformer for computational reasons. And so we only have that much context length, but now we have very, very long sequences, and this is just inefficient, and it's not going to allow us to attend to sufficiently long text before us for the purposes of the next token prediction task.

So we don't want to use the raw bytes of the UTF-8 encoding. We want to be able to support larger vocabulary size that we can tune as a hyperparameter, but we want to stick with the UTF-8 encoding of these strings. So what do we do?

The Byte Pair Encoding (BPE) Algorithm

Understanding BPE Fundamentals

Well, the answer, of course, is we turn to the Byte Pair Encoding algorithm, which will allow us to compress these byte sequences to a variable amount. So we'll get to that in a bit, but I just want to briefly speak to the fact that I would love nothing more than to be able to feed raw byte sequences into language models. In fact, there's a paper about how this could potentially be done from the summer last year.

Now, the problem is you have to go in and you have to modify the transformer architecture because, as I mentioned, you're going to have a problem where the attention will start to become extremely expensive because the sequences are so long. And so in this paper, they propose kind of a hierarchical structuring of the transformer that could allow you to just feed in raw bytes. And so at the end, they say, "Together, these results establish the viability of tokenization-free autoregressive sequence modeling at scale." So tokenization-free would indeed be amazing. We would just feed byte streams directly into our models. But unfortunately, I don't know that this has really been proven out yet by sufficiently many groups at sufficient scale. But something like this at one point would be amazing, and I hope someone comes up with it. But for now, we have to come back, and we can't feed this directly into language models, and we have to compress it using the Byte Pair Encoding algorithm. So let's see how that works.

So as I mentioned, the Byte Pair Encoding algorithm is not all that complicated, and the Wikipedia page is actually quite instructive as far as the basic idea goes. What we're doing is we have some kind of an input sequence. Like, for example, here we have only four elements in our vocabulary: a, b, c, and d. And we have a sequence of them. So instead of bytes, let's say we just have four, a vocab size of four.

This sequence is too long, and we'd like to compress it. So what we do is that we iteratively find the pair of tokens that occur the most frequently. And then once we've identified that pair, we replace that pair with just a single new token that we append to our vocabulary. So for example, here, the byte pair 'aa' occurs most often. So we mint a new token, let's call it capital Z, and we replace every single occurrence of 'aa' by Z. So now we have two Z's here.

Step 1: Initial sequence

aaabdaaabac

Most frequent pair: aa (occurs 2 times) Replace aa with Z:

Zabdaabac → ZabdZabac

So here, we took a sequence of 11 characters with vocabulary size four, and we've converted this to a sequence of only nine tokens, but now with a vocabulary of five, because we have a fifth vocabulary element that we just created, and it's Z, standing for concatenation of 'aa'. And we can again repeat this process. So we again look at the sequence and identify the pair of tokens that are most frequent. Let's say that that is now 'ab'. Well, we are going to replace 'ab' with a new token that we mint, called Y. So Y becomes 'ab', and then every single occurrence of 'ab' is now replaced with Y. So we end up with this.

Step 2: Continue compression

ZabdZabac

Most frequent pair: ab (occurs 2 times)
Replace ab with Y:

ZYdZYac

So now we only have 1, 2, 3, 4, 5, 6, 7 characters in our sequence, but we have not just four vocabulary elements, or five, but now we have six. And for the final round, we again look through the sequence, find that the phrase 'ZY' or the pair 'ZY' is most common, and replace it one more time with another character, let's say X. So X is 'ZY', and we replace all occurrences of 'ZY', and we get this following sequence.

Step 3: Final merge

ZYdZYac

Most frequent pair: ZY (occurs 2 times) Replace ZY with X:

XdXac

Final result: XdXac Final vocabulary: {a, b, c, d, Z=aa, Y=ab, X=ZY} Original length: 11 tokens → Compressed length: 5 tokens

So basically, after we've gone through this process, instead of having a sequence of 11 tokens with a vocabulary length of four, we now have a sequence of 1, 2, 3, 4, 5 tokens, but our vocabulary length now is seven. And so in this way, we can iteratively compress our sequence as we mint new tokens. So in the exact same way, we start off 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. And in this way, we're going to end up with a compressed training dataset and also an algorithm for taking any arbitrary sequence and encoding it using this vocabulary, and also decoding it back to strings. So let's now implement all that.

So here's what I did. I went to this blog post that I enjoyed, and I took the first paragraph, and I copy-pasted it here into text. So this is one very long line here.

Now, to get the tokens, as I mentioned, we just take our text and we encode it into UTF-8. The tokens here at this point will be our raw bytes, single stream of bytes. And just so that it's easier to work with, instead of just a bytes object, I'm going to convert all those bytes to integers and then create a list of it, just so it's easier for us to manipulate and work with in Python and visualize. And here I'm printing all of that. So this is the original paragraph, and its length is 533 code points. And then here are the bytes encoded in UTF-8, and we see that this has a length of 616 bytes at this point, or 616 tokens. And the reason this is more is because a lot of these simple ASCII characters or simple characters, they just become a single byte, but a lot of these Unicode, more complex characters become multiple bytes, up to four, and so we are expanding that size.

# Step 1: Get the sample text from Nathan Reed's blog post
text = """Unicode is a standard for encoding and representing text in computers. It was created to solve the problem of multiple incompatible character encodings that existed before it. In the early days of computing, different regions and manufacturers developed their own ways to encode text, leading to a fragmented landscape where text that looked fine on one system would appear as gibberish on another."""

print(f"Text: {text}")
print(f"Length in characters: {len(text)}")
# Step 2: Encode the text to UTF-8 bytes and convert to list of integers
tokens = list(text.encode("utf-8"))
print(f"UTF-8 encoded bytes: {tokens[:50]}...")  # Show first 50 bytes
print(f"Length in bytes: {len(tokens)}")

So now what we'd like to do as a first step of the algorithm is we'd like to iterate over here and find the pair of bytes that occur most frequently, because we're then going to merge it. So if you are working along on the notebook on the side, then I encourage you to basically click on the link, find this notebook, and try to write that function yourself. Otherwise, I'm going to come here and implement first the function that finds the most common pair.

Okay, so here's what I came up with. There are many different ways to implement this, but I'm calling the function get_stats. It expects a list of integers. I'm using a dictionary to keep track of basically the counts. And then this is a Pythonic way to iterate consecutive elements of this list, which we covered in the previous video. And then here, I'm just keeping track of, just incrementing by one, for all the pairs. So if I call this on all the tokens here, then the stats comes out here. So this is a dictionary. The keys are these tuples of consecutive elements, and this is the count.

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}
    Optionally allows to update an existing dictionary of counts
    """
    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

Let me also show you how zip(ids, ids[1:]) works for consecutive pairs:

# Step 3a: Understand how zip(ids, ids[1:]) works for consecutive pairs
sample_list = [1, 2, 3, 4, 5]
consecutive_pairs = list(zip(sample_list, sample_list[1:]))
print(f"Sample list: {sample_list}")
print(f"Consecutive pairs: {consecutive_pairs}")
print("This is the 'Pythonic way' Andrej mentions for iterating consecutive elements")

Now let's find the most common consecutive pair using get_stats:

# Step 3: Find the most common consecutive pair using get_stats
stats = get_stats(tokens)
print(f"Total number of unique pairs: {len(stats)}")

# Show top 10 most frequent pairs
top_pairs = sorted([(count, pair) for pair, count in stats.items()], reverse=True)[:10]
print("\nTop 10 most frequent pairs:")
for count, pair in top_pairs:
    print(f"  {pair}: {count} times")

So just to print it in a slightly better way, this is one way that I like to do that, where you iterate over all the items. The .items() called on dictionary returns pairs of key-value. And instead, I create a list here of value-key, because if it's a value-key list, then I can call sort() on it. And by default, Python will use the first element, which in this case will be value, to sort by if it's given tuples. And then reverse, so it's descending, and print that.

So basically, it looks like 101, 32 was the most commonly occurring consecutive pair, and it occurred 20 times. We can double check that that makes reasonable sense. So if I just search 101, 32, then you see that these are the 20 occurrences of that pair.

# Step 4: Get the most frequent pair using max() function
most_frequent_pair = max(stats, key=stats.get)
print(f"Most frequent pair: {most_frequent_pair}")
print(f"Occurs {stats[most_frequent_pair]} times")

# Convert bytes back to characters to see what this pair represents
char1 = chr(most_frequent_pair[0])
char2 = chr(most_frequent_pair[1])
print(f"This represents: '{char1}' + '{char2}'")

And if we'd like to take a look at what exactly that pair is, we can use chr, which is the opposite of ord in Python. So we give it a Unicode code point, so 101 and of 32, and we see that this is 'e' and 'space'. So basically, there's a lot of 'e space' here, meaning that a lot of these words seem to end with 'e'. So here's 'e space' as an example. So there's a lot of that going on here, and this is the most common pair.

Let's verify the most frequent pair by finding its occurrences in the text:

# Step 4a: Verify the most frequent pair by finding its occurrences in the text
pair_to_find = most_frequent_pair  # (101, 32) which is 'e' + ' '

# Find all positions where this pair occurs
occurrences = []
for i in range(len(tokens) - 1):
    if tokens[i] == pair_to_find[0] and tokens[i + 1] == pair_to_find[1]:
        occurrences.append(i)

print(f"Found {len(occurrences)} occurrences of pair {pair_to_find} ('e' + ' ') at positions:")
print(f"Positions: {occurrences}")

So now that we've identified the most common pair, we would like to iterate over the sequence. We're going to mint a new token with the ID of 256, right? Because these tokens currently go from 0 to 255. So when we create a new token, it will have an ID of 256. And we're going to iterate over this entire list, and every time we see 101, 32, we're going to swap that out for 256. So let's implement that now, and feel free to do that yourself as well.

# Step 5: Prepare to merge - create new token ID
# Current tokens are 0-255 (256 possible values), so new token will be 256
new_token_id = 256
print(f"Will replace pair {most_frequent_pair} with new token ID: {new_token_id}")
print(f"Ready to implement merge function...")

So first, I commented this just so we don't pollute the notebook too much. This is a nice way of in Python obtaining the highest ranking pair. So we're basically calling the max on this dictionary stats, and this will return the maximum key. And then the question is, how does it rank keys? So you can provide it with a function that ranks keys, and that function is just stats.get. stats.get would basically return the value. And so we're ranking by the value and getting the maximum key. So it's 101, 32, as we saw.

Perfect! Let's complete the ### Understanding BPE Fundamentals section by adding the merge function implementation and training loop.

Now that we've identified the most common pair, we would like to iterate over the sequence. We're going to mint a new token with the ID of 256, right? Because these tokens currently go from 0 to 255. So when we create a new token, it will have an ID of 256. And we're going to iterate over this entire list, and every time we see 101, 32, we're going to swap that out for 256. So let's implement that now.

# Step 6: Implement the merge function
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  # skip over the pair
        else:
            newids.append(ids[i])
            i += 1
    return newids

Let's test this with a simple example first:

# Test with simple example
test_ids = [5, 6, 6, 7, 9, 1]
result = merge(test_ids, (6, 7), 99)
print(f"Original: {test_ids}")
print(f"After merging (6, 7) -> 99: {result}")

Now let's apply the merge to our actual tokens:

# Step 7: Apply merge to our actual tokens
# Merge the most frequent pair (101, 32) with token ID 256
tokens2 = merge(tokens, most_frequent_pair, new_token_id)

print(f"Original length: {len(tokens)}")
print(f"After merge length: {len(tokens2)}")
print(f"Reduction: {len(tokens) - len(tokens2)} tokens")

# Verify the merge worked
print(f"\nOccurrences of new token {new_token_id}: {tokens2.count(new_token_id)}")
print(f"Occurrences of old pair in original: {sum(1 for i in range(len(tokens)-1) if (tokens[i], tokens[i+1]) == most_frequent_pair)}")

# Verify old pair is gone
old_pair_count = sum(1 for i in range(len(tokens2)-1) if (tokens2[i], tokens2[i+1]) == most_frequent_pair)
print(f"Occurrences of old pair in new tokens: {old_pair_count}")

Now let's iterate the BPE algorithm. We repeat: find most common pair, merge it, repeat...

# Step 8: Iterate the BPE algorithm
# Now we repeat: find most common pair, merge it, repeat...
# Let's do a few more iterations

current_tokens = tokens2
vocab_size = 257  # Started with 256, now have 257

print("BPE Training Progress:")
print(f"Step 0: {len(tokens)} tokens, vocab size: 256")
print(f"Step 1: {len(current_tokens)} tokens, vocab size: {vocab_size}")

# Do a few more iterations
for step in range(2, 6):  # Steps 2-5
    # Find most common pair
    stats = get_stats(current_tokens)
    if not stats:  # No more pairs to merge
        break
    
    most_frequent_pair = max(stats, key=stats.get)
    
    # Merge it
    current_tokens = merge(current_tokens, most_frequent_pair, vocab_size)
    
    print(f"Step {step}: {len(current_tokens)} tokens, vocab size: {vocab_size + 1}")
    print(f"  Merged pair: {most_frequent_pair} -> {vocab_size}")
    
    vocab_size += 1

print(f"\nFinal: {len(current_tokens)} tokens, vocab size: {vocab_size}")

Let's also track the merges we made to understand what our tokenizer learned:

# Track the merges we made
merges = {
    256: (101, 32),  # 'e' + ' '
    257: (100, 32),  # 'd' + ' '  
    258: (116, 101), # 't' + 'e'
    259: (115, 32),  # 's' + ' '
    260: (105, 110)  # 'i' + 'n'
}

for token_id, (byte1, byte2) in merges.items():
    char1, char2 = chr(byte1), chr(byte2)
    print(f"Token {token_id}: ({byte1}, {byte2}) -> '{char1}' + '{char2}' = '{char1}{char2}'")

This completes our understanding of BPE fundamentals! We can see how the algorithm iteratively finds the most frequent byte pairs and merges them into new tokens, gradually building up a vocabulary that can efficiently represent our text.

Building the Core Functions

Now that we understand the BPE algorithm conceptually, let's build the complete tokenizer with training, encoding, and decoding functions. Before we dive into the while loop, I wanted to add one more cell here where I went to the blog post, and instead of grabbing just the first paragraph or two, I took the entire blog post, and I stretched it out in a single line. And basically, just using longer text will allow us to have more representative statistics for the byte pairs, and we'll just get a more sensible result out of it, because it's longer text. So here we have the raw text, we encode it into bytes using the UTF-8 encoding. And then here, as before, we are just changing it into a list of integers in Python just so it's easier to work with instead of the raw bytes object.

tokens = list(text.encode("utf-8"))
print(f"UTF-8 encoded bytes: {tokens[:50]}...")  # Show first 50 bytes
print(f"Length in bytes: {len(tokens)}")

And then this is the code that I came up with to actually do the merging in loop. These two functions here are identical to what we had above. I only included them here just so that you have the point of reference here. So, these two are identical, and then this is the new code that I added. So the first thing we want to do is we want to decide on a final vocabulary size that we want our tokenizer to have. And as I mentioned, this is a hyperparameter, and you set it in some way depending on your best performance. So let's say for us, we're going to use 276 because that way we're going to be doing exactly 20 merges. And, 20 merges because we already have 256 tokens for the raw bytes. And to reach 276, we have to do 20 merges to add 20 new tokens.

# BPE training
vocab_size = 276  # hyperparameter: the desired final vocabulary size
num_merges = vocab_size - 256
tokens = list(text.encode("utf-8"))

for i in range(num_merges):
    # count up all the pairs
    stats = get_stats(tokens)
    # find the pair with the highest count
    pair = max(stats, key=stats.get)
    # mint a new token: assign it the next available id
    idx = 256 + i
    # replace all occurrences of pair in tokens with idx
    tokens = merge(tokens, pair, idx)
    # print progress
    print(f"merge {i+1}/{num_merges}: {pair} -> {idx} ({stats[pair]} occurrences)")

Here, this is one way in Python to just create a copy of the list. So I'm taking the tokens list, and by wrapping it in a list, Python will construct a new list of all the individual elements. So this is just a copy operation. Then here, I'm creating a merges dictionary. So this merges dictionary is going to maintain basically the child one, child two mapping to a new token. And so 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 like merge two of them at a time. And so it's not a tree, it's more like a forest, as we merge these elements.

So for 20 merges, we're going to find the most commonly occurring pair. We're going to mint a new token integer for it. So i here will start at zero, so we'll start with 256. We're going to print that we're merging it, and we're going to replace all the occurrences of that pair with the new, newly minted token. And we're going to record that this pair of integers merged into this new integer. So running this gives us the following output.

So we did 20 merges. And for example, the first merge was exactly as before, the 101, 32, tokens merging into a new token 256. Now, keep in mind that the individual tokens 101 and 32 can still occur in the sequence after merging. It's only when they occur exactly consecutively that that becomes 256 now. And in particular, the other thing to notice here is that the token 256, which is the newly minted token, is also eligible for merging. So here on the bottom, the 20th merge was a merge of 256 and 259 becoming 275. So every time we replace these tokens, they become eligible for merging in the next round of the iteration. So that's why we're building up a small sort of binary forest instead of a single individual tree.

One thing we can take a look at as well is we can take a look at the compression ratio that we've achieved. So in particular, we started off with this tokens list. So we started off with 24,000 bytes, and after merging 20 times, we now have only 19,000 tokens. And so therefore, the compression ratio is simply just dividing the two is roughly 1.27. So that's the amount of compression we were able to achieve of this text with only 20 merges. And of course, the more vocabulary elements you add, the greater the compression ratio here would be.

Finally, so that's kind of like the training of the tokenizer, if you will. Now, one point that I wanted to make is that, and maybe this is a diagram that can help, kind of illustrate, is that the tokenizer is a completely separate object from the large language model itself. So everything in this lecture, we're not really touching the LLM itself. We're just training the tokenizer. That is a completely separate preprocessing stage usually. So the tokenizer will have its own training set, just like a large language model has a potentially different training set. So the tokenizer has a training set of documents on which you're going to train the tokenizer. And then, we're performing the Byte Pair Encoding algorithm as we saw above.

pasted_image

This allows us to train the vocabulary of this tokenizer. So it has its own training set, it has a pre-processing stage that you would run a single time in the beginning. And the tokenizer is trained using byte-pair encoding algorithm. Once you have the tokenizer, once it's trained and you have the vocabulary and you have the merges, we can do both encoding and decoding. So these two arrows here. So the tokenizer is a translation layer between raw text, which is, as we saw, the sequence of Unicode code points. It can take raw text and turn it into a token sequence. And vice versa, it can take a token sequence and translate it back into raw text.

So now that we have trained the tokenizer and we have these merges, we are going to turn to how we can do the encoding and the decoding step. If you give me text, here are the tokens, and vice versa, if you give me tokens, here's the text. Once we have that, we can translate between these two realms. And then the language model is going to be trained as a step two afterwards. And typically in a sort of a state-of-the-art application, you might take all of your training data for the language model and you might run it through the tokenizer and sort of translate everything into a massive token sequence. And then you can throw away the raw text. You're just left with the tokens themselves. And those are stored on disk and that is what the large language model is actually reading when it's training on them. So that's one approach that you can take as a single massive pre-processing stage.

So, yeah, basically, I think the most important thing I want to get across is that this is a completely separate stage. It usually has its own entire training set. You may want to have those training sets be different between the tokenizer and the large language model. So for example, when you're training the tokenizer, as I mentioned, we don't just care about the performance of English text, we care about many different languages. And we also care about code or not code. So you may want to look into different kinds of mixtures of different kinds of languages and different amounts of code and things like that, because the amount of different language that you have in your tokenizer training set will determine how many merges of it there will be. And therefore that determines the density with which this type of data is sort of has in the token space.

And so, roughly speaking, intuitively, if you add some amount of data, let's 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 on which it can work on in in the token space. So hopefully that makes sense. So we're now going to turn to encoding and decoding now that we have trained a tokenizer. So we have our merges and now how do we do encoding and decoding?

Decoding: From Tokens Back to Text

Okay, so let's begin with decoding, which is this arrow over here. So given a token sequence, let's go through the tokenizer to get back a Python string object. So the raw text.

So this is the function that we'd like to implement. We're given the list of integers and we want to return a Python string. If you'd like, try to implement this function yourself. It's a fun exercise. Otherwise, I'm going to start pasting in my own solution.

So there are many different ways to do it. Here's one way. I will create a pre-processing variable that I will call vocab. And vocab is a mapping or a dictionary in Python from the token ID to the bytes object for that token. So we begin with the raw bytes for tokens from 0 to 255. And then we go in order of all the merges and we sort of populate this vocab list by doing an addition here. So this is the basically the bytes representation of the first child followed by the second one. And remember these are bytes objects, so this addition here is an addition of two bytes objects, just concatenation. So that's what we get here.

# Track the merges we made
merges = {
    (101, 32) : 256,  # 'e' + ' '
    (100, 32) : 257,  # 'd' + ' '  
    (116, 101) : 258, # 't' + 'e'
    (115, 32) : 259,  # 's' + ' '
    (105, 110): 260  # 'i' + 'n'
}
# given ids (list of integers), return Python string
vocab = {idx: bytes([idx]) for idx in range(256)}
for (p0, p1), idx in merges.items():
    vocab[idx] = vocab[p0] + vocab[p1]

def decode(ids):
    # given ids, get tokens
    tokens = b"".join(vocab[idx] for idx in ids)
    # convert from bytes to string
    text = tokens.decode("utf-8")
    return text

One tricky thing to be careful with by the way is that I'm iterating a dictionary in Python using a .items() and it really matters that this runs in the order in which we inserted items into the merges dictionary. 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. But we are using a modern Python, so we're okay.

And then here, given the IDs, the first thing we're going to do is get the tokens. So the way I implemented this here is I'm taking, I'm iterating over all the IDs, I'm using vocab to look up their bytes, and then here, this is one way in Python to concatenate all these bytes together to create our tokens. And then these tokens here at this point are raw bytes. So I have to decode using UTF-8 now back into Python strings. So previously we called .encode() on a string object to get the bytes, and now we're doing its opposite. We're taking the bytes and calling a decode on the bytes object to get a string in Python. And then we can return text.

Let's test this works:

print(decode([97]))  # Should work fine

So, this is how we can do it. Now, this actually has an issue in the way I implemented it, and this could actually throw an error. So try to figure out why this code could actually result in an error if we plug in some sequence of IDs that is unlucky. So let me demonstrate the issue. When I try to decode just something like 97, I am going to get the letter a here back. So nothing too crazy happening. But when I try to decode 128 as a single element, the token 128 is what in string or in Python object? UnicodeDecodeError. UTF-8 can't decode byte 0x80, which is this in hex, at position zero, invalid start byte. What does that mean?

try:print(decode([128]))  # This will cause UnicodeDecodeError
except Exception as e: print(str(e))

Well, to understand what this means, we have to go back to our UTF-8 page that I briefly showed earlier, and this is Wikipedia UTF-8. And basically there's a specific schema that UTF-8 bytes take. So in particular, if you have a multi-byte object for some of the Unicode characters, they have to have this special sort of envelope in how the encoding works. And so what's happening here is that invalid start byte, that's because 128, the binary representation of it is one followed by all zeros. So we have one and then all zero. And we see here that that doesn't conform to the format because one followed by all zero just doesn't fit any of these rules, so to speak. So it's an invalid start byte, which is byte one. This one must have a one following it, and then a zero following it, and then the content of your Unicode in Xs here.

So basically we don't exactly follow the UTF-8 standard and this cannot be decoded. And so the way to fix this is to use this errors equals in bytes.decode function of Python. And by default, errors is strict. So we will throw an error if it's not valid UTF-8 byte encoding. But there are many different things that you can put here on error handling. This is the full list of all the errors that you can use. And in particular, instead of strict, let's change it to replace. And that will replace with this special marker, this replacement character. So errors equals replace. And now we just get that character back.

def decode(ids):
    # given ids (list of integers), return Python string
    tokens = b"".join(vocab[idx] for idx in ids)
    text = tokens.decode("utf-8", errors="replace")
    return text
try:print(decode([128]))  # This should now print the replacement character without error
except Exception as e: print(str(e))

So basically 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 to basically use errors equals replace. And this is what you will also find in the OpenAI code that they released as well. But basically whenever you see this kind of a character in your output in that case, something went wrong and the LLM output was not valid sort of sequence of tokens.

Encoding: From Text to Tokens

Okay, and now we're going to go the other way. So we are going to implement this arrow right here, where we are going to be given a string and we want to encode it into tokens.

So this is the signature of the function that we're interested in. And this should basically print a list of integers of the tokens. So again, try to maybe implement this yourself if you'd like a fun exercise. And pause here, otherwise I'm going to start putting in my solution. So again, there are many ways to do this. So, this is one of the ways that sort of I came up with. So the first thing we're going to do is we are going to take our text, encode it into UTF-8 to get the raw bytes. And then as before, we're going to call list on the bytes object to get a list of integers of those bytes. So those are the starting tokens, those are the raw bytes of our sequence.

def encode(text):
    # given a string, return list of integers (the tokens)
    tokens = list(text.encode("utf-8"))
    while True:
        stats = get_stats(tokens)
        pair = min(stats, key=lambda p: merges.get(p, float("inf")))
        if pair not in merges:
            break # nothing else can be merged
        idx = merges[pair]
        tokens = merge(tokens, pair, idx)
    return tokens

But now, of course, according to the merges dictionary above, and recall this was the merges, some of the bytes may be merged according to this lookup. And in addition to that, remember that the 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. So we have to go in the order from top to bottom sort of if we are going to be merging anything.

Now, we expect to be doing a few merges, so we're going to be doing while true. And now we want to find a pair of bytes that is consecutive that we are allowed to merge according to this. In order to reuse some of the functionality that we've already written, I'm going to reuse the function get_stats.

So recall that get_stats will give us the, will basically count up how many times every single pair occurs in our sequence of tokens and return that as a dictionary. And the dictionary was a mapping from all the different byte pairs to the number of times that they occur, right? 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. And so I'm only going to be using basically the keys of this dictionary. I only care about the set of possible merge candidates, if that makes sense.

Let's test our encoding:

print(encode("hello world!"))

Now we want to identify the pair that we're going to be merging at this stage of the loop. So what do we want? We want to find the pair or like the key inside stats that has the lowest index in the merges dictionary because we want to do all the early merges before we work our way to the late merges. So again, there are many different ways to implement this, but I'm going to do something a little bit fancy here.

So I'm going to be using the min over an iterator. In Python, when you call min on an iterator and stats here is a dictionary, we're going to be iterating the keys of this dictionary in Python. So we're looking at all the pairs inside stats, which are all the consecutive pairs. And we're going to be taking the consecutive pair inside tokens that has the minimum what? The min takes a key which gives us the function that is going to return a value over which we're going to do the min. And the one we care about is we care about taking merges and basically getting that pair's index.

Handling Edge Cases

Okay, so let's wrap up this section at least. I wanted to point out that this is not quite the right implementation just yet because we are leaving out a special case. So in particular, if we try to do this, this will give us an error. And the issue is that if we only have a single character or an empty string, then stats is empty and that causes an issue inside min. So one way to fight this is if length of tokens is at least two. Because if it's less than two, it's just a single token or no tokens, then let's just there's nothing to merge, so we just return. So that would fix that case.

try: print(encode('h'))
except Exception as e: print(e)
def encode(text):
    # given a string, return list of integers (the tokens)
    tokens = list(text.encode("utf-8"))
    while True:
        stats = get_stats(tokens)
        if len(tokens) < 2:
            break  # nothing to merge
        pair = min(stats, key=lambda p: merges.get(p, float("inf")))
        if pair not in merges:
            break # nothing else can be merged
        idx = merges[pair]
        tokens = merge(tokens, pair, idx)
    return tokens
encode('h')

And then second, I have a few test cases here for us as well. So first, let's make sure about or let's note the following. If we take a string and we try to encode it and then decode it back, you would expect to get the same string back, right? Is that true for all strings?

# Test that encode/decode is identity for training text
text2 = decode(encode(text))
test_eq(text, text2)
# Test on new validation text
valtext = "Many common characters, including numerals, punctuation, and other symbols, are unified within the standard"
test_eq(decode(encode(valtext)), valtext)

So I think so here it is the case, and I think in general this is probably the case. But notice that 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 sort of byte streams. And so therefore some of them can't even be decodable. So this only goes in one direction. But for that one direction, we can check here. If we take the training text, which is the text that we trained the tokenizer on, we can make sure that when we encode and decode, we get the same thing back, which is true. And here I took some validation data. So I went to, I think this web page and I grabbed some text. So this is text that the tokenizer has not seen, and we can make sure that this also works. So that gives us some confidence that this was correctly implemented.

So those are the basics of the byte-pair encoding algorithm. We saw how we can take some training set, train a tokenizer. The parameters of this tokenizer really are just this dictionary of merges. And that basically creates a little binary forest on top of raw bytes. Once we have this, the merges table, we can both encode and decode between raw text and token sequences. So that's the simplest setting of the tokenizer. 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. So we're going to go through the details of this complexification one at a time.

GPT-2 and GPT-4 Tokenizers

Regex-Based Pre-tokenization

So let's get things off by looking at the GPT series. So in particular, I have the GPT-2 paper here. And this paper is from 2019 or so, so five years ago. And let's scroll down to input representation. This is where they talk about the tokenizer that they're using for GPT-2.

Now, this is all fairly readable, so I encourage you to pause and read this yourself. But this is where they motivate the use of the byte-pair encoding algorithm on the byte level representation of UTF-8 encoding. So this is where they motivated and they talk about the vocabulary sizes and everything. Now, everything here is exactly as we've covered it so far, but things start to depart around here. So what they mention is that they don't just apply the naive algorithm as we have done it. And in particular, here's a motivating example. Suppose that you have common words like dog. What will happen is that dog, of course, occurs very frequently in the text, and it occurs right next to all kinds of punctuation as an example. So dog dot, dog exclamation mark, dog question mark, etc. And naively, you might imagine that the BPE algorithm could merge these to be single tokens. And then you end up with lots of tokens that are just like dog with a slightly different punctuation. And so it feels like you're clustering things that shouldn't be clustered. You're combining kind of semantics with punctuation.

"We observed BPE includes many versions of common words like 'dog' since they occur in many contexts (e.g., 'dog.', 'dog!', 'dog?', etc.). This results in a sub-optimal allocation of limited vocabulary slots and model capacity. To avoid this, we prevent BPE from merging across character categories for any byte sequence."

And this feels suboptimal, and indeed they also say that this is suboptimal according to some of the experiments. So what they want to do is they want to top down in a manual way enforce that some types of characters should never be merged together. They want to enforce these merging rules on top of the byte-pair encoding algorithm. So let's take a look at their code and see how they actually enforce this and what kinds of merges they actually do perform.

So I have the tab open here for GPT-2 under OpenAI on GitHub. And when we go into source, there is an encoder.py. Now, 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 that it's called encoder, but that is the tokenizer. And there's a lot going on here and we're going to step through it in detail at one point. For now, I just want to focus on this part here. They create a regex pattern here that looks very complicated, and we're going to go through it in a bit. But this is the core part that allows them to enforce rules for what parts of the text will never be merged for sure.

# GPT-2 Encoder with regex pattern
class Encoder:
    def __init__(self, encoder, bpe_merges, errors='replace'):
        self.encoder = encoder
        self.decoder = {v:k for k,v in self.encoder.items()}
        self.bpe_merges = dict(zip(bpe_merges, range(len(bpe_merges))))
        self.cache = {}
        self.pat = re.compile(r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""")

Now notice that re.compile here is a little bit misleading because we're not just doing import re, which is the Python re module. We're doing import regex as re. And regex is a Python package that you can install, pip install regex. And it's basically an extension of re, so it's a bit more powerful re.

So let's take a look at this pattern and what it's doing and why this is actually doing the separation that they are looking for.

import regex as re
pat = re.compile(r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""")

Okay, so I've copy pasted the pattern here to our Jupyter notebook where we left off. And let's take this pattern for a spin. So in the exact same way that their code does, we're going to call an re.findall for this pattern on any arbitrary string that we are interested in. So this is the string that we want to encode into tokens to feed into an LLM like GPT-2. So what exactly is this doing?

# Test the regex pattern on simple text
text = "Hello world"
matches = pat.findall(text)
print(f"Text: '{text}'")
print(f"Matches: {matches}")
print(f"Number of chunks: {len(matches)}")

Well, re.findall will take this pattern and try to match it against this string. The way this works is that you are going from left to right in the string and you're trying to match the pattern. And re.findall will get all the occurrences and organize them into a list. Now, when you look at this pattern, first of all, notice that this is a raw string, and then these are three double quotes just to start the string. So really the string itself, this is the pattern itself, right?

And notice that it's made up of a lot of ors. So see these vertical bars, those are ors in regex. And so you go from left to right in the pattern and try to match it against the string wherever you are. So we have hello and we're going to try to match it. Well, it's not apostrophe s, it's not apostrophe t, or any of these. But it is an optional space followed by slash p of L one or more times. What is slash p of L? It is coming to some documentation that I found. Slash p of L is a letter, any kind of letter from any language. And hello is made up of letters, h e l l o, etc. So optional space followed by a bunch of letters, one or more letters, is going to match hello, but then the match ends because a white space is not a letter. So from there on begins a new sort of attempt to match against the string again. And starting in here, we're going to skip over all these again until we get to the exact same point again. And we see that there's an optional space, this is the optional space, followed by a bunch of letters, one or more of them, and so that matches. So when we run this, we get a list of two elements, hello and then space world.

Let's break down this pattern to understand what each part does:

Pattern: 's|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+

Breaking it down:

  • 's|'t|'re|'ve|'m|'ll|'d -> Common contractions
  • ?\p{L}+ -> Optional space + one or more letters
  • ?\p{N}+ -> Optional space + one or more numbers
  • ?[^\s\p{L}\p{N}]+ -> Optional space + punctuation/symbols
  • \s+(?!\S)|\s+ -> Whitespace handling
# Test with more complex text including punctuation
text2 = "Hello world how are you?"
matches2 = pat.findall(text2)
print(f"Text: '{text2}'")
print(f"Matches: {matches2}")
print(f"Number of chunks: {len(matches2)}")

The regex pattern ensures that BPE merging respects natural language boundaries by splitting text into these categories:

What it captures:

  1. Contractions - Common English contractions like "don't", "we're", "I'll" are kept as single units
  2. Words - Letters from any language (including accented characters) are grouped together, optionally preceded by a space
  3. Numbers - Digits are grouped together, optionally preceded by a space
  4. Punctuation & Symbols - Non-letter, non-digit characters are grouped together, optionally preceded by a space
  5. Whitespace - Handles various whitespace patterns

Why this matters:

  • Prevents "dog" + "." from merging into a single token
  • Keeps semantic meaning (words) separate from punctuation
  • Allows BPE to work within each category, but not across categories
  • Results in more meaningful token boundaries that respect language structure
pat.findall("I'll go! I don't know 123?   ")

Now, what is this doing and why is this important? We are taking our string and instead of directly encoding it for tokenization, we are first splitting it up. And when you actually step through the code, and we'll do that in a bit more detail, what really it's doing on a high level is that it first splits your text into a list of texts, just like this one. And all these elements of this list are processed independently by the tokenizer, and all the results of that processing are simply concatenated. So hello, world, oh, I missed how. Hello, world, how are you? We have five elements of a list. All of these will independently go from text to a token sequence, and then that token sequence is going to be concatenated. It's all going to be joined up.

And roughly speaking, what that does is you're only ever finding merges between the elements of this list. So you can only ever consider merges within every one of these elements individually. And after you've done all the possible merging for all these elements individually, the results of all that will be joined by concatenation. And so you are basically, what you're doing effectively is you are never going to be merging this e with this space because they are now parts of the separate elements of this list. And so you are saying we are never going to merge e space, because we're breaking it up in this way. So basically using this regex pattern to chunk up the text is just one way of enforcing that some merges are not to happen. And we're going to go into more of this text and we'll see that what this is trying to do on a high level is we're trying to not merge across letters, across numbers, across punctuation, and so on.

Let's see in more detail how that works. Let's continue now. We have slash p of n. If you go to the documentation, slash p of n is any kind of numeric character in any script. So it's numbers. So we have an optional space followed by numbers and those would be separated out. So letters and numbers are being separated.

# Step 1: Show how letters and numbers are separated
text = "Hello world123 how are you?"
matches = pat.findall(text)
print(f"Text: '{text}'")
print(f"Matches: {matches}")
print("Notice: 'world' and '123' are separate chunks")

So if I do hello world 123, how are you? Then world will stop matching here because one is not a letter anymore. But one is a number, so this group will match for that and we'll get it as a separate entity.

Let's see how these apostrophes work. So here, if we have apostrophe v as an example, then apostrophe here is not a letter or a number. So hello will stop matching and then we will exactly match this with that. So that will come out as a separate thing. So why are they doing the apostrophes here? Honestly, I think that these are just like very common apostrophes that are used typically. I don't love that they've done this because let me show you what happens when you have some Unicode apostrophes. Like for example, you can have if you have how's, then this will be separated out because of this matching. But if you use the Unicode apostrophe like this, then suddenly this does not work. And so this apostrophe will actually become its own thing now.

# Step 2: Show how contractions work with standard apostrophes
text = "how's it going"
matches = pat.findall(text)
print(f"Text: '{text}'")
print(f"Matches: {matches}")
print("Notice: Standard apostrophe 's' is kept with the word")
# Step 3: Show the Unicode apostrophe problem
text = "how\u2019s it going"  # Unicode apostrophe (different from standard ')
matches = pat.findall(text)
print(f"Text: '{text}'")
print(f"Matches: {matches}")
print("Notice: Unicode apostrophe becomes its own separate chunk!")

And so, it basically hardcoded for this specific kind of apostrophe and otherwise they become completely separate tokens. In addition to this, you can go to the GPT-2 docs and here where they define the pattern, they say, should have added re.ignorecase so BPE merges can happen for capitalized versions of contractions. So what they're pointing out is that you see how this is apostrophe and then lowercase letters. Well, because they didn't do re.ignorecase, then these rules will not separate out the apostrophes if it's uppercase.

So how's would be like this. But if I did HOW'S from uppercase, then notice suddenly the apostrophe comes by itself.

pat.findall("HOW'S it going?")

So the tokenization will work differently in uppercase and lowercase, inconsistently separating out these apostrophes. So this feels extremely gnarly and slightly gross. But that's that's how that works. Okay, so let's come back. After trying to match a bunch of apostrophe expressions, by the way, the other issue here is that these are quite language specific probably. So I don't know that all languages, for example, use or have these apostrophes, but that would be inconsistently tokenized as a result. Then we try to match letters, then we try to match numbers. And then if that doesn't work, we fall back to here. And what this is saying is again, optional space followed by something that is not a letter, number, or a space, and one or more of that. So what this is doing effectively is this is trying to match punctuation, roughly speaking, not letters and not numbers. So this group will try to trigger for that.

pat.findall("you!!!??")

So if I do something like this, then these parts here are not letters or numbers, but they will actually get caught here. And so they become its own group. So we've separated out the punctuation.

And finally, this is also a little bit confusing. So this is matching white space, but this is using a negative look ahead assertion in regex. So what this is doing is it's matching white space up to but not including the last white space character. Why is this important? This is pretty subtle, I think. So you see how the white space is always included at the beginning of the word. So space r, space u, etc. Suppose we have a lot of spaces here. What's going to happen here is that these spaces up to and not including the last character will get caught by this. And what that will do is it will separate out the spaces up to but not including the last character. So that the last character can come here and join with the space you. And the reason that's nice is because space you is the common token. So if I didn't have these extra spaces here, you would just have space you. And if I add tokens, if I add spaces, we still have a space you, but now we have all this extra white space.

pat.findall("     you")

So basically the GPT-2 tokenizer really likes to have a space letters or numbers. And it prepends these spaces and this is just something that it does consistently. So that's what that is for. And then finally, we have the last fallback is white space characters. So that would be just if that doesn't get caught, then this thing will catch any trailing spaces and so on.

I wanted to show one more real world example here. So if we have this string, which is a piece of Python code, and then we try to split it out, then this is the kind of output we get. So you'll notice that the list has many elements here and that's because we are splitting up fairly often every time sort of a category changes. So there will never be any merges within these elements. And that's what you are seeing here.

example = """
for i in range(1, 101):
    if i % 3 == 0 and i % 5 == 0:
        print("FizzBuzz")
    elif i % 3 == 0:
        print("Fizz")
    elif i % 5 == 0:
        print("Buzz")
    else:
        print(i)
"""

print(pat.findall(example))

Now, you might think that in order to train the tokenizer, OpenAI has used this to split up text into chunks and then run just the BPE algorithm within all the chunks. But that is not exactly what happened. And the reason is the following. Notice that we have the spaces here. Those spaces end up being entire elements. But these spaces never actually end up being merged by OpenAI. And the way you can tell is that if you copy paste the exact same chunk here into tiktoken, tiktokenizer, you see that all the spaces are kept independent and they're all token 220.

pasted_image

So, I think OpenAI at some point enforced some rule that these spaces would never be merged. And so, there's some additional rules on top of just chunking and BPEing that OpenAI is not clear about. Now, the training code for the GPT-2 tokenizer was never released. So all we have is the code that I've already shown you. But this code here that they released is only the inference code for the tokens. So this is not the training code. You can't give it a piece of text and train a tokenizer. This is just the inference code which takes the merges that we have up above and applies them to a new piece of text. And so we don't know exactly how OpenAI trained the tokenizer, but it wasn't as simple as chunk it up and BPE it, whatever it was.

The tiktoken Library

Next, I wanted to introduce you to the tiktoken library from OpenAI, which is the official library for tokenization from OpenAI. So tiktoken is what you should use. Pip install tiktoken and then you can do the tokenization inference. So this is again, not training code, this is only inference code for tokenization.

I wanted to show you how you would use it. It's quite simple. And running this just gives us the GPT-2 tokens or the GPT-4 tokens. So this is the tokenizer used for GPT-4. As in particular, we see that the white space in GPT-2 remains unmerged, but in GPT-4, these white spaces merge as we also saw in this one, where here they're all unmerged, but if we go down to GPT-4, they become merged.

import tiktoken
# Compare GPT-2 vs GPT-4 tokenization
enc_gpt2 = tiktoken.get_encoding("gpt2")
enc_gpt4 = tiktoken.get_encoding("cl100k_base")

tokens_gpt2 = enc_gpt2.encode(example)
tokens_gpt4 = enc_gpt4.encode(example)

print(f"GPT-2 tokens: {len(tokens_gpt2)}")
print(f"GPT-4 tokens: {len(tokens_gpt4)}")

Let's examine how the whitespace handling differs between the two tokenizers:

decoded_gpt4 = [enc_gpt4.decode([token]) for token in tokens_gpt4] 
for i, token_str in enumerate(decoded_gpt4): 
    if token_str.strip() == '': print(f"Token {i}: {repr(token_str)} (all whitespace)")

pasted_image

Now, in the GPT-4 tokenizer, they changed the regular expression that they use to chunk up text. So the way to see this is that if you come to your the tiktoken library, and then you go to this file, tiktoken_ext, openai_public. This is where sort of like the definition of all the different tokenizers that OpenAI maintains is. And so, necessarily to do the inference, they had to publish some of the details about the strings. So this is the string that we already saw for GPT-2. It is slightly different, but it is actually equivalent to what we discussed here. So this pattern that we discussed is equivalent to this pattern. This one just executes a little bit faster. So here you see a little bit of a slightly different definition, but otherwise it's the same.

# GPT-2 tokenizer pattern from tiktoken openai_public.py
def gpt2():
    mergeable_ranks = data_gym_to_mergeable_bpe_ranks(
        vocab_bpe_file="https://openaipublic.blob.core.windows.net/gpt-2/encodings/main/vocab.bpe",
        encoder_json_file="https://openaipublic.blob.core.windows.net/gpt-2/encodings/main/encoder.json",
        vocab_bpe_hash="1ce1664773c50f3e0cc8842619a93edc4624525b728b188a9e0be33b7726adc5",
        encoder_json_hash="196139668be63f3b5d6574427317ae82f612a97c5d1cdaf36ed2256dbf636783",
    )
    return {
        "name": "gpt2",
        "explicit_n_vocab": 50257,
        # The pattern in the original GPT-2 release is:
        # r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
        # This is equivalent, but executes faster:
        "pat_str": r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}++| ?\p{N}++| ?[^\s\p{L}\p{N}]++|\s++$|\s+(?!\S)|\s""",
        "mergeable_ranks": mergeable_ranks,
        "special_tokens": {"<|endoftext|>": 50256},
    }

We're going to go into special tokens in a bit. And then if you scroll down to cl100k, this is the GPT-4 tokenizer. You see that the pattern has changed. And this is kind of like the main, the major change in addition to a bunch of other special tokens which we'll go into in a bit again.

# GPT-4 tokenizer pattern from tiktoken openai_public.py
def cl100k_base():
    mergeable_ranks = load_tiktoken_bpe(
        "https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken"
    )
    special_tokens = {
        "<|endoftext|>": 100257,
        "<|fim_prefix|>": 100258,
        "<|fim_middle|>": 100259,
        "<|fim_suffix|>": 100260,
        "<|endofprompt|>": 100276
    }
    return {
        "name": "cl100k_base", 
        "explicit_n_vocab": 100277,
        # Different pattern from GPT-2 - handles whitespace better
        "pat_str": r"""(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{2,}|[^\r\n\p{L}\p{N}]?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+""",
        "mergeable_ranks": mergeable_ranks,
        "special_tokens": special_tokens,
    }

Now, 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 really the major changes are, number one, you see this i here, that means that the case sensitivity, this is case insensitive match. And so the comment that we saw earlier on, oh, you should have used re.uppercase, basically, we're now going to be matching these apostrophe s, apostrophe d, apostrophe m, etc. We're going to be matching them both in lowercase and in uppercase. So that's fixed. There's a bunch of different like handling of the white space that I'm not going to go into the full details of. And then one more thing here is you will notice that when they match the numbers, they only match one to three numbers. So they will never merge numbers that are in in more than three digits. Only up to three digits of numbers will ever be merged. And that's one change that they made as well to prevent tokens that are very, very long number sequences. But again, 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. But those are some of the changes that GPT-4 has made. And of course, the vocabulary size went from roughly 50k to roughly 100k.

The GPT-4 pattern: r"""(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{2,}|[^\r\n\p{L}\p{N}]?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+"""

Breaking it down:

  1. (?i:'s|'t|'re|'ve|'m|'ll|'d) - Case-insensitive contractions (fixes the uppercase problem!)
  2. [^\r\n\p{L}\p{N}]?\p{L}+ - Optional non-letter/non-digit/non-newline + letters
  3. \p{N}{2,} - Numbers with 2+ digits (changed from 1+ in GPT-2)
  4. [^\r\n\p{L}\p{N}]?[^\s\p{L}\p{N}]+[\r\n]* - Punctuation/symbols with optional newlines
  5. \s*[\r\n]+ - Newline handling with optional spaces
  6. \s+(?!\S)|\s+ - Whitespace handling (similar to GPT-2)

Key improvements over GPT-2:

  • ✅ Case-insensitive contractions ((?i:...))
  • ✅ Better newline handling
  • ✅ Numbers require 2+ digits (prevents single digit tokens)
  • ✅ More sophisticated whitespace merging

Let's test these differences:

# Step 1: Test case-insensitive contractions (GPT-4 vs GPT-2)
gpt4_pat = re.compile(r"""(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{2,}|[^\r\n\p{L}\p{N}]?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+""")

# Test uppercase contractions
test_text = "HOW'S IT GOING? how's it going?"
gpt2_result = pat.findall(test_text)
gpt4_result = gpt4_pat.findall(test_text)

print(f"Text: '{test_text}'")
print(f"GPT-2: {gpt2_result}")
print(f"GPT-4: {gpt4_result}")
print("Notice: GPT-4 keeps 'HOW'S' together, GPT-2 splits it!")
# Step 2: Test number handling (2+ digits requirement)
test_numbers = "I have 1 apple, 12 oranges, and 123 bananas."
gpt2_result = pat.findall(test_numbers)
gpt4_result = gpt4_pat.findall(test_numbers)

print(f"Text: '{test_numbers}'")
print(f"GPT-2: {gpt2_result}")
print(f"GPT-4: {gpt4_result}")
print("Notice: GPT-4 drops single digits entirely (1 is missing), only captures multi-digits (12, 123)")
# Step 3: Test newline and whitespace handling
test_newlines = "Hello\nworld\n\n  \ntest"
gpt2_result = pat.findall(test_newlines)
gpt4_result = gpt4_pat.findall(test_newlines)

print(f"Text: {repr(test_newlines)}")
print(f"GPT-2: {gpt2_result}")
print(f"GPT-4: {gpt4_result}")
print("Notice: GPT-4 merges more newline sequences together")

Building Your Own Tokenizer

The minbpe Exercise

Okay, so at this point you should have everything you need in order to build your own GPT-4 tokenizer. Now, in the process of developing this lecture, I've done that and I've published the code under this repository minbpe.

So minbpe looks like this right now as I'm recording, but the minbpe repository will probably change quite a bit because I intend to continue working on it. In addition to the minbpe repository, I've published this exercise progression that you can follow. So if you go to exercise.md here, this is sort of me breaking up the task ahead of you into four steps that sort of build up to what can be a GPT-4 tokenizer. And so feel free to follow these steps exactly and follow a little bit of the guidance that I've laid out here. And anytime you feel stuck, just reference the minbpe repository here.

Build your own GPT-4 Tokenizer!

This exercise progression will guide you through building a complete GPT-4 style tokenizer step by step. Each step builds upon the previous one, gradually adding complexity until you have a fully functional tokenizer that matches OpenAI's tiktoken library.

Step 1: Basic BPE Implementation

Write the BasicTokenizer class with the following three core functions:

  • def train(self, text, vocab_size, verbose=False)
  • def encode(self, text)
  • def decode(self, ids)

Your Task:

  • Train your tokenizer on whatever text you like and visualize the merged tokens
  • Do they look reasonable?
  • One default test you may wish to use is the text file tests/taylorswift.txt

What you're building: The simplest possible BPE tokenizer that works directly on raw text without any preprocessing.

# Do your work in solveit here

Step 2: Add Regex Preprocessing (GPT-2/GPT-4 Style)

Convert your BasicTokenizer into a RegexTokenizer that:

  • Takes a regex pattern and splits the text exactly as GPT-4 would
  • Processes the parts separately as before, then concatenates the results
  • Retrain your tokenizer and compare the results before and after

Use the GPT-4 pattern:

GPT4_SPLIT_PATTERN = 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*[\r\n]|\s+(?!\S)|\s+"""

Expected Result: You should see that you will now have no tokens that go across categories (numbers, letters, punctuation, more than one whitespace).

# Do your work in solveit here

Step 3: Load GPT-4 Merges

Now we want to load the GPT-4 tokenizer merges and exactly reproduce the GPT-4 tokenizer. This step is the most complex because we need to recover the original merges from the GPT-4 tokenizer.

The Challenge:

  • GPT-4 applies a byte permutation to the raw bytes before BPE
  • We need to "recover" the original merges from the final tokenizer
  • Use the recover_merges() function to extract merges from tiktoken

Your Task:

  • Load the GPT-4 tokenizer using tiktoken
  • Recover the merges and handle the byte shuffle
  • Verify your tokenizer matches tiktoken exactly on test cases

Expected Result: Your RegexTokenizer should now tokenize exactly like GPT-4's cl100k_base encoding.

# Do your work in solveit here

Step 4: Handle Special Tokens (Optional)

Add support for special tokens like <|endoftext|> to match tiktoken's behavior completely.

Your Task:

  • Extend your tokenizer to handle special tokens
  • Implement the allowed_special parameter
  • Test with GPT-4's special tokens: <|endoftext|>, <|fim_prefix|>, etc.

Key Features:

  • Special tokens bypass normal BPE processing
  • They get assigned specific token IDs outside the regular vocabulary
  • Handle the allowed_special and disallowed_special parameters

Expected Result: Your tokenizer can now handle special tokens exactly like tiktoken, including proper error handling for disallowed special tokens.

# Do your work in solveit here

Step 5: Advanced - Explore SentencePiece (Stretch Goal)

This is the most advanced step - understanding how other tokenizers like Llama 2 work differently from GPT's byte-level BPE.

The Key Difference:

  • GPT-style: Byte-level BPE (works on UTF-8 bytes)
  • Llama-style: Unicode code point BPE (works on Unicode characters)

Your Challenge:

  • Study how SentencePiece tokenization differs from byte-level BPE
  • Understand why Llama 2 can handle non-English languages more efficiently
  • (Optional) Try implementing a SentencePiece-style tokenizer

Learning Goals:

  • Appreciate the trade-offs between different tokenization approaches
  • Understand why different models make different tokenization choices
  • See how tokenization affects model performance on different languages

Resources: Check the SentencePiece paper and the Llama 2 tokenizer for reference.

# Do your work in solveit here

So either the tests could be useful or the minbpe repository itself. I tried to keep the code fairly clean and understandable. And so, feel free to reference it whenever you get stuck.

In addition to that, basically, once you write it, you should be able to reproduce this behavior from tiktoken. So getting the GPT-4 tokenizer, you can take you can encode this string and you should get these tokens. And then you can encode and decode the exact same string to recover it. And in addition to all that, you should be able to implement your own train function, which tiktoken library does not provide. It's again, only inference code. But you should be able to write your own train, minbpe does it as well. And that will allow you to train your own token vocabularies.

So here's some of the code inside minbpe, minbpe shows the token vocabularies that you might obtain. So on the left here, we have the GPT-4 merges. So the first 256 are raw individual bytes. And then here I am visualizing the merges that GPT-4 performed during its training. So the very first merge that GPT-4 did was merge two spaces into a single token for, you know, two spaces. And that is the token 256.

And so this is the order in which things merged during GPT-4 training. And this is the merge order that we obtained in minbpe by training a tokenizer. And in this case, I trained it on a Wikipedia page of Taylor Swift. 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. And what was I going to say? Yeah, so you can compare these two vocabularies and so as an example, here GPT-4 merged I M to become in, and we've done the exact same thing on this token 259. Here, space T becomes spacey, and that happened for us a little bit later as well. So the difference here is again, to my understanding, only a difference of the training set. So as an example, because I see a lot of white space, I expect 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. And when you train your own, you're probably going to get something similar depending on what you train it on.

Key Insights from the minbpe Exercise:

What You Should Be Able to Do:

  • Reproduce tiktoken behavior exactly - Your tokenizer should encode/decode strings identically to GPT-4's cl100k_base
  • Implement your own training function - Unlike tiktoken (inference-only), you can train custom vocabularies
  • Compare different training datasets - See how training data affects the learned merges

Vocabulary Comparison Insights: Looking at the side-by-side comparison in the image:

Left (GPT-4 Official):

  • First 256 tokens: Raw individual bytes
  • Token 256: Two spaces merged (indicates lots of code/structured text in training)
  • Shows heavy whitespace merging patterns

Right (Taylor Swift Wikipedia):

  • Same algorithm, different training data
  • Less whitespace merging (typical prose text)
  • Similar patterns but different priorities

Key Observations:

  • Same algorithm, different results - BPE produces vocabularies that reflect the training data
  • Training data matters - GPT-4's heavy whitespace merging suggests Python code in training set
  • Merge order reveals priorities - Most frequent patterns get merged first
  • Reproducible patterns - Both show similar merges like "IM" → "in" and "space+T" → "space+T"

The Power of Custom Training: You can now train tokenizers optimized for your specific domain - whether that's code, medical text, or any specialized content!

SentencePiece and Alternative Approaches

How SentencePiece Differs

Okay, so we are now going to move on from tiktoken and the way that OpenAI tokenizes its strings. And we're going to discuss one more very commonly used library for working with tokenization in LLMs, and that is SentencePiece. So SentencePiece is very commonly used in language models because unlike tiktoken, it can do both training and inference, and it's quite efficient at both. It supports a number of algorithms for training vocabularies, but one of them is the byte-pair encoding algorithm that we've been looking at. So it supports it.

Now, SentencePiece is used both by Llama and Mistral series and many other models as well. It is on GitHub under google/sentencepiece.

And the big difference with SentencePiece, and we're going to look at an example because this is kind of hard and subtle to explain, is that they think different about the order of operations here. So in the case of tiktoken, we first take our code points in a string, we encode them using UTF-8 to bytes, and then we're merging bytes. It's fairly straightforward.

For SentencePiece, it works directly on the level of the code points themselves. So it looks at whatever code points are available in your training set, and then it starts merging those code points. And the BPE is running on the level of code points. And if you happen to run out of code points, so there are maybe some rare code points that just don't come up too often, and the rarity is determined by this character coverage hyperparameter, then these code points will either get mapped to a special unknown token, like unk, or if you have the byte fallback option turned on, then they will take those rare code points, they will encode them using UTF-8, and then the individual bytes of that encoding will be translated into tokens. And there are these special byte tokens that basically get added to the vocabulary. So it uses BPE on on the code points, and then it falls back to bytes for rare code points. And so that's kind of our difference. Personally, I find the tiktoken way significantly cleaner, but it's kind of like a subtle but pretty major difference between the way they approach tokenization.

tiktoken vs SentencePiece: The Key Difference

tiktoken (GPT approach):

  1. Text → UTF-8 bytes → BPE on bytes
  2. Always works on byte level (0-255)

SentencePiece (Llama approach):

  1. Text → Unicode code points → BPE on code points
  2. Falls back to bytes only for rare characters

Why it matters:

  • tiktoken: Handles all languages equally but may be less efficient for non-English
  • SentencePiece: More efficient for languages with many unique characters (Chinese, Japanese)

Let's work with a concrete example because otherwise this is kind of hard to get your head around. So let's work with a concrete example. This is how we can import SentencePiece. And then here we're going to take, I think I took like the description of SentencePiece and I just created like a little toy dataset. It really likes to have a file, so I created a toy.txt file with this content.

# Compare tiktoken vs SentencePiece on Chinese text
chinese_text = "你好世界"  # "Hello World" in Chinese

print(f"Text: {chinese_text}")
print(f"UTF-8 bytes: {chinese_text.encode('utf-8')}")
print(f"Unicode code points: {[ord(c) for c in chinese_text]}")

# tiktoken approach: work on bytes
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
tiktoken_tokens = enc.encode(chinese_text)
print(f"tiktoken tokens: {tiktoken_tokens} (count: {len(tiktoken_tokens)})")
# SentencePiece approach: work on code points (if we had it installed)
# !pip install sentencepiece  # Uncomment to install

# For comparison, let's see the difference in approach:
print("tiktoken approach:")
print("1. Characters → UTF-8 bytes → BPE merges bytes")
for char in chinese_text:
    utf8_bytes = char.encode('utf-8')
    print(f"  '{char}' → {utf8_bytes} → separate tokens for each byte")

print("\nSentencePiece approach:")
print("2. Characters → Unicode code points → BPE merges code points")
for char in chinese_text:
    code_point = ord(char)
    print(f"  '{char}' → U+{code_point:04X} → can merge whole characters")

Now, what's kind of a little bit crazy about SentencePiece is that there's a ton of options and configurations. And the reason this is so is because SentencePiece has been around, I think for a while, and it really tried 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. And so in particular, there's like a ton of configuration arguments. This is not even all of it.

You can go to here to see all the training options. And there's also quite useful documentation when you look at the raw protobuf that is used to represent the trainer spec and so on. Many of these options are irrelevant to us. So maybe to point out one example, dash dash shrinking factor. This 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.

Key SentencePiece Options for BPE Training:

Essential BPE Parameters:

  • --model_type=bpe - Use byte-pair encoding (default is "unigram")
  • --vocab_size=8000 - Final vocabulary size (e.g., 8000, 16000, 32000)
  • --input=file.txt - Training text file
  • --model_prefix=model_name - Output model prefix (creates .model and .vocab files)

Important for Different Languages:

  • --character_coverage=0.9995 - For languages with rich character sets (Japanese/Chinese)
  • --character_coverage=1.0 - For languages with small character sets (English/European)

Special Tokens:

  • --bos_id=1 - Beginning of sentence token ID
  • --eos_id=2 - End of sentence token ID
  • --unk_id=0 - Unknown token ID
  • --pad_id=-1 - Padding token ID (-1 disables)

Advanced Options:

  • --byte_fallback=true - Use byte fallback for rare characters
  • --split_digits=true - Split numbers into individual digits
  • --user_defined_symbols=["<mask>"] - Add custom special tokens

Note: Many options (like --shrinking_factor) apply only to other algorithms (unigram) and are irrelevant for BPE training.

Now, what I tried to do here is I tried to set up SentencePiece in a way that is very, very similar, as far as I can tell, to maybe identical hopefully, to the way that Llama 2 was trained. So the way they trained their own tokenizer. And the way I did this was basically going to take the tokenizer.model file that Meta released, and you can open it using the proto protobuf sort of file that you can generate. And then you can inspect all the options, and I tried to copy over all the options that look relevant.

pasted_image

So here we set up the input. It's raw text in this file. Here it's going to be the output, so it's going to be prefix tok400.model and .vocab. We're saying that we're going to use the BPE algorithm and we want a vocab size of 400. Then there's a ton of configurations here for basically preprocessing and normalization rules as they're called. 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 white space, etc.

And in language models, it's preferred 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. So you're basically trying to turn off a lot of this if you can. The other thing that SentencePiece does is that it has this concept of sentences. So SentencePiece, it's back, it kind of was developed, I think early in the days where there was an idea that they you're training a tokenizer on a bunch of independent sentences. So it has a lot of like how many sentences you're going to train on, what is the maximum sentence length. Shuffling sentences. And so for it, sentences are kind of like the individual training examples.

But again, in the context of LLMs, I find that this is like a very spurious and weird distinction. Like sentences are just like, don't touch the raw data. Sentences happen to exist, but in the raw datasets, there are a lot of like in-betweens. Like what exactly is a sentence? What isn't a sentence? And so I think like it's really hard to define what an actual sentence is if you really like dig into it. And there could be different concepts of it in different languages or something like that. So why even introduce the concept? It it doesn't honestly make sense to me. I would just prefer to treat a file as a giant stream of bytes.

It has a lot of treatment around rare word characters, and when I say word, I mean code points. We're going to come back to this in a second. And it has a lot of other rules for basically splitting digits, splitting white space and numbers and how you deal with that. So this is something like merge rules. So I think this is a little bit similar to tiktoken using the regular expression to split up categories. There's like kind of the equivalent of it is quintated in SentencePiece where you can also, for example, split up the digits, and so on.

There's a few more things here that I'll come back to in a bit. And then there are some special tokens that you can indicate. And it hardcodes the unk token, the beginning of sentence, end of sentence, and a pad token. And the unk token must exist for my understanding. And then some system things. So we can train. And when when I press train, it's going to create this file tok400.model and tok400.vocab. I can then load the model file and I can inspect the vocabulary of it.

Training Configuration

Let's now train a SentencePiece model with all these configuration options. First, let's create our training data:

# Create toy training data
with open("toy.txt", "w", encoding="utf-8") as f:
    f.write("SentencePiece is an unsupervised text tokenizer and detokenizer mainly for Neural Network-based text generation systems where the vocabulary size is predetermined prior to the neural model training. SentencePiece implements subword units (e.g., byte-pair-encoding (BPE) [Sennrich et al.]) and unigram language model [Kudo.]) with the extension of direct training from raw sentences. SentencePiece allows us to make a purely end-to-end system that does not depend on language-specific pre/postprocessing.")
# pip install sentencepiece
# NOTE: after pip install sentencepiece can't be imported, requires a restarting the dialogue env
import sentencepiece as spm

Now let's set up the training with all the configuration options that match Llama 2:

# Train a SentencePiece BPE model
# These settings match those used for training Llama 2

options = dict(
    # Input spec
    input="toy.txt",
    input_format="text",
    # Output spec
    model_prefix="tok400", # output filename prefix
    # Algorithm spec - BPE algorithm
    model_type="bpe",
    vocab_size=400,
    # Normalization (turn off to keep raw data)
    normalization_rule_name="identity", # turn off normalization
    remove_extra_whitespaces=False,
    input_sentence_size=200000000, # max number of training sentences
    max_sentence_length=4192, # max number of bytes per sentence
    seed_sentencepiece_size=1000000,
    shuffle_input_sentence=True,
    # Rare word treatment
    character_coverage=0.99995,
    byte_fallback=True,
    # Merge rules
    split_digits=True,
    split_by_unicode_script=True,
    split_by_whitespace=True,
    split_by_number=True,
    max_sentencepiece_length=16,
    add_dummy_prefix=True,
    allow_whitespace_only_pieces=True,
    # Special tokens
    unk_id=0, # the UNK token MUST exist
    bos_id=1, # the others are optional, set to -1 to turn off
    eos_id=2,
    pad_id=-1,
    # Systems
    num_threads=os.cpu_count(), # use ~all system resources
)

spm.SentencePieceTrainer.train(**options);

Once trained, we can load and inspect the model:

# Load and inspect the trained model
sp = spm.SentencePieceProcessor()
sp.load('tok400.model')

# Show the vocabulary - first few entries
vocab = [[sp.id_to_piece(idx), idx] for idx in range(sp.get_piece_size())]
print("First 20 tokens:")
for token, idx in vocab[:20]:
    print(f"  {idx}: '{token}'")
    
print(f"\nTotal vocabulary size: {len(vocab)}")

And we trained a vocab size 400 on this text here. And these are the individual pieces, the individual tokens that SentencePiece will create. So in the beginning, we see that we have the unk token with the ID zero. Then we have the beginning of sequence, end of sequence, one and two. And we said that the pad ID is negative one, so we chose not to use it. So there's no pad ID here.

Then these are individual byte tokens. So here we saw that byte fallback in Llama was turned on, so it's true. So what follows are going to be the 256 byte tokens. And these are their IDs.

# Show the SentencePiece vocabulary structure
print("SentencePiece Vocabulary Structure:")
print("=" * 40)

# 1. Special tokens (first few)
print("1. Special tokens:")
for i in range(3):
    print(f"  {i}: '{sp.id_to_piece(i)}'")

print("\n2. Byte tokens (next 256):")
print("  3-258: <0x00> through <0xFF>")
for i in [3, 4, 5, 257, 258]:  # Show first few and last few
    print(f"  {i}: '{sp.id_to_piece(i)}'")
# 3. Merge tokens (BPE learned merges)
print("\n3. Merge tokens (BPE merges):")
print("  259-399: Learned BPE merges")
for i in range(259, min(269, sp.get_piece_size())):  # Show first 10 merges
    print(f"  {i}: '{sp.id_to_piece(i)}'")

# 4. Individual code point tokens
print("\n4. Individual code point tokens:")
print("  These are raw Unicode characters from training data")
# Find where individual tokens start (after merges)
for i in range(350, min(400, sp.get_piece_size())):
    piece = sp.id_to_piece(i)
    if len(piece) == 1 and not piece.startswith('<'):  # Single character, not a byte token
        print(f"  {i}: '{piece}'")
        if i > 360:  # Just show a few examples
            break

And then at the bottom, after the byte tokens, come the merges. And these are the parent nodes in the merges. So we're not seeing the children, we're just seeing the parents and their ID. And then after the merges comes eventually the individual tokens and their IDs. And so these are the individual tokens, so these are the individual code point tokens, if you will, and they come at the end.

So that is the ordering with which SentencePiece sort of represents its vocabularies. It starts with special tokens, then the byte tokens, then the merge tokens, and then the individual code point tokens. And all these raw code point tokens are the ones that it encountered in the training set. So those individual code points are all the the entire set of code points that occurred here.

And then those that are extremely rare, as determined by character coverage, so 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. Once we have a vocabulary, we can encode into IDs and we can sort of get a list. And then here I am also decoding the individual tokens back into little pieces as they call it.

Let's test the tokenizer with some text, including non-English characters:

# Test the SentencePiece tokenizer
test_text = "hello 안녕하세요"
ids = sp.encode(test_text)
pieces = [sp.id_to_piece(idx) for idx in ids]

print(f"Text: '{test_text}'")
print(f"Token IDs: {ids}")
print(f"Token pieces: {pieces}")
print(f"Decoded: '{sp.decode(ids)}'")

# Notice how Korean characters become byte tokens due to byte_fallback=True

So let's take a look at what happened here. Hello space 안녕하세요. So these are the token IDs we got back. And when we look here, a few things sort of jump to mind. Number one, take a look at these characters. The Korean characters, of course, were not part of the training set. So SentencePiece is encountering code points that it has not seen during training time, and those code points do not have a token associated with them. So suddenly these are unk tokens, unknown tokens.

But because byte fallback is true, instead, SentencePiece falls back to bytes. And so it takes this, it encodes it with UTF-8, and then it uses these tokens to represent those bytes. And that's what we are getting sort of here. This is the UTF-8 encoding, and it is shifted by three because of these special tokens here that have IDs earlier on. So that's what happened here.

Byte Fallback in SentencePiece

What is byte fallback? When SentencePiece encounters a rare character (Unicode code point) that's not in the vocabulary, instead of mapping it to <unk>, it:

  1. Converts the character to its UTF-8 bytes
  2. Maps each byte to a special byte token (<0x00> through <0xFF>)

Example:

  • Korean character '안' → UTF-8 bytes: 0xEC 0x95 0x88
  • Becomes 3 tokens: <0xEC>, <0x95>, <0x88>

Key benefits:

  • No information loss - can perfectly reconstruct original text
  • Universal coverage - handles any language/character
  • Graceful degradation - rare characters just use more tokens

Vocabulary impact:

  • All 256 byte tokens are automatically added to vocabulary
  • Takes up 256 slots of your vocab_size
  • Remaining slots used for learned BPE merges

vs tiktoken: SentencePiece tries character-level first, falls back to bytes. tiktoken always works at byte-level.

Now, one more thing that, well, first before I go on, with respect to the byte fallback, let me remove byte fallback. If this is false, what's going to happen? Let's retrain.

# Train SentencePiece WITHOUT byte fallback
options_no_fallback = options.copy()
options_no_fallback['byte_fallback'] = False
options_no_fallback['model_prefix'] = "tok400_no_fallback"

spm.SentencePieceTrainer.train(**options_no_fallback);
# Load the no-fallback model and compare vocabularies
sp_no_fallback = spm.SentencePieceProcessor()
sp_no_fallback.load('tok400_no_fallback.model')

print(f"With byte fallback: {sp.get_piece_size()} tokens")
print(f"Without byte fallback: {sp_no_fallback.get_piece_size()} tokens")

# Show that byte tokens are gone
print("\nFirst 10 tokens (no fallback):")
for i in range(10):
    print(f"  {i}: '{sp_no_fallback.id_to_piece(i)}'")

So the first thing that happened is all the byte tokens disappeared, right? And now we just have the merges, and we have a lot more merges now because we have a lot more space because we're not taking up space in the vocab size with all the bytes.

# Test encoding Korean text without byte fallback
test_text = "hello 안녕하세요"

# With byte fallback
ids_with_fallback = sp.encode(test_text)
print(f"With fallback: {ids_with_fallback}")
print(f"Decoded: '{[sp.id_to_piece(id) for id in ids_with_fallback]}'")

# Without byte fallback  
ids_no_fallback = sp_no_fallback.encode(test_text)
print(f"\nWithout fallback: {ids_no_fallback}")
print(f"Decoded: '{[sp_no_fallback.id_to_piece(id) for id in ids_no_fallback]}'")

# Korean characters become UNK (token 0)

And now if we encode this, we get a zero. So this entire string here suddenly, there's no byte fallback, so this is unknown, and unknown is unk. And so this is zero because the unk token is token zero. And you have to keep in mind that this would feed into your language model. So what is the language model supposed to do when all kinds of different things that are unrecognized because they are 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 next thing I want to show you is the following. Notice here when we are decoding all the individual tokens, you see how spaces, space here ends up being this bold underline. 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.

But notice this, why do we have an extra space in the front of hello? What where is this coming from? Well, it's coming from this option here, add dummy prefix is true.

And when you go to the documentation, add dummy whitespace at the beginning of text in order to treat "world" in "world" and "hello world" in the exact same way.

// Add dummy whitespace at the beginning of text in order to
// treat "world" in "world" and "hello world" in the exact same way.
optional bool add_dummy_prefix = 26 [default = true];

Purpose: Ensures consistent tokenization by making sure words are treated the same whether they appear at the beginning of text or in the middle. Without this, "world" alone vs "world" in "hello world" might tokenize differently due to the presence/absence of leading whitespace.

sp.encode('world'), sp.encode('a world')
sp.id_to_piece(313)

So what this is trying to do is the following. If we go back to our tiktokenizer, "world" as a token by itself has a different ID than "space world". So we have, this is 1917, but this is 14, etc. So these are two different tokens for the language model, and the language model has to learn from data that they are actually kind of a very similar 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.

pasted_image

So this add dummy prefix is trying to fight that a little bit. And the way that works is that it basically adds a dummy prefix. So for, as a part of preprocessing, it will take this string and it will add a space. It will do this. And that's done in an effort to make this world and that world the same. They will both be "space world". So that's one other kind of preprocessing option that is turned on, and Llama 2 also uses this option.

And that's I think everything that I want to say from my preview of SentencePiece and how it is different. Maybe here what I've done is I just put in the raw protocol buffer representation basically of the tokenizer that Llama 2 trained. So feel free to sort of inspect through this, and if you would like your tokenization to look identical to that of the Meta Llama 2, then you would be copy-pasting these settings as I've tried to do up above. And, yeah, that's, I think that's it for this section.

Llama 2 Tokenizer Configuration (from protobuf):

normalizer_spec {
  name: "identity"
  precompiled_charsmap: ""
  add_dummy_prefix: true
  remove_extra_whitespaces: false
  normalization_rule_tsv: ""
}

trainer_spec {
  input: "/large_experiments/theorem/datasets/MERGED/all.test1.merged"
  model_prefix: "spm_model_32k_200M_charcov099995_allowWSO__v2"
  model_type: BPE
  vocab_size: 32000
  self_test_sample_size: 0
  input_format: "text"
  character_coverage: 0.99995
  input_sentence_size: 200000000
  seed_sentencepiece_size: 1000000
  shrinking_factor: 0.75
  num_threads: 80
  num_sub_iterations: 2
  max_sentence_length: 4192
  shuffle_input_sentence: true
  max_sentencepiece_length: 16
  split_by_unicode_script: true
  split_by_whitespace: true
  split_by_number: true
  treat_whitespace_as_suffix: false
  split_digits: true
  allow_whitespace_only_pieces: true
  vocabulary_output_piece_score: true
  hard_vocab_limit: true
  use_all_vocab: false
  byte_fallback: true
  required_chars: ""
  unk_id: 0
  bos_id: 1
  eos_id: 2
  pad_id: -1
  unk_surface: " ⁇ "
  unk_piece: "<unk>"
  bos_piece: "<s>"
  eos_piece: "</s>"
  pad_piece: "<pad>"
  train_extremely_large_corpus: false
  enable_differential_privacy: false
  differential_privacy_noise_level: 0.0
  differential_privacy_clipping_threshold: 0
}

This shows the exact configuration Meta used to train Llama 2's tokenizer, including all the preprocessing options, vocabulary settings, and special token definitions that I discuss in the video.

I think my summary for SentencePiece from all this is, number one, I think that there's a lot of historical baggage in SentencePiece. A lot of concepts that I think are slightly confusing and I think potentially contain footguns, like this concept of a sentence and its maximum length and stuff like that. 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. And unfortunately, I have to say it's not very well documented. So it took me a lot of time working with this myself, and just visualizing things and trying to really understand what is happening here because documentation unfortunately is, in my opinion, not super amazing. But it is a very nice repo that is available to you if you'd like to train your own tokenizer right now.

Special Tokens and Extensions

Understanding Special Tokens

The next topic I would like to turn to is that of special tokens. So, in addition to tokens that are coming from, you know, raw bytes and the BPE merges, we can insert all kinds of tokens that we are going to use to delimit different parts of the data or introduce to create a special structure of the token streams. So, in if you look at this encoder object from OpenAI's GPT-2 right here, we mentioned this is very similar to our vocab. You'll notice that the length of this is 50,257.

Where are the tokens? As I mentioned, there are 256 raw byte tokens. And then OpenAI actually did 50,000 merges. So those become the other tokens. But this would have been 50,256. So what is the 57th token? And there is basically one special token. And that one special token, you can see, is called end of text. So this is a special token and it's the very last token. And this token is used to delimit documents in the training set.

# Find the token with the highest ID (should be the special token)
max_id = max(encoder.values())
special_token = [k for k, v in encoder.items() if v == max_id][0]
print(f"Special token: '{special_token}' with ID: {max_id}")

So, when we're creating the training data, we have all these documents and we tokenize them and we get a stream of tokens. Those tokens only range from 0 to 50,256. And then in between those documents, we put special end of text token. And we insert that token in between documents.

And 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. That said, 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 what came before this token is not actually informative to what comes next. But we are expecting the language model to just like learn this, but we are giving it this special sort of delimiter of these documents.

We can go here to Tiktokenizer, and this is the GPT-2 tokenizer. Our code that we've been playing with before. So we can add here, right? Hello world, how are you? And we're getting different tokens.

But now you can see what happens if I put end of text. You see how until I finished it, these are all different tokens. End of text, still different tokens. And now when I finish it, suddenly we get token 50,256.

pasted_image

And the reason this works is because 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. We did not see these special instructions for handling special tokens in the encoder.py. It's absent there.

But if you go to the tiktoken library, which is implemented in Rust, you will 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 it 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.

// From tiktoken/src/lib.rs - Special Token Handling
impl CoreBPE {
    fn new_internal(
        encoder: HashMap<Vec<u8>, Rank>,
        special_tokens_encoder: HashMap<String, Rank>,  // Special tokens mapping
        pattern: &str,
    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
        let regex = Regex::new(pattern)?;

        // This is the key part Andrej mentions - creating a special regex
        // that matches all special tokens
        let special_regex = {
            let parts = special_tokens_encoder
                .keys()
                .map(|s| fancy_regex::escape(s))  // Escape special token strings
                .collect::<Vec<_>>();
            Regex::new(&parts.join("|"))?       // Join with OR operator
        };

        let decoder: HashMap<Rank, Vec<u8>> =
            encoder.iter().map(|(k, v)| (*v, k.clone())).collect();

        let special_tokens_decoder: HashMap<Rank, Vec<u8>> = 
            special_tokens_encoder
                .iter()
                .map(|(k, v)| (*v, k.as_bytes().to_vec()))
                .collect();

        ...

        Ok(Self {
            encoder,
            special_tokens_encoder,    // Store special tokens
            decoder,
            special_tokens_decoder,    // Store special token decoder
            regex_tls: (0..MAX_NUM_THREADS).map(|_| regex.clone()).collect(),
            special_regex_tls: (0..MAX_NUM_THREADS)
                .map(|_| special_regex.clone())  // Thread-local special regex
                .collect(),
            sorted_token_bytes,
        })
    }

    pub fn encode_with_special_tokens(&self, text: &str) -> Vec<Rank> {
        let allowed_special = self.special_tokens();
        self.encode(text, &allowed_special).unwrap().0
    }
}

Key points from the implementation:

  • Special regex creation: Creates a separate regex that matches all special tokens by escaping them and joining with | (OR)
  • Separate handling: Special tokens bypass normal BPE processing entirely
  • Thread-local storage: Uses thread-local regex instances for performance
  • Direct token swapping: When special tokens are found, they're directly mapped to their token IDs

So these special tokens are used pervasively, not just in basically base language modeling of predicting the next token in a sequence, but especially when it gets to later to the fine-tuning stage and all the chat GPT sort of aspects of it. Because we don't just want to delimit documents, we want to delimit entire conversations between an assistant and a user. So if I refresh this Tiktokenizer page, the default example that they have here is using not sort of base model encoders, but fine-tuned model sort of tokenizers.

So for example, using the GPT-3.5 Turbo scheme, these here are all special tokens. I am start, I am end, etc. This is short for imaginary monologue underscore start, by the way. But you can see here that there's a sort of start and end of every single message, and there can be many other tokens, lots of tokens, in use to delimit these conversations and kind of keep track of the flow of the messages here.

pasted_image

Now let's go back to the tiktoken library. And here when you scroll to the bottom, they talk about how you can extend tiktoken and how you can create basically, you can fork the CL100K base tokenizers in GPT-4. And for example, you can extend it by adding more special tokens. And these are totally up to you. You can come up with any arbitrary tokens and add them with the new ID afterwards. And the tiktoken library will correctly swap them out when it sees this in the strings.

# Extending tiktoken with custom special tokens
import tiktoken

cl100k_base = tiktoken.get_encoding("cl100k_base")

# In production, load the arguments directly instead of accessing private attributes
# See openai_public.py for examples of arguments for specific encodings
enc = tiktoken.Encoding(
    # If you're changing the set of special tokens, make sure to use a different name
    # It should be clear from the name what behaviour to expect.
    name="cl100k_im",
    pat_str=cl100k_base._pat_str,
    mergeable_ranks=cl100k_base._mergeable_ranks,
    special_tokens={
        **cl100k_base._special_tokens,
        "<|im_start|>": 100264,
        "<|im_end|>": 100265,
    }
)
cl100k_base._special_tokens
enc.encode('<|im_start|>Hello world<|im_end|>', allowed_special={'<|im_start|>', '<|im_end|>'})
enc._special_tokens

Now, we can also go back to this file which we looked at previously. And I mentioned that the GPT-2 in tiktoken, openai_public.py, we have the vocabulary, we have the pattern for splitting, and then here we are registering the single special token in GPT-2, which was the end of text token, and we saw that it has this ID.

In GPT-4, when they defined this here, you see that the pattern has changed as we've discussed, but also the special tokens have changed in this tokenizer. So we of course have the end of text, just like in GPT-2, but we also see three, sorry, four additional tokens here. FIM prefix, middle, and suffix. What is FIM? FIM is short for fill in the middle. And if you'd like to learn more about this idea, it comes from this paper.

# GPT-2 Special Tokens (from openai_public.py)
def gpt2():
    # ... other tokenizer configuration ...
    return {
        "name": "gpt2",
        "explicit_n_vocab": 50257,
        "pat_str": r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}++| ?\p{N}++| ?[^\s\p{L}\p{N}]++|\s++$|\s+(?!\S)|\s""",
        "mergeable_ranks": mergeable_ranks,
        "special_tokens": {"<|endoftext|>": 50256},  # Only one special token
    }

# GPT-4 Special Tokens (cl100k_base from openai_public.py) 
def cl100k_base():
    # ... other tokenizer configuration ...
    special_tokens = {
        "<|endoftext|>": 100257,    # Same as GPT-2 but different ID
        "<|fim_prefix|>": 100258,   # Fill-in-the-middle: prefix
        "<|fim_middle|>": 100259,   # Fill-in-the-middle: middle  
        "<|fim_suffix|>": 100260,   # Fill-in-the-middle: suffix
        "<|endofprompt|>": 100276   # End of prompt marker
    }
    return {
        "name": "cl100k_base",
        "explicit_n_vocab": 100277,
        "pat_str": r"""(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{2,}|[^\r\n\p{L}\p{N}]?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+""",
        "mergeable_ranks": mergeable_ranks,
        "special_tokens": special_tokens,
    }

Key differences:

  • GPT-2: Only has <|endoftext|> (ID: 50256)
  • GPT-4: Has 5 special tokens including FIM (Fill-in-the-Middle) tokens for code completion tasks
  • Vocabulary growth: From 50,257 tokens (GPT-2) to 100,277 tokens (GPT-4)

And I'm not going to go into detail in this video, it's beyond this video. And then there's one additional sort of token here. So that's that encoding as well. The FIM (Fill-in-the-Middle) paper: Efficient Training of Language Models to Fill in the Middle

So it's very common, basically to train a language model, and then if you'd like, you can add special tokens. Now, 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. Because you are basically adding an integer and you want to make sure that for example, your embedding matrix for the vocabulary tokens has to be extended by adding a row. And typically this row would be initialized with small random numbers or something like that, because we need to have a vector that now stands for that token.

In addition to that, you have to go to the final layer of the transformer and you have to make sure that that projection at the very end into the classifier is 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.

Vocabulary Size Considerations

Okay, let me now switch gears again as we're starting to slowly wrap up here. I want to revisit this issue in a bit more detail of how we should set the vocab size or some of the considerations around it. So for this, I'd like to go back to the model architecture that we developed in the last video when we built the GPT from scratch.

So this here was the file that we built in the previous video, and we defined the transformer model. And let's specifically look at vocab size and where it appears in this file. So here we define the vocab size. At this time it was 65 or something like that, extremely small number. So this will grow much larger.

You'll see that vocab size doesn't come up too much in most of these layers. The only place that it comes up to is in exactly these two places here. So when we define the language model, there's the token embedding table, which is this two-dimensional array where the vocab size is basically the number of rows. And each vocabulary element, each token, has a vector that we're going to train using backpropagation. That vector is of size n_embed, which is the number of channels in the transformer. And basically as vocab size increases, this embedding table, as I mentioned earlier, is going to also grow. We're going to be adding rows.

# From gpt.py - Vocabulary size definition
vocab_size = len(chars)  # Based on unique characters in text

File: gpt.py - Character-level vocabulary size

This shows how vocab_size is initially set based on the number of unique characters in the training text (e.g., 65 for Shakespeare dataset).

# From gpt.py - Token embedding table
class GPTLanguageModel(nn.Module):
    def __init__(self):
        super().__init__()
        # Token embedding table - vocab_size rows, n_embed columns
        self.token_embedding_table = nn.Embedding(vocab_size, n_embed)
        self.position_embedding_table = nn.Embedding(block_size, n_embed)
        # ... other layers ...
        # Final linear layer - projects to vocab_size logits
        self.lm_head = nn.Linear(n_embed, vocab_size)

File: gpt.py - Model architecture

This shows the two places where vocab_size matters:

  1. Token embedding: Maps token IDs to vectors (vocab_size → n_embed)
  2. Language model head: Maps final hidden states to logits (n_embed → vocab_size)

In addition to that, at the end of the transformer, there's this lm_head layer, which is a linear layer. And you'll notice that that layer is used at the very end to produce the logits, which become the probabilities for the next token in the sequence. And so intuitively, we're trying to produce a probability for every single token that might come next at every point in time of that transformer. And 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 here in this linear layer for this final layer in the transformer.

# From gpt.py - Forward pass using lm_head
def forward(self, idx, targets=None):
    B, T = idx.shape
    
    # Token and position embeddings
    tok_emb = self.token_embedding_table(idx)  # (B,T,C)
    pos_emb = self.position_embedding_table(torch.arange(T, device=device))  # (T,C)
    x = tok_emb + pos_emb  # (B,T,C)
    
    # Transformer blocks
    x = self.blocks(x)  # (B,T,C)
    x = self.ln_f(x)  # (B,T,C)
    
    # Final projection to vocabulary
    logits = self.lm_head(x)  # (B,T,vocab_size)
    
    # ... loss calculation ...

File: gpt.py - Forward pass

The lm_head layer produces logits for each token position, with one probability for every token in the vocabulary. As vocab_size grows, this final computation becomes more expensive.

So why can't vocab size be infinite? Why can't it grow to infinity? Well, number one, your token embedding table is going to grow. Your linear layer is going to grow. So we're going to be doing a lot more computation here because this lm_head layer will become more computationally expensive. Number two, because we have more parameters, we could be worried that we are going to be under-training some of these parameters.

So intuitively, 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 under-trained as a result because they just don't come up too often and don't participate in the forward-backward pass.

In addition to that, as your vocab size grows, you're going to start shrinking your sequences a lot, right? And that's really nice because that means that we're going to be attending to more and more text. So that's nice. But also you might be worried that too large of chunks are being squished into single tokens. And so the model just doesn't have as much sort of time to think per sort of some number of characters in a text, or you can think about it that way, right? So 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.

And so these are some of the considerations you're thinking about when you're designing the vocab size. As I mentioned, 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.

How can I increase vocab size?

And the next consideration I want to briefly talk about is what if we want to take a pre-trained model and we want to extend the vocab size? And this is done fairly commonly actually. So for example, when you're doing fine-tuning with ChatGPT, 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 the user and the 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.

So if you want to be adding a token, that's totally possible, right? All we have to do is we have to resize this embedding, so we have to add rows. We would initialize these parameters from scratch, which would be small random numbers. And then we have to extend the weight inside this linear. So we have to start making dot products with the associated parameters as well to basically calculate the probabilities for these new tokens.

So both of these are just the resizing operation. It's a very mild model surgery and can be done fairly easily. And 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. And so you can freeze arbitrary parts of it or you can train arbitrary parts of it and that's totally up to you. So basically minor surgery required if you'd like to introduce new tokens.

Advanced Topics

Multi-modal Tokenization

And finally, I'd like to mention that actually 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. So just to give you the sense of the design space, but this could be an entire video just by itself. I think recently 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. So be it images, videos, audio, etc. And how do you feed in all these modalities and potentially predict these modalities from a transformer? Do you have to change the architecture in some fundamental way? And I think 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 identical in an identical manner.

So here for example, there was an early paper that has a nice graphic for how you can take an image and you can truncate it into integers. And these, sometimes, so these would basically become the tokens of images as an example. And these tokens can be hard tokens where you force them to be integers. They can also be soft tokens where you sort of don't require these to be discrete, but you do force these representations to go through a bottleneck, like in autoencoders.

pasted_image

Also in this technical report that came out from OpenAI Sora, which I think really blew the mind of many people and inspired a lot of people in terms of what's possible. They have a graphic here and they talk briefly about how LLMs have text tokens, Sora has visual patches. So again, they came up with a way to truncate videos into basically tokens with their own vocabularies. And then you can either process discrete tokens, say with autoregressive models, or even soft tokens with diffusion models. And all of that is sort of being actively worked on and designed on and is beyond the scope of this video, but just something I wanted to mention briefly.

pasted_image

Gist Tokens and Prompt Compression

This is a paper on learning to compress prompts with what they call gist tokens.

And the rough idea is, suppose that you're using language models in a setting that requires very long prompts. Well, these long prompts just slow everything down because you have to encode them and then you have to use them and then you're attending over them and it's just heavy to have very large prompts. So instead, what they do here in this paper is they introduce new tokens and, imagine basically having a few new tokens, you put them in a sequence, and then you train the model by distillation.

pasted_image

So you are keeping the entire model frozen and you're only training the representations of the new tokens, their embeddings. And you're optimizing over the new tokens such that the behavior of the language model is identical to the model that has a very long prompt that works for you. And 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 like a stand-in for that very long prompt and have an almost identical performance. And so this is one technique in 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. So that's just one example, but this could again be like an entire video, but just to give you a sense that there's a whole design space here that is potentially worth exploring in the future.

Supporting Quotes from the Gist Tokens Paper:

Regarding training gist tokens for categories of prompts rather than individual prompts is supported by these key quotes:

1. Meta-learning approach for generalization:

"But where prefix-tuning requires learning prefixes via gradient descent for each task, gisting adopts a meta-learning approach, where we simply predict the gist prefixes zero-shot given only the prompt, allowing for generalization to unseen instructions without any additional training."

2. Training across a distribution of tasks:

"However, we differ from this prior work in that we are not interested in distilling just a single task, but in amortizing the cost of distillation across a distribution of tasks T. That is, given a task t ∼ T, instead of obtaining the distilled model via gradient descent, we use G to simply predict the gist tokens (≈ parameters) of the distilled model"

3. Single model handles multiple task types:

"A dataset with a large variety of tasks (prompts) is crucial to learn gist models that can generalize. To obtain the largest possible set of tasks for instruction finetuning, we create a dataset called Alpaca+, which combines... 104,664 unique tasks t"

4. Reusable across similar prompts:

"Since gist tokens are much shorter than the full prompt, gisting allows arbitrary prompts to be compressed, cached, and reused for compute efficiency."

Key insight: The paper trains one model that learns to compress any prompt into gist tokens, rather than training separate tokens for each individual prompt. The gist tokens are predicted dynamically based on the input prompt content.

Common Tokenization Issues and Pitfalls

Why LLMs Struggle with Certain Tasks

Okay, now that we have gone 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.

So first of all, why can't my LLM spell words very well or do other spell-related tasks? So fundamentally, this is because, as we saw, these characters are chunked up into tokens, and some of these tokens are actually fairly long. So as an example, I went to the GPT-4 vocabulary and I looked at one of the longer tokens. So .DefaultCellStyle turns out to be a single individual token. So that's a lot of characters for a single token.

pasted_image

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 I asked, how many letters L are there in the word .DefaultCellStyle? And of course, my prompt is intentionally done that way. And you see how .DefaultCellStyle will be a single token. So this is what the model sees. So my suspicion is that it wouldn't be very good at this, and indeed it is not. It doesn't actually know how many L's are in there. It thinks there are three, and actually there are four. So I'm not getting this wrong myself. So that didn't go extremely well.

pasted_image

Let's look at another kind of character-level task. So for example, here I asked GPT-4 to reverse the string .DefaultCellStyle. And it tried to use a code interpreter, and I stopped it and I said, just do it, just try it. And it gave me jumble. So it doesn't actually really know how to reverse this string going from right to left. So it gave a wrong result.

So again, like working with this, working on the hypothesis that maybe this is due to tokenization, I tried a different approach. I said, okay, let's reverse the exact same string, but take the following approach. Step one, just print out every single character separated by spaces, and then as a step two, reverse that list. And it again tried to use a tool, but when I stopped it, it first produced all the characters, and that was actually correct. And then it reversed them, and that was correct once it had this. So somehow it can't reverse it directly, but when you go just first, you know, listing it out in order, it can do that somehow. And then it can, 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. So that is kind of interesting.

pasted_image

So let's continue now. Why are LLMs worse at non-English languages? And I briefly covered this already, but basically, 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. And so here, for example, "Hello how are you?" is five tokens, and its translation is 15 tokens. So this is a three times blow-up. And so, for example, "annyeonghaseyo" 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. It is a typical greeting, like "hello", and that ends up being three tokens, whereas our "hello" is a single token. And so basically everything is a lot more bloated and diffused, and this is I think partly the reason that the model works worse on other languages.

pasted_image

Why is LLM bad at simple arithmetic? That has to do with the tokenization of numbers. And so, you'll notice that, for example, addition is very sort of like, there's an algorithm that is like 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.

pasted_image

But, these numbers are represented completely arbitrarily based on whatever happened to merge or not merge during the tokenization process. There's an entire blog post about this that I think is quite good, "Integer tokenization is insane". And this person basically systematically explores the tokenization of numbers in, I believe this is GPT-2. And so they notice that, for example, for four-digit numbers, you can take a look at whether it is a single token or whether it is two tokens that is a 1-3 or a 2-2 or a 3-1 combination. And so all the different numbers are all the different combinations. And you can imagine that this is all completely arbitrarily so. And the model, unfortunately, sometimes sees four, 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. And so that's why, for example, we saw that Meta, when they trained the Llama 2 algorithm and they used SentencePiece, they made sure to split up all the digits as an example for Llama 2. And this is partly to improve simple arithmetic kind of performance.

pasted_image

And finally, why is GPT-2 not as good in Python? Again, this is partly a modeling issue on in the architecture and the dataset and the strength of the model, but it's also partly tokenization because 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. So that's almost like a tokenization bug for GPT-2, and that was later fixed with GPT-4.

The Infamous Edge Cases

Okay, so here's another fun one. My LLM abruptly halts when it sees the string <|endoftext|>. So here's a very strange behavior. Print the string <|endoftext|>. That's what I told GPT-4. And it says, "Could you please specify the string?" And I'm telling it, "Give me <|endoftext|>." And it seems like there's an issue. It's not seeing <|endoftext|>. And then I give it <|endoftext|> is the string, and then here's the string, and then it just doesn't print it.

pasted_image

So obviously something is breaking here with respect to the handling of the special token. And I didn'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 <|endoftext|> as like individual sort of pieces of it without the special token handling logic. And so it might be that someone when they're calling .encode, they are passing in the allowed_special and they are allowing <|endoftext|> as a special character in the user prompt. But the user prompt, of course, is a sort of attacker-controlled text. So you would hope that they don't really parse or use special tokens from that kind of input. But it appears that there's something definitely going wrong here. And 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.

Okay, so this next one is another fun one. The trailing whitespace issue. So if you come to Playground and we come here to gpt-3.5-turbo-instruct. So this is not a chat model, this is a completion model. So think of it more like, it's a lot more closer to a base model. It does completion. It will continue the token sequence.

So here's a tagline for an ice cream shop, and we want to continue the sequence. And so we can submit and get a bunch of tokens. Okay, no problem. But now, suppose I do this, but instead of pressing submit here, I do, "Here's a tagline for an ice cream shop space." So I have a space here before I click submit. We get a warning. "Your text ends in a trailing space, which causes worse performance due to how the API splits text into tokens."

pasted_image

So what's happening here? It still gave us a completion here, but let's take a look at what's happening. So here's a tagline for an ice cream shop. And then what does this look like in the actual training data? Suppose you found the completion in the training document somewhere on the internet and the LLM trained on this data. So maybe it's something like, "Oh yeah." Maybe that's the completion.

pasted_image

terrible tagline. But notice here that when I create O, you see that because the space character is always a prefix to these tokens in GPT. So it's not an O token, it's a space O token. The space is part of the O, and together they are token 8840. That's space O.

pasted_image

So what's happening here is that when I just have it like this and I let it complete the next token, it can sample the space O token. But instead, if I have this and I add my space, then what I'm doing here when I encode this string is I have basically, here's a tagline for an ice cream shop, and this space at the very end becomes a token 220.

pasted_image

And so we've added token 220, and this token otherwise would be part of the tagline because if there actually is a tagline here, so space O is a token. And so this is throwing 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 distribution and now arbitrary bad things happen. And it's just a very rare example for it to see something like that. And that's why we get the warning.

So the fundamental issue here is, of course, that the LLM is on top of these tokens, and these tokens are text chunks, they're not characters in the way you and I would think of them. They are, these are the atoms of what the LLM is seeing, and there's a bunch of weird stuff that comes out of it. Let's go back to our default cell style. I bet you that the model has never in its training set seen default cell sty without le in there.

pasted_image

It's always seen this as a single group because this is some kind of a function in, I'm guess, I don't actually know what this is part of, it's some kind of API. But I bet you that it's never seen this combination of tokens in its training data because, or I think it would be extremely rare.

So I took this and I copy-pasted it here, and I had, I tried to complete from it, and it immediately gave me a big error. And it said, the model predicted a completion that begins with a stop sequence, resulting in no output. Consider adjusting your prompt or stop sequences. So what happens here when I click submit is that immediately the model emitted an sort of like end of text token, I think, or something like that. It basically predicted the stop sequence immediately, so it had no completion. And so this is where I'm getting a warning again because we're off the data distribution and the model is just predicting, it's totally arbitrary things. It's just really confused, basically. This is, this is giving it brain damage. It's never seen this before. It's shocked and it's predicting end of text or something.

pasted_image

I tried it again here, and it, in this case, it completed it, but then for some reason, this request may violate our usage policies. This was flagged. Basically something just like goes wrong, and this is like jank. You can just feel the jank because the model is like extremely unhappy with just this, and it doesn't know how to complete it because it's never occurred in the training set. In the training set, it always appears like this and becomes a single token.

pasted_image

So these kinds of issues where tokens are either you sort of like complete the first character of the next token, or you are sort of, you have long tokens that you then have just some of the characters of, all of these are kind of like issues with partial tokens, is how I would describe it. And if you actually dig into the tiktoken repository, you go to the Rust code and search for unstable, and you'll see encode unstable native, unstable tokens, and a lot of like special case handling. None of this stuff about unstable tokens is documented anywhere, but there's a ton of code dealing with unstable tokens. And unstable tokens is exactly kind of like what I'm describing here.

What you would like out of a completion API is something a lot more fancy. Like if we're putting in default cell sty, 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 actually trying to append, we're trying to consider lots of tokens that if we were, or I guess like, we're trying to search over characters that if we re-tokenized would be of high probability, if that makes sense. So that we can actually add a single individual character instead of just like adding the next full token that comes after this partial token list. So this is very tricky to describe, and I invite you to maybe like look through this. It ends up being an extremely gnarly and hairy kind of topic. It, and it comes from tokenization fundamentally. So maybe I can even spend an entire video talking about unstable tokens sometime in the future.

Okay, and I'm really saving the best for last. My favorite one by far is this SolidGoldMagikarp.

It's just, okay, so this comes from this blog post, SolidGoldMagikarp. And this is internet famous now for those of us in LLMs. And basically what this person was doing is this person went to the token embedding table and clustered the tokens based on their embedding representation. And this person noticed that there's a cluster of tokens that look really strange. So there's a cluster here, petertodd, StreamerBot, SolidGoldMagikarp, signupmessage, like really weird tokens in basically in this embedding cluster.

pasted_image

And so where are these tokens and where do they even come from? Like what is SolidGoldMagikarp? It makes no sense. And then they found a bunch of these tokens. And then they noticed that actually the plot thickens here because if you ask the model about these tokens, like you ask it some very benign question like, please can you repeat back to me the string SolidGoldMagikarp? Then you get a variety of basically totally broken LLM behavior. So either you get evasion, so, I'm sorry, I can't hear you, or you get a bunch of hallucinations as a response. You can even get back like insults. So you ask it about StreamerBot and the model actually just calls you names. Or it kind of comes up with like weird humor. But you're actually breaking the model by asking about these very simple strings like attRot and SolidGoldMagikarp.

pasted_image

So like, what the hell is happening? And there's a variety of here documented behaviors. There's a bunch of tokens, not just SolidGoldMagikarp that have that kind of behavior. And so basically there's a bunch of like 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 sort of ones that violate typical safety guidelines and the alignment of the model, like it's swearing back at you. So what is happening here and how can this possibly be true?

Well, this again comes down to tokenization. So what's happening here is that SolidGoldMagikarp, if you actually dig into it, is a Reddit user. So there's a u/SolidGoldMagikarp. And probably what happened here, even though I don't know that it has been like really definitively explored, but what is thought to have happened is that the tokenization dataset was very different from the training dataset for the actual language model. So in the tokenization dataset, there was a ton of Reddit data potentially, where the user SolidGoldMagikarp was mentioned in the text. Because SolidGoldMagikarp was a very common sort of person who was posting a lot, this would be a string that occurs many times in a tokenization dataset. Because it occurs many times in the tokenization dataset, these tokens would end up getting merged into a single individual token for that single Reddit user, SolidGoldMagikarp. So they would have a dedicated token in the vocabulary of, what is this, 50,000 tokens in GPT-2, that is devoted to that Reddit user.

And then what happens is the tokenization dataset has those strings, but then later when you train the model, the language model itself, this data from Reddit was not present. And so therefore, in the entire training set for the language model, SolidGoldMagikarp never occurs. That token never appears in the training set for the actual language model later. So this 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. It's completely untrained. It's kind of like unallocated memory in a typical binary program written in C or something like that. So it's unallocated memory. And then at test time, if you evoke 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 is completely undefined, never before seen in the training behavior. And so any of these kind of like weird tokens would evoke this behavior because fundamentally the model is out of sample, out of distribution.

Token Efficiency

Okay, and the very last thing I wanted to just briefly mention and point out, although I think a lot of people are quite aware of this, is that different kinds of formats and different representations and different languages and so on might be more or less efficient with GPT tokenizers, or any tokenizer for any other LLM for that matter. So for example, JSON is actually really dense in tokens, and YAML is a lot more efficient in tokens.

pasted_image

So for example, this, our, these are the same in JSON and in YAML. The JSON is 116 and the YAML is 99. So quite a bit of an improvement. And so in the token economy where you 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 when you have to, so prefer to use YAML over JSONs. And in general, kind of the tokenization density is something that you have to sort of 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 and so on.

pasted_image

Conclusion and Final Recommendations

Okay, so that concludes my fairly long video on tokenization. I know it's dry, I know it's annoying, I know it's irritating. I personally really dislike this stage. But what I do have to say at this point is don't brush it off. There's a lot of footguns, sharp edges here, security issues, AI safety issues, as we saw with plugging in unallocated memory into language models. So, it's worth understanding this stage. That said, I will say that eternal glory goes to anyone who can get rid of it. I showed you one possible paper that tried to do that, and I think, I hope a lot more can follow over time.

So those are the basics of the byte-pair encoding algorithm and all its modern complexities. We've covered:

  • How tokenization evolved from simple character-level encoding to sophisticated BPE algorithms
  • The crucial role of UTF-8 encoding and why we work with bytes
  • How GPT-2 and GPT-4 use regex patterns to enforce merge boundaries
  • The differences between tiktoken and SentencePiece approaches
  • Special tokens and their role in structuring LLM inputs
  • Why tokenization causes so many weird LLM behaviors

Key Takeaways:

  1. Tokenization is foundational - It's the bridge between human text and neural networks. Every quirk in tokenization cascades into model behavior.

  2. Different tokenizers for different purposes - GPT models optimize for English and code, while models like Llama try to be more multilingual. Choose wisely based on your use case.

  3. Token efficiency matters - In production systems, you're paying per token. Understanding tokenization helps you optimize prompts and choose better data formats (YAML over JSON!).

  4. Edge cases are everywhere - From trailing spaces to partial tokens to unallocated embeddings, tokenization is full of sharp edges that can break your applications.

  5. The dream lives on - While we're stuck with tokenization for now, the dream of tokenization-free language models that work directly on bytes remains an active area of research.

I hope this deep dive has given you the knowledge to debug those weird LLM behaviors, optimize your token usage, and maybe even build your own tokenizer. Remember: when your LLM does something bizarre, it's probably tokenization.

Good luck, and may your tokens always be efficiently encoded! 🚀

Note