Byte-Pair Encoding: the tokeniser you inherited
The one component of your model that nobody trained and everybody blames.
Download tokenization_from_scratch.py — everything below runs from it. Pure Python and NumPy.
On the attention page I said what actually separates AI engineers isn't research maths — it's tokenisation edge cases, KV-cache maths, eval design. This is the tokenisation page. I owed it to you.
The thing I wish someone had told me earlier: the tokeniser is not part of the model. It's a lookup table fitted once on somebody else's corpus, then frozen and shipped alongside the weights — you cannot swap it without retraining. And almost every baffling LLM behaviour you've filed a ticket about traces straight back to it.
A lossy codec you did not choose
You already ship codecs. You know what happens when one sits in the middle of a pipeline: everything downstream inherits its assumptions.
raw_bytes = open("photo.jpg", "rb").read()
# the JPEG encoder already decided what detail was worth keeping.
# your image pipeline doesn't get a vote. it gets the artefacts.
A tokeniser is that, for text — tuned to compress mostly English web text, and everything else pays for the mismatch:
ids = tokeniser.encode(prompt) # your string is gone now
out = model(ids) # the model has never seen a character
text = tokeniser.decode(out) # and back, through the same frozen table
The model lives entirely on the right-hand side of that first line. It has no access to your letters. It has integers.
| Term | Means | Programmer's analogy |
|---|---|---|
| Merge list | Ordered rules: glue this pair into one symbol | A migration file. Order is the schema. |
| Vocabulary | Symbol → integer id | A frozen dict, shipped with the weights |
| Token | One entry in that dict | A file handle. Opaque contents. |
| Pre-tokenisation | Where merges are forbidden to cross | Record boundaries before you compress |
It came from translation, not from language models
Worth getting right, because it's an interview question. BPE was a 1994 compression
algorithm. Sennrich, Haddow and Birch pointed it at words in 2015 to solve a machine-translation
problem: a fixed vocabulary means an unseen word becomes <unk>, and you
cannot translate <unk>. Their title says it — "Neural Machine Translation of
Rare Words with Subword Units".
The fix is the one you'd reach for: if you can't store every word, store frequent pieces and spell the rest out. Open vocabulary. GPT inherited it years later because it happened to be the right tool.
The one formula, which is a for loop
Read it as pipeline stages:
| Step | Data | What it does |
|---|---|---|
pre_tokenise | text → chunks | Split into units merges may not cross. Space stays glued to the word. |
to_symbols | chunk → tuple | Start from single bytes. 256 of them, so nothing is unencodable. |
count_pairs | corpus → Counter | Every adjacent pair, weighted by word frequency. |
merge_pair | most common pair | Glue it everywhere. One new symbol, one new id. |
| repeat | n times | n is your vocab-size budget minus 256. That's the only hyperparameter. |
That's the paper: Counter.most_common(1) in a loop. No gradient, no objective
function, no training in the sense you mean it.
Watch the vocabulary grow
The script trains on the toy corpus from the paper's own worked example, and prints every merge in order. Read it top to bottom: characters become fragments, fragments become stems, stems become whole words.
Don't take my word for it. Here's the actual output:
starting vocabulary: 11 characters -> ' deilnorstw'
merges, in the order they were learned:
merge 1: 'e' + 's' -> 'es' seen 9x, vocab 12
merge 2: 'es' + 't' -> 'est' seen 9x, vocab 13
merge 3: 'l' + 'o' -> 'lo' seen 7x, vocab 14
merge 4: 'lo' + 'w' -> 'low' seen 7x, vocab 15
merge 5: ' ' + 'low' -> ' low' seen 6x, vocab 16
merge 6: ' ' + 'n' -> ' n' seen 6x, vocab 17
merge 7: ' n' + 'e' -> ' ne' seen 6x, vocab 18
merge 8: ' ne' + 'w' -> ' new' seen 6x, vocab 19
merge 9: ' new' + 'est' -> ' newest' seen 6x, vocab 20 ← a whole word
merge 10: ' ' + 'w' -> ' w' seen 3x, vocab 21
merge 11: ' w' + 'i' -> ' wi' seen 3x, vocab 22
merge 12: ' wi' + 'd' -> ' wid' seen 3x, vocab 23 ✓
Now encode with those 12 rules. Frequency decides everything: how often a string appeared during tokeniser training is exactly how cheap it is for you at inference time, forever.
word tokens count
------------ ---------------------------------- -----
low _low 1
lower _low|e|r 3
newest _newest 1
widest _wid|est 2
lowest _low|est 2 ← never seen, still 2
slowness _|s|low|n|es|s 6
kubernetes _|k|u|b|e|r|n|e|t|es 10 ← falls apart
lowest never appeared in the corpus. It still costs 2 tokens, because '
low' and 'est' both did. That is the 2015 paper's entire contribution,
printed. kubernetes shares almost nothing with the corpus and degrades to
characters — correct, just expensive. Your domain jargon is kubernetes.
Why it cannot count the r's in strawberry
The famous one. Here is the honest mechanical explanation, with the byte-level tokeniser the script trains on a slightly bigger English corpus:
strawberry: _strawberry
ids: [361]
letters: 10 characters -> 1 token
the model is asked 'how many r in strawberry'. What it received:
[361]
One integer. There is no r in what the model received, the same way there's no
filename inside a file descriptor. Asking it to count letters is asking it to introspect a
symbol it only ever saw as an opaque id. Compare it to a task it's good at — reasoning about the
meaning of token 361 — and the asymmetry stops being mysterious.
The multilingual tax
This one costs money, and it will cost you money if your users write Tamil or Hindi. The same sentence, the same tokeniser, trained mostly on English:
language chars tokens ratio
English 45 21 1.00x ✓
Tamil 40 107 5.10x
Hindi 39 101 4.81x
English: 0.60 tokens per character
Tamil: 3.06 tokens per character
Hindi: 3.26 tokens per character
Every Tamil and Devanagari character is 3 UTF-8 bytes, and no merge ever learned to glue them, so each character costs roughly 3 tokens. Say that plainly: the same sentence in Tamil costs about five times as much and fits in a fifth of the context window. Not because Tamil is verbose. Because the codec was fitted on English.
My ratios are extreme — a toy tokeniser with 376 ids. Production tokenisers have merges for common Devanagari sequences and land closer to 2–3x. The direction is real and it's on your invoice.
The leading space is part of the token
Pre-tokenisation glues the space onto the following word. GPT-2 did this, everyone copied it, and it's the source of a whole family of "why did my prompt do that" bugs:
'the' -> the ids [375]
' the' -> _the ids [260]
same tokens? False
Two different integers for the same three letters. The model saw ' the' constantly
(mid-sentence) and 'the' rarely (start of a line). End your prompt with a trailing
space and you've handed it a token boundary it almost never trained on — then the next token it
predicts has to be a word with no leading space, which is the rare branch. That's the
whole trailing-space folklore, and it's just this.
Byte-level: the fallback that cannot fail
GPT-2's contribution here was small and enormous. Start from bytes, not characters.
There are exactly 256 of them, so every possible input has an encoding and there is no
<unk> token, ever — for any script, any emoji, any corrupted binary you
accidentally pasted in.
'🍓' -> 4 utf-8 bytes, 4 tokens, unknown ids: 0
round trip -> '🍓'
'வணக்கம்' -> 21 utf-8 bytes, 21 tokens, unknown ids: 0
round trip -> 'வணக்கம்' ✓
one strawberry emoji = 4 tokens: [172, 253, 235, 241]
Lossless round trip on all three. But look at the price: one emoji is four tokens, and to produce that one character the model must emit four correct ids in a row. Get one wrong and you have an invalid UTF-8 sequence in the middle of your JSON. That is why a single emoji breaks a schema you thought was safe — not a parser bug, a four-in-a-row bet.
Attention பக்கத்தில் நான் சொன்னேன்: AI engineers-ஐ உண்மையில் வேறுபடுத்துவது research கணிதம் அல்ல — tokenisation edge cases, KV-cache கணக்கு, eval design. இது அந்த tokenisation பக்கம். இதை உங்களுக்கு நான் கடனாக வைத்திருந்தேன்.
யாராவது முன்பே எனக்குச் சொல்லியிருக்க வேண்டியது: tokeniser என்பது model-ன் ஒரு பகுதி அல்ல. அது வேறு யாரோ ஒருவரின் corpus-ல் ஒரே ஒரு முறை பொருத்தப்பட்ட ஒரு lookup table — பிறகு freeze செய்யப்பட்டு weights-உடன் சேர்த்து அனுப்பப்படுகிறது. மீண்டும் train செய்யாமல் அதை மாற்ற முடியாது. நீங்கள் ticket போட்ட, புரியாத LLM நடத்தைகள் கிட்டத்தட்ட எல்லாமே நேராக இதற்குத்தான் வந்து சேரும்.
நீங்கள் தேர்ந்தெடுக்காத ஒரு lossy codec
நீங்கள் ஏற்கனவே codec-களை ship செய்கிறீர்கள். ஒன்று pipeline-ன் நடுவில் உட்கார்ந்தால் என்ன ஆகும் என்று உங்களுக்குத் தெரியும்: அதற்குக் கீழே உள்ள எல்லாமே அதன் அனுமானங்களை வாரிசாகப் பெறுகிறது.
raw_bytes = open("photo.jpg", "rb").read()
# எந்த விவரம் தேவை என்று JPEG encoder ஏற்கனவே முடிவு செய்துவிட்டது.
# உங்கள் image pipeline-க்கு வாக்கு இல்லை. artefacts மட்டும்தான் கிடைக்கும்.
Tokeniser என்பது text-க்கான அதே விஷயம் — பெரும்பாலும் English web text-ஐ compress செய்ய tune செய்யப்பட்டது, மற்ற எல்லாமே அந்தப் பொருத்தமின்மைக்கு விலை கொடுக்கின்றன:
ids = tokeniser.encode(prompt) # உங்கள் string இப்போது போய்விட்டது
out = model(ids) # model ஒரு எழுத்தையும் பார்த்ததில்லை
text = tokeniser.decode(out) # அதே frozen table வழியாக மீண்டும் திரும்ப
Model முழுக்க முழுக்க அந்த முதல் வரியின் வலப்பக்கத்தில்தான் வாழ்கிறது. உங்கள் எழுத்துகளை அது அணுக முடியாது. அதனிடம் integers மட்டும்தான் இருக்கின்றன.
| சொல் | அர்த்தம் | Programmer-ன் உவமை |
|---|---|---|
| Merge list | வரிசைப்படுத்தப்பட்ட விதிகள்: இந்த இணையை ஒரே symbol-ஆக ஒட்டு | ஒரு migration file. வரிசையே schema. |
| Vocabulary | Symbol → integer id | Weights-உடன் அனுப்பப்படும் ஒரு frozen dict |
| Token | அந்த dict-ல் ஒரு entry | ஒரு file handle. உள்ளடக்கம் தெரியாது. |
| Pre-tokenisation | Merges கடக்கத் தடை விதிக்கப்பட்ட எல்லைகள் | Compress செய்வதற்கு முன் record எல்லைகள் |
இது translation-ல் இருந்து வந்தது, language models-ல் இருந்து அல்ல
இதைச் சரியாகத் தெரிந்துகொள்ளுங்கள், ஏனெனில் இது ஒரு interview கேள்வி. BPE 1994-ல்
ஒரு compression algorithm. Sennrich, Haddow, Birch மூவரும் 2015-ல் அதை
வார்த்தைகளின் மேல் திருப்பி ஒரு machine-translation பிரச்சனையைத் தீர்த்தார்கள்:
நிலையான vocabulary என்றால் பார்க்காத ஒரு வார்த்தை <unk> ஆகிவிடும்,
<unk>-ஐ மொழிபெயர்க்க முடியாது. அவர்களின் தலைப்பே சொல்கிறது —
"Neural Machine Translation of Rare Words with Subword Units".
தீர்வு நீங்களே யோசித்திருப்பீர்கள்: ஒவ்வொரு வார்த்தையையும் சேமிக்க முடியாவிட்டால், அடிக்கடி வரும் துண்டுகளை சேமித்து, மீதியை எழுத்துக்கூட்டி எழுதுங்கள். Open vocabulary. வருடங்கள் கழித்து GPT இதை வாரிசாகப் பெற்றது, ஏனெனில் இதுதான் சரியான கருவியாக இருந்தது.
அந்த ஒரு சூத்திரம், அது ஒரு for loop
இதை pipeline stages-ஆகப் படியுங்கள்:
| படி | Data | என்ன செய்கிறது |
|---|---|---|
pre_tokenise | text → chunks | Merges கடக்கக் கூடாத அலகுகளாகப் பிரி. Space வார்த்தையுடன் ஒட்டியே இருக்கும். |
to_symbols | chunk → tuple | ஒற்றை bytes-ல் இருந்து தொடங்கு. 256 உள்ளன, எனவே எதுவும் encode செய்ய முடியாமல் போகாது. |
count_pairs | corpus → Counter | அருகருகே உள்ள ஒவ்வொரு இணையும், வார்த்தை அடிக்கடி வருவதற்கேற்ப எடையிடப்படுகிறது. |
merge_pair | அதிகம் வரும் இணை | எல்லா இடத்திலும் ஒட்டு. ஒரு புதிய symbol, ஒரு புதிய id. |
| மீண்டும் | n முறை | n என்பது உங்கள் vocab-size பட்ஜெட் கழித்தல் 256. அதுவே ஒரே hyperparameter. |
Paper இவ்வளவுதான்: ஒரு loop-க்குள் Counter.most_common(1). Gradient
இல்லை, objective function இல்லை, நீங்கள் நினைக்கும் அர்த்தத்தில் training இல்லை.
Vocabulary வளர்வதைப் பாருங்கள்
Paper-ன் சொந்த உதாரணத்தில் உள்ள சிறிய corpus-ல் script train செய்து, ஒவ்வொரு merge-ஐயும் வரிசையாக அச்சிடுகிறது. மேலிருந்து கீழாகப் படியுங்கள்: எழுத்துகள் துண்டுகளாகின்றன, துண்டுகள் அடிச்சொற்களாகின்றன, அடிச்சொற்கள் முழு வார்த்தைகளாகின்றன.
என் சொல்லை நம்பாதீர்கள். இதோ உண்மையான output:
starting vocabulary: 11 characters -> ' deilnorstw'
merges, in the order they were learned:
merge 1: 'e' + 's' -> 'es' seen 9x, vocab 12
merge 2: 'es' + 't' -> 'est' seen 9x, vocab 13
merge 3: 'l' + 'o' -> 'lo' seen 7x, vocab 14
merge 4: 'lo' + 'w' -> 'low' seen 7x, vocab 15
merge 5: ' ' + 'low' -> ' low' seen 6x, vocab 16
merge 6: ' ' + 'n' -> ' n' seen 6x, vocab 17
merge 7: ' n' + 'e' -> ' ne' seen 6x, vocab 18
merge 8: ' ne' + 'w' -> ' new' seen 6x, vocab 19
merge 9: ' new' + 'est' -> ' newest' seen 6x, vocab 20 ← ஒரு முழு வார்த்தை
merge 10: ' ' + 'w' -> ' w' seen 3x, vocab 21
merge 11: ' w' + 'i' -> ' wi' seen 3x, vocab 22
merge 12: ' wi' + 'd' -> ' wid' seen 3x, vocab 23 ✓
இப்போது அந்த 12 விதிகளுடன் encode செய்யுங்கள். எல்லாவற்றையும் அதிர்வெண்தான் முடிவு செய்கிறது: tokeniser training-ன் போது ஒரு string எத்தனை முறை வந்ததோ, அதுவே inference நேரத்தில் உங்களுக்கு அது எவ்வளவு மலிவு என்பதை என்றென்றும் தீர்மானிக்கிறது.
word tokens count
------------ ---------------------------------- -----
low _low 1
lower _low|e|r 3
newest _newest 1
widest _wid|est 2
lowest _low|est 2 ← வரவே இல்லை, இருந்தும் 2
slowness _|s|low|n|es|s 6
kubernetes _|k|u|b|e|r|n|e|t|es 10 ← சிதறிப் போகிறது
lowest corpus-ல் ஒருபோதும் வரவில்லை. இருந்தும் அது 2 tokens மட்டுமே,
ஏனெனில் ' low'-ம் 'est'-ம் இரண்டுமே வந்தன. 2015 paper-ன்
முழுப் பங்களிப்பும் இதுதான், அச்சிடப்பட்டு. kubernetes corpus-உடன்
கிட்டத்தட்ட எதையும் பகிர்ந்துகொள்ளவில்லை, எனவே எழுத்துகளாகச் சிதைகிறது — சரிதான்,
ஆனால் விலை அதிகம். உங்கள் துறைச் சொற்கள்தான் kubernetes.
Frequency dhaan ellaathaiyum decide pannudhu: training-la eppadi paathadho, adhu dhaan inference-la unga vilai. Ungaloda domain jargon dhaan kubernetes.
Strawberry-ல் r எத்தனை என்று ஏன் எண்ண முடியவில்லை
அந்தப் பிரபலமான கேள்வி. இதோ நேர்மையான இயந்திர விளக்கம் — script சற்றுப் பெரிய English corpus-ல் train செய்யும் byte-level tokeniser-உடன்:
strawberry: _strawberry
ids: [361]
letters: 10 characters -> 1 token
the model is asked 'how many r in strawberry'. What it received:
[361]
ஒரே ஒரு integer. Model பெற்றதில் r என்பதே இல்லை — ஒரு file
descriptor-ன் உள்ளே filename இல்லாதது போலவே. எழுத்துகளை எண்ணச் சொல்வது என்பது,
ஒரு opaque id-ஆக மட்டுமே பார்த்த symbol-ஐ உள்நோக்கிப் பார்க்கச் சொல்வது. அது
நன்றாகச் செய்யும் வேலையுடன் ஒப்பிடுங்கள் — token 361-ன் பொருள் பற்றி
சிந்திப்பது — அப்போது இந்த ஏற்றத்தாழ்வு மர்மமாக இருக்காது.
பன்மொழி வரி
இது பணச் செலவு, உங்கள் users தமிழிலோ இந்தியிலோ எழுதினால் இது உங்களுக்கு பணச் செலவு. அதே வாக்கியம், அதே tokeniser, பெரும்பாலும் English-ல் train செய்யப்பட்டது:
language chars tokens ratio
English 45 21 1.00x ✓
Tamil 40 107 5.10x
Hindi 39 101 4.81x
English: 0.60 tokens per character
Tamil: 3.06 tokens per character
Hindi: 3.26 tokens per character
ஒவ்வொரு தமிழ் மற்றும் தேவநாகரி எழுத்தும் 3 UTF-8 bytes, அவற்றை ஒட்ட எந்த merge-ம் கற்றுக்கொள்ளவில்லை, எனவே ஒவ்வொரு எழுத்துக்கும் தோராயமாக 3 tokens விலை. இதை வெளிப்படையாகச் சொல்கிறேன்: அதே வாக்கியம் தமிழில் சுமார் ஐந்து மடங்கு செலவாகிறது, context window-ல் ஐந்தில் ஒரு பங்கு மட்டுமே பிடிக்கிறது. தமிழ் நீளமான மொழி என்பதால் அல்ல. Codec English-ல் பொருத்தப்பட்டதால்.
என் விகிதங்கள் மிகைப்படுத்தப்பட்டவை — 376 ids கொண்ட ஒரு சிறு tokeniser. Production tokenisers-ல் அடிக்கடி வரும் தேவநாகரி வரிசைகளுக்கு merges உண்டு, அவை 2–3x அளவுக்கு வந்துவிடும். ஆனால் திசை உண்மையானது, அது உங்கள் invoice-ல் இருக்கிறது.
Ovvoru Tamil eluthum 3 UTF-8 bytes, merge edhuvum adha ottala — adhanaala ore vaakkiyam Tamil-la anju madangu selavu, context-um anjil onnu dhaan.
முன்னால் உள்ள space token-ன் ஒரு பகுதி
Pre-tokenisation space-ஐ அடுத்து வரும் வார்த்தையுடன் ஒட்டுகிறது. GPT-2 இதைச் செய்தது, எல்லோரும் நகல் எடுத்தார்கள், "என் prompt ஏன் அப்படிச் செய்தது" என்ற bug-களின் ஒரு முழுக் குடும்பமே இதிலிருந்துதான் வருகிறது:
'the' -> the ids [375]
' the' -> _the ids [260]
same tokens? False
ஒரே மூன்று எழுத்துகளுக்கு இரண்டு வெவ்வேறு integers. Model ' the'-ஐ
தொடர்ந்து பார்த்தது (வாக்கியத்தின் நடுவில்), 'the'-ஐ அரிதாகவே
(வரியின் தொடக்கத்தில்). உங்கள் prompt-ஐ ஒரு space-உடன் முடித்தால், அது கிட்டத்தட்ட
train ஆகாத ஒரு token எல்லையை நீங்கள் அதனிடம் கொடுத்திருக்கிறீர்கள் — பிறகு அது
கணிக்கும் அடுத்த token, முன்னால் space இல்லாத வார்த்தையாக இருக்க வேண்டும்,
அதுவே அரிதான கிளை. முடிவில் உள்ள space பற்றிய எல்லா நாட்டுப்புறக் கதைகளும் இதுதான்.
Byte-level: தோற்கவே முடியாத fallback
GPT-2-ன் பங்களிப்பு இங்கே சிறியது, ஆனால் மகத்தானது. எழுத்துகளில் அல்ல,
bytes-ல் இருந்து தொடங்குங்கள். சரியாக 256 உள்ளன, எனவே சாத்தியமான ஒவ்வொரு
input-க்கும் ஒரு encoding உண்டு, <unk> token என்பதே எப்போதும்
இல்லை — எந்த எழுத்துமுறைக்கும், எந்த emoji-க்கும், நீங்கள் தவறுதலாக ஒட்டிய எந்தச்
சிதைந்த binary-க்கும்.
'🍓' -> 4 utf-8 bytes, 4 tokens, unknown ids: 0
round trip -> '🍓'
'வணக்கம்' -> 21 utf-8 bytes, 21 tokens, unknown ids: 0
round trip -> 'வணக்கம்' ✓
one strawberry emoji = 4 tokens: [172, 253, 235, 241]
மூன்றிலும் இழப்பில்லாத round trip. ஆனால் விலையைப் பாருங்கள்: ஒரு emoji நான்கு tokens, அந்த ஒரு எழுத்தை உருவாக்க model நான்கு சரியான ids-ஐ வரிசையாக வெளியிட வேண்டும். ஒன்று தவறினால் உங்கள் JSON-ன் நடுவில் ஒரு செல்லாத UTF-8 வரிசை கிடைக்கும். பாதுகாப்பானது என்று நீங்கள் நினைத்த ஒரு schema-வை ஒரு emoji ஏன் உடைக்கிறது என்பதற்கு இதுதான் காரணம் — parser bug அல்ல, வரிசையாக நான்கு சரி வர வேண்டிய ஒரு சூது.
Attention वाले पेज पर मैंने कहा था कि AI engineers को असल में अलग करती है research गणित नहीं — tokenisation के edge cases, KV-cache का हिसाब, eval design. यह वही tokenisation वाला पेज है। यह मुझ पर आपका उधार था।
जो बात कोई पहले बता देता तो अच्छा होता: tokeniser model का हिस्सा नहीं है। यह एक lookup table है जो किसी और के corpus पर एक बार fit किया गया, फिर freeze करके weights के साथ भेज दिया गया — दोबारा training के बिना आप इसे बदल नहीं सकते। और LLM का लगभग हर उलझाने वाला व्यवहार, जिस पर आपने ticket खोला है, सीधे यहीं तक पहुँचता है।
एक lossy codec जो आपने चुना ही नहीं
आप पहले से codecs ship करते हैं। आप जानते हैं कि जब कोई codec pipeline के बीच में बैठ जाता है तो क्या होता है: उसके आगे का सब कुछ उसकी मान्यताएँ विरासत में पा जाता है।
raw_bytes = open("photo.jpg", "rb").read()
# JPEG encoder पहले ही तय कर चुका कि कौन-सी बारीकी रखनी है।
# आपकी image pipeline का वोट नहीं है। उसे artefacts मिलते हैं।
Tokeniser text के लिए बिलकुल यही है — ज़्यादातर English web text को compress करने के लिए tune किया गया, और बाक़ी सब इस बेमेल की क़ीमत चुकाते हैं:
ids = tokeniser.encode(prompt) # आपकी string अब ख़त्म
out = model(ids) # model ने कभी कोई अक्षर देखा ही नहीं
text = tokeniser.decode(out) # और उसी frozen table से वापस
Model पूरी तरह उस पहली लाइन के दाईं ओर रहता है। आपके अक्षरों तक उसकी पहुँच नहीं है। उसके पास integers हैं।
| शब्द | मतलब | Programmer की उपमा |
|---|---|---|
| Merge list | क्रम में लिखे नियम: इस जोड़ी को एक symbol में चिपकाओ | एक migration file. क्रम ही schema है। |
| Vocabulary | Symbol → integer id | Weights के साथ भेजी गई एक frozen dict |
| Token | उस dict की एक entry | एक file handle. अंदर क्या है, पता नहीं। |
| Pre-tokenisation | वे सीमाएँ जिन्हें merges पार नहीं कर सकतीं | Compress करने से पहले record की सीमाएँ |
यह translation से आया है, language models से नहीं
इसे ठीक से जान लीजिए, क्योंकि यह interview का सवाल है। BPE 1994 का एक
compression algorithm था। Sennrich, Haddow और Birch ने 2015 में इसे शब्दों
पर लगाकर एक machine-translation समस्या हल की: तय vocabulary का मतलब है कि कोई
अनदेखा शब्द <unk> बन जाता है, और <unk> का
अनुवाद नहीं हो सकता। उनका शीर्षक ही कह देता है — "Neural Machine Translation of
Rare Words with Subword Units".
हल वही है जो आप भी सोचते: अगर हर शब्द नहीं रख सकते, तो बार-बार आने वाले टुकड़े रखो और बाक़ी को अक्षर-अक्षर लिखो। Open vocabulary. सालों बाद GPT को यही विरासत में मिला, क्योंकि यही सही औज़ार था।
वह इकलौता सूत्र, जो असल में एक for loop है
इसे pipeline के चरणों की तरह पढ़िए:
| चरण | Data | क्या करता है |
|---|---|---|
pre_tokenise | text → chunks | ऐसी इकाइयों में बाँटो जिन्हें merges पार न कर सकें। Space शब्द से चिपका रहता है। |
to_symbols | chunk → tuple | अकेले bytes से शुरू करो। कुल 256, इसलिए कुछ भी encode से बाहर नहीं। |
count_pairs | corpus → Counter | हर अगल-बग़ल की जोड़ी, शब्द की आवृत्ति के हिसाब से भारित। |
merge_pair | सबसे आम जोड़ी | हर जगह चिपका दो। एक नया symbol, एक नई id. |
| दोहराओ | n बार | n यानी आपका vocab-size बजट घटा 256. यही इकलौता hyperparameter है। |
पूरा paper इतना ही है: एक loop के अंदर Counter.most_common(1). कोई
gradient नहीं, कोई objective function नहीं, और training भी नहीं — कम से कम उस अर्थ
में नहीं जिस अर्थ में आप सोचते हैं।
Vocabulary को बढ़ते हुए देखिए
Script उसी छोटे corpus पर train करती है जो paper के अपने उदाहरण में है, और हर merge को क्रम से छापती है। ऊपर से नीचे पढ़िए: अक्षर टुकड़े बनते हैं, टुकड़े मूल शब्दांश बनते हैं, और मूल शब्दांश पूरे शब्द बन जाते हैं।
मेरी बात पर मत जाइए। यह रहा असली output:
starting vocabulary: 11 characters -> ' deilnorstw'
merges, in the order they were learned:
merge 1: 'e' + 's' -> 'es' seen 9x, vocab 12
merge 2: 'es' + 't' -> 'est' seen 9x, vocab 13
merge 3: 'l' + 'o' -> 'lo' seen 7x, vocab 14
merge 4: 'lo' + 'w' -> 'low' seen 7x, vocab 15
merge 5: ' ' + 'low' -> ' low' seen 6x, vocab 16
merge 6: ' ' + 'n' -> ' n' seen 6x, vocab 17
merge 7: ' n' + 'e' -> ' ne' seen 6x, vocab 18
merge 8: ' ne' + 'w' -> ' new' seen 6x, vocab 19
merge 9: ' new' + 'est' -> ' newest' seen 6x, vocab 20 ← एक पूरा शब्द
merge 10: ' ' + 'w' -> ' w' seen 3x, vocab 21
merge 11: ' w' + 'i' -> ' wi' seen 3x, vocab 22
merge 12: ' wi' + 'd' -> ' wid' seen 3x, vocab 23 ✓
अब उन्हीं 12 नियमों से encode कीजिए। सब कुछ आवृत्ति तय करती है: tokeniser की training के दौरान कोई string कितनी बार आई, ठीक यही तय करता है कि inference के वक़्त वह आपके लिए कितनी सस्ती है — हमेशा के लिए।
word tokens count
------------ ---------------------------------- -----
low _low 1
lower _low|e|r 3
newest _newest 1
widest _wid|est 2
lowest _low|est 2 ← कभी देखा नहीं, फिर भी 2
slowness _|s|low|n|es|s 6
kubernetes _|k|u|b|e|r|n|e|t|es 10 ← बिखर जाता है
lowest corpus में कभी आया ही नहीं। फिर भी उसकी क़ीमत 2 tokens है,
क्योंकि ' low' और 'est' दोनों आए थे। 2015 के paper का पूरा
योगदान यही है, छपा हुआ। kubernetes corpus के साथ लगभग कुछ भी साझा नहीं
करता, इसलिए अक्षरों में बिखर जाता है — सही है, बस महँगा है। आपकी domain की शब्दावली
ही kubernetes है।
यह strawberry में r क्यों नहीं गिन पाता
वही मशहूर सवाल। यह रही ईमानदार यांत्रिक व्याख्या, उस byte-level tokeniser के साथ जिसे script थोड़े बड़े English corpus पर train करती है:
strawberry: _strawberry
ids: [361]
letters: 10 characters -> 1 token
the model is asked 'how many r in strawberry'. What it received:
[361]
एक अकेला integer. Model को जो मिला उसमें कोई r है ही नहीं — ठीक वैसे
ही जैसे file descriptor के अंदर filename नहीं होता। उससे अक्षर गिनने को कहना यानी
उससे कहना कि वह एक ऐसे symbol के भीतर झाँके जिसे उसने हमेशा एक अपारदर्शी id की तरह
ही देखा है। जिस काम में वह अच्छा है उससे तुलना कीजिए — token 361 के अर्थ
पर सोचना — और यह असंतुलन रहस्य नहीं रह जाता।
बहुभाषी टैक्स
इसकी क़ीमत पैसों में है, और अगर आपके users तमिल या हिंदी लिखते हैं तो यह आपकी जेब से जाएगी। वही वाक्य, वही tokeniser, जो ज़्यादातर English पर train हुआ है:
language chars tokens ratio
English 45 21 1.00x ✓
Tamil 40 107 5.10x
Hindi 39 101 4.81x
English: 0.60 tokens per character
Tamil: 3.06 tokens per character
Hindi: 3.26 tokens per character
हर तमिल और देवनागरी अक्षर 3 UTF-8 bytes का है, और किसी merge ने उन्हें चिपकाना सीखा ही नहीं, इसलिए हर अक्षर की क़ीमत क़रीब 3 tokens है। साफ़ शब्दों में: वही वाक्य हिंदी में लगभग पाँच गुना महँगा पड़ता है और context window के पाँचवें हिस्से में ही समा पाता है। इसलिए नहीं कि हिंदी लंबी भाषा है। इसलिए कि codec English पर fit किया गया था।
मेरे अनुपात चरम हैं — 376 ids वाला एक खिलौना tokeniser। Production tokenisers में आम देवनागरी अनुक्रमों के लिए merges होते हैं और वे 2–3x के आसपास आ जाते हैं। पर दिशा असली है, और वह आपके invoice पर दिखती है।
आगे लगा space token का ही हिस्सा है
Pre-tokenisation space को अगले शब्द के साथ चिपका देता है। GPT-2 ने ऐसा किया, सबने नक़ल की, और "मेरे prompt ने ऐसा क्यों किया" वाले bugs का पूरा कुनबा यहीं से निकलता है:
'the' -> the ids [375]
' the' -> _the ids [260]
same tokens? False
एक ही तीन अक्षरों के लिए दो अलग integers. Model ने ' the' लगातार देखा
(वाक्य के बीच में) और 'the' बहुत कम (पंक्ति की शुरुआत में)। अपने prompt
के अंत में space छोड़ दीजिए और आपने उसे एक ऐसी token सीमा थमा दी जिस पर उसने लगभग
training ही नहीं की — फिर उसे जो अगला token चुनना है वह ऐसा शब्द होगा जिसके आगे
space नहीं है, यानी दुर्लभ शाखा। trailing-space की सारी लोककथा बस इतनी ही
है।
Byte-level: वह fallback जो नाकाम हो ही नहीं सकता
GPT-2 का योगदान यहाँ छोटा भी था और बहुत बड़ा भी। अक्षरों से नहीं, bytes से
शुरू कीजिए। वे ठीक 256 हैं, इसलिए हर संभव input की एक encoding है और
<unk> token कभी होता ही नहीं — किसी भी लिपि, किसी भी emoji, और
ग़लती से चिपकाई गई किसी भी टूटी binary के लिए।
'🍓' -> 4 utf-8 bytes, 4 tokens, unknown ids: 0
round trip -> '🍓'
'வணக்கம்' -> 21 utf-8 bytes, 21 tokens, unknown ids: 0
round trip -> 'வணக்கம்' ✓
one strawberry emoji = 4 tokens: [172, 253, 235, 241]
तीनों पर बिना नुक़सान का round trip. पर क़ीमत देखिए: एक emoji चार tokens है, और उस एक अक्षर को बनाने के लिए model को चार सही ids लगातार निकालनी होंगी। एक भी ग़लत हुई और आपके JSON के बीचोंबीच एक अमान्य UTF-8 अनुक्रम बैठ जाएगा। इसीलिए एक अकेला emoji उस schema को तोड़ देता है जिसे आप सुरक्षित मान रहे थे — parser का bug नहीं, लगातार चार सही निकलने का दाँव।
The code
Pure Python (NumPy is imported only to pin the seed). No torch, no downloads — a tokeniser is small enough to write completely, so this file is the tokeniser. Every function under 20 lines.
Download the full script — 436 lines including seven runnable demos. Then:
python3 tokenization_from_scratch.py
def to_symbols(word, byte_level=True):
"""A word -> the tuple of starting symbols BPE will merge."""
if byte_level:
return tuple(BYTE_TO_CHAR[b] for b in word.encode("utf-8"))
return tuple(word) # character-level: one symbol per character
def pre_tokenise(text):
return re.findall(r" ?[^\s]+|\s+", text)
def count_pairs(freqs):
pairs = Counter()
for symbols, n in freqs.items():
for a, b in zip(symbols, symbols[1:]):
pairs[(a, b)] += n # weighted by how often the word occurs
return pairs
def merge_pair(symbols, pair):
"""Glue every occurrence of `pair` in one word into a single symbol."""
out, i = [], 0
while i < len(symbols):
if i < len(symbols) - 1 and (symbols[i], symbols[i + 1]) == pair:
out.append(symbols[i] + symbols[i + 1])
i += 2
else:
out.append(symbols[i])
i += 1
return tuple(out)
def train_bpe(freqs, n_merges, trace=0, base_size=0):
"""Return the ordered merge list. Order IS the model -- rank matters."""
merges = []
for step in range(n_merges):
pairs = count_pairs(freqs)
if not pairs:
break
# ties broken by the pair itself, so two runs give identical output
best_count = max(pairs.values())
best = min(p for p, c in pairs.items() if c == best_count)
freqs = {merge_pair(w, best): n for w, n in freqs.items()}
merges.append(best)
return merges
def encode_word(word, merges, byte_level=True):
ranks = {pair: i for i, pair in enumerate(merges)}
symbols = to_symbols(word, byte_level)
while len(symbols) > 1:
candidates = [(ranks[p], p) for p in zip(symbols, symbols[1:])
if p in ranks]
if not candidates:
break
symbols = merge_pair(symbols, min(candidates)[1]) # lowest rank first
return list(symbols)
BYTE_TO_CHAR (the 256-entry byte alphabet) and the trace printing
inside train_bpe are elided here. Both are in the file.
Now break it
Demo 7 runs most of these and prints the broken version next to the working one. Do them by hand anyway — reverse-engineering from broken behaviour is faster than re-reading a method section.
| Break this | Watch for |
|---|---|
Train with train_bpe(freqs, 0) instead of 400 | A 32-character sentence goes from 6 tokens to 32. Everything is bytes again. You pay that on every request, forever. |
Pass byte_level=False, then encode Tamil | All 4 symbols of பூனை have no id at all. Byte-level: 12 tokens, 0 unknown. |
Train with word_freqs(ENGLISH, pretokenise=False) | 168 merges contain a space; 138 tokens span two words, e.g. _the_model. One id now means "the model". |
Encode English with tamil_merges | 6 tokens becomes 32. No error, no warning, valid ids throughout — silently worse. |
Drop the leading space: encode("continues…") | First id flips from 220 to 66 and the whole sequence differs from the one the model trained on. |
In encode_word, use max(candidates) | Merges apply in reverse rank order. Splits stop matching the training-time splits, still with no error. |
low /
lower / newest example, which is the corpus in demo 1. Skip the BLEU tables. For
SentencePiece, read the design section on treating input as a raw stream; skip the benchmarks.
On the maths anxiety
There isn't any. This is the paper on the site with the least maths in it: counting pairs,
taking the most frequent, and a dictionary from string to integer. If you can write a
Counter, you can write a tokeniser, and you just read one.
The only quantity worth internalising is the ratio: tokens per character for your users' actual languages. That's arithmetic, and it's the number that turns into your bill and your context budget.
Skipped here: the unigram tokeniser (SentencePiece's other algorithm, which scores candidate
segmentations probabilistically instead of replaying merges), BPE-dropout, the exact GPT-2
regex, how special tokens like <|endoftext|> get injected outside the merge
process, and whether tokenisation should exist at all.
Where to go next
This page gave you one thing: BPE as a merge list you trained yourself and can break. Here's where the parts I skipped live.
-
Neural Machine Translation of Rare Words with Subword Units
Short, and it will now read as the comments you've already seen. Read the method section — its worked example is demo 1's corpus — and the analysis of which words got split. Skip the 2015 BLEU tables.
-
SentencePiece
The half I compressed into one callout: why the raw stream as input removes language-specific pre-tokenisation, how lossless detokenisation is guaranteed, and what the unigram alternative optimises. This is the library you're actually using in production.
-
Let's build the GPT Tokenizer
The definitive version of this topic. He builds the same thing I did, then goes where I stopped: the real GPT-2 regex, special tokens, why SolidGoldMagikarp broke GPT-2, and why LLMs are bad at arithmetic. Watch this if you watch one thing.
-
Tiktokenizer
Not an explainer — a debugger. Every claim on this page checks against a real production tokeniser in ten seconds. Paste your Tamil sentence, your JSON with an emoji in it, a prompt with a trailing space. Believe the tool, not me.