Set the boundaries first: the regex before BPE
Part 28 trained 20 BPE merges on the whole Shakespeare text. The first merged e with a space; later rules joined t to a space, a comma to a space, and a colon to a newline.
All are valid choices for BPE: the most frequent adjacent pair wins.
But a word followed by a period, an exclamation mark, or a newline can acquire different representations. Vocabulary entries are spent memorizing these combinations. GPT-2 adds a constraint before BPE: split text into character-category chunks, then prohibit merges across chunk boundaries. A single leading space is an intentional exception.
Text → regex chunks → each chunk's UTF-8 bytes → independent BPE → concatenate IDsA chunk is not a token. The regex determines where merging is forbidden; learned rules determine how many tokens remain inside each chunk.
Read GPT-2’s regex branch by branch
Historical encoder.py imports the third-party regex package as re:
import regex as re
pattern = r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
re.findall(pattern, "Hello've world123 how's are you!!!?")# ['Hello', "'ve", ' world', '123', ' how', "'s", ' are', ' you', '!!!?']The standard re module does not support \p{L} or \p{N}. L means Unicode letters, including Hanzi, not just A–Z; N means numbers. \s matches whitespace and \S non-whitespace.
The alternatives separated by | are tried in order at the current position as matching advances through the input. They are not independent results to concatenate.
| Branch | Purpose | Example |
|---|---|---|
's|'t|'re|'ve|'m|'ll|'d | Extract lowercase contraction suffixes first | how + 's |
?\p{L}+ | Letters, with at most one ordinary leading space | ' world' |
?\p{N}+ | An unbounded group of numbers, optionally with one space | ' 1234567' |
?[^\s\p{L}\p{N}]+ | Symbols excluding whitespace, letters, and numbers | '!!!?' |
\s+(?!\S) | Whitespace whose end is not immediately followed by non-whitespace | The first three indentation spaces |
\s+ | Remaining whitespace | Whitespace not consumed earlier |
The space in ? is literal; ? makes that one space optional. It does not mean arbitrary whitespace, so this prefix does not attach a tab to a word.
Why four indentation spaces become three plus one
Writing spaces as ·:
Input: ····ifChunks: ['···', '·if']At the first space, the earlier alternatives cannot skip four spaces to reach a letter. \s+ greedily consumes all four, leaving i.
The negative lookahead (?!\S) fails because i is non-whitespace.
The quantifier backtracks by one space. Now the next character is whitespace, so the assertion succeeds.
Matching resumes at the fourth space, where ?\p{L}+ consumes ' if'.
🔑
(?!\S)checks the following position without consuming it. It leaves one space for the next word. At a line ending, a whitespace run can be consumed in full.
This also explains why GPT-2’s one-token-per-indentation-space behavior does not come directly from the regex. The first three spaces are already in the same chunk; GPT-2’s learned vocabulary simply does not merge them.
Chinese is not chunked one character at a time
re.findall(pattern, "分词是大模型许多怪现象的根源。")# ['分词是大模型许多怪现象的根源', '。']The Hanzi all belong to \p{L} and have no intervening spaces. The regex does not segment Chinese words or individual characters.
After conversion to UTF-8, BPE may split one character into several fragments or merge byte fragments across character boundaries.
Character-category boundaries, character boundaries, and token boundaries are distinct.
cl100k_base changes the boundary rules
The video compares GPT-2 with the cl100k_base regex used for GPT-4:
pattern = r"""(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\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+"""This is the video’s spelling. The local tiktoken 0.14.0 _pat_str uses optimizations such as possessive quantifiers. The comparisons below concern chunking behavior on these inputs, rather than textual equality between regex strings.
Four changes:
(?i:...)makes contractions case-insensitive. GPT-2 splitsHOW'SintoHOW,',S; here it becomesHOW,'S.\p{N}{1,3}limits number groups to three digits and omits the optional leading space.1234567first becomes123,456,7.- Letters may be preceded by one character excluding newlines, letters, and numbers. This allows
.appendand(yas chunks. - Punctuation may retain trailing newlines, with another branch for whitespace followed by newlines.
)\n\n\ncan stay together.
The last two affect code:
text = "x = self.append(y)\n\n\nreturn x"| Tokenizer | Final token contents (· marks a space) | Count |
|---|---|---|
| gpt2 | x, ·=, ·self, ., append, (, y, ), \n\n, \n, return, ·x | 12 |
| cl100k_base | x, ·=, ·self, .append, (y, )\n\n\n, return, ·x | 8 |
Being allowed in one chunk is only a prerequisite. The vocabulary must also contain the merges needed to make it one token.
Cleaner digit groups need not reduce the count
GPT-2 treats 1234567 as one regex chunk, then learned rules split it into 123 | 45 | 67.
cl100k_base first imposes 123 | 456 | 7 boundaries; each happens to be one token. Both use three tokens with different digit groupings.
127 + 677 = 804 also takes seven tokens in both:
gpt2: 127 | ·+ | ·6 | 77 | ·= | ·8 | 04cl100k: 127 | ·+ | · | 677 | ·= | · | 804The count alone does not say which chunks the model sees.
Multi-space tokens come from training
Both regex patterns split " hello world!!!" identically: three spaces, hello with a leading space, world with a leading space, and !!!.
Count vocabulary entries consisting of at least two spaces:
| Multi-space entries | Final IDs for this input | |
|---|---|---|
| gpt2 | 0 | [220, 220, 220, 23748, 995, 10185] |
| cl100k_base | 85 | [262, 24748, 1917, 12340] |
The count of 85 applies to the local cl100k_base vocabulary. GPT-2 emits three separate 220 IDs; cl100k_base merges them into 262.
Same chunks, different learned merges.
Training: count inside chunks, choose globally
Part 28’s get_stats created a dictionary each time. Let it accumulate into a supplied dictionary:
def get_stats(ids, counts=None): counts = {} if counts is None else counts for pair in zip(ids, ids[1:]): counts[pair] = counts.get(pair, 0) + 1 return countsUse None as the default, not mutable {}, which would retain shared state across calls.
The main change replaces one ID list with one list per chunk:
chunks = re.findall(pattern, text)ids = [list(chunk.encode("utf-8")) for chunk in chunks]merges = {}vocab = {i: bytes([i]) for i in range(256)}
for i in range(vocab_size - 256): stats = {} for chunk_ids in ids: get_stats(chunk_ids, stats) if not stats: break pair = max(stats, key=stats.get) idx = 256 + i ids = [merge(chunk_ids, pair, idx) for chunk_ids in ids] merges[pair] = idx vocab[idx] = vocab[pair[0]] + vocab[pair[1]]merge still replaces every non-overlapping occurrence from left to right. If counts tie, max chooses the first inserted dictionary key; corpus traversal order can therefore affect ties.
🔑 There is not a separate trained table for each chunk. Count pairs inside each chunk into a shared
statsdictionary, choose one pair globally, then replace it within every chunk. The vocabulary and rules are shared; a chunk’s last symbol never forms a candidate with the next chunk’s first symbol.
Fifteen bytes, three merges
the cat the hat becomes four chunks: the, cat, the, hat.
| Round | Globally selected pair | New ID / contents | IDs after merging each chunk |
|---|---|---|---|
| 1 | (116,104), th occurs twice | 256 = b'th' | [256,101] / [32,99,97,116] / [32,256,101] / [32,104,97,116] |
| 2 | (256,101), the occurs twice | 257 = b'the' | [257] / [32,99,97,116] / [32,257] / [32,104,97,116] |
| 3 | (97,116), at occurs twice | 258 = b'at' | [257] / [32,99,258] / [32,257] / [32,104,258] |
Fifteen bytes become nine tokens: compression 15 / 9 ≈ 1.67.
Without chunking, round three would select the plus a space, crossing the letter/whitespace boundary.
Same Shakespeare text, one boundary change
The local input.txt contains 1,115,394 bytes. With 20 merges in both runs:
| Regex chunks | Compression | First learned merges | |
|---|---|---|---|
| No chunking | 1 | 1.26 | e·, th, t·, s·, d·, ,· |
| GPT-2 regex | 297,833 | 1.22 | ·t, he, ·a, ou, ·s, ·m |
In the chunked run, the first pair (32,116) occurs 23,837 times and the second (104,101) 18,203 times.
Merge twelve joins their new symbols into ' the', with 8,684 occurrences at that round.
Trailing-space combinations disappear; leading-space combinations remain.
The slight compression loss is the cost of blocking cross-boundary merges. The benefit is the boundary constraint, not a promise of fewer tokens on every input. Frequency counts are rebuilt each training round; new input is encoded with fixed ranks, without those counts.
Compare boundaries and learned merges together
The diagram’s GPT-4 and GPT-4o labels refer to cl100k_base and o200k_base in the video and these comparisons, rather than specifying tokenizers for every current model.
cl100k_base has 100,256 ordinary entries. Its 100,277 figure is the ID-space size, including special IDs and gaps, not the number of ordinary merge rules.
o200k_base adds letter-case boundaries: HelloWorld getElementById takes four tokens under cl100k_base and six under o200k_base.
The Chinese sample instead drops from 19 to 14; GPT-2 needs 32.
A newer tokenizer need not shorten every input. Examine both boundaries and vocabulary.
When a result is unexpected:
- Print
findallchunks to locate the boundary that first prohibits a merge. - Inspect rules and ranks within each chunk: is the pair absent, or did an earlier rule consume its symbols?
- Inspect complete token bytes rather than assuming each byte fragment is a character.
The next article reads GPT-2’s encoder.py. Here, base IDs still equal raw byte values.
That implementation first turns bytes into reversible character aliases, then performs string BPE — which is where vocabulary characters such as Ġ, ä, and ½ come from.