Your sentence, as the model sees it
_ marks the end of a word. Small numbers are token IDs. A dashed chip with "?" is a character never seen in training, so it has no ID. Real tokenizers avoid that by starting from the 256 possible bytes instead of letters.
Settings
The merges it learned, in order
Each row glued the most common neighbouring pair in the training text at that moment.
| # | Pair | New token | Seen |
|---|---|---|---|
| 1 | e + a | ea | 15× |
| 2 | s + _ | s_ | 13× |
| 3 | d + e | de | 12× |
| 4 | i + n | in | 12× |
| 5 | s + t | st | 11× |
| 6 | e + _ | e_ | 10× |
| 7 | r + _ | r_ | 9× |
| 8 | g + _ | g_ | 8× |
| 9 | h + e_ | he_ | 8× |
| 10 | in + g_ | ing_ | 8× |
How the training words are split now
| Count | Word | Tokens |
|---|---|---|
| 8 | the | the_ |
| 6 | is | is_ |
| 3 | learning | learning_ |
| 2 | and | and_ |
| 2 | reading | reading_ |
| 2 | studying | studying_ |
| 1 | are | are_ |
| 1 | happier | happier_ |
| 1 | happiest | happiest_ |
| 1 | happy | happy_ |
| 1 | kept | kept_ |
| 1 | kind | kind_ |
| 1 | kinder | kinder_ |
| 1 | kindest | kindest_ |
| 1 | learn | learn_ |
| 1 | learner | learner_ |
| 1 | learners | learners_ |
| 1 | low | low_ |
| 1 | lower | lower_ |
| 1 | lowest | lowest_ |
| 1 | model | model_ |
| 1 | new | new_ |
| 1 | newer | newer_ |
| 1 | newest | newest_ |
| 1 | next | next_ |
| 1 | of | of_ |
| 1 | old | old_ |
| 1 | older | older_ |
| 1 | oldest | oldest_ |
| 1 | pieces | pieces_ |
| 1 | predict | predict_ |
| 1 | read | read_ |
| 1 | reader | reader_ |
| 1 | readers | readers_ |
| 1 | student | student_ |
| 1 | students | students_ |
| 1 | studied | studied_ |
| 1 | teach | teach_ |
| 1 | teacher | teacher_ |
| 1 | teachers | teachers_ |
What to notice
Frequent words become single tokens first. "the_" is glued together almost immediately. Common endings like "ing_", "er", "est_" become tokens too, because many different words share them.
New words still work. "unbelievably" isn't in the training text, but it gets built from known pieces like "un", "believ" and "ly". Nothing is ever truly unknown, just split into more pieces.
This explains some famous LLM mistakes. Ask a model how many r's are in "strawberry" and it may get it wrong: it never saw the letters, only tokens like "str" + "aw" + "berry". Real tokenizers have 50,000 to 200,000 tokens, and one English token is about ¾ of a word on average.
The code that just ran
Everything on this page came from calling this code, which lives at the top of this same file.
Show or hide the source
final class BPE
{
public const END = '_'; // marks the end of a word, so "er_" (end) ≠ "er" (middle)
public array $merges = []; // in the order they were learned: [left, right, how often]
private array $rank = []; // "left right" → merge number (lower = learned earlier)
public array $trainedWords = []; // how each training word is split at the end
// Split text into words and count them.
public static function wordCounts(string $text): array
{
preg_match_all("/[a-z0-9']+/", strtolower($text), $m);
return array_count_values($m[0]);
}
private static function chars(string $word): array
{
$c = preg_split('//u', $word, -1, PREG_SPLIT_NO_EMPTY);
$c[] = self::END;
return $c;
}
// Replace every "a" followed by "b" with the single token "ab".
private static function merge(array $symbols, string $a, string $b): array
{
$out = [];
for ($i = 0; $i < count($symbols); $i++) {
if ($i < count($symbols) - 1 && $symbols[$i] === $a && $symbols[$i + 1] === $b) {
$out[] = $a . $b; $i++;
} else {
$out[] = $symbols[$i];
}
}
return $out;
}
// TRAINING: repeatedly glue the most common neighbouring pair.
public function train(string $text, int $numMerges): void
{
$words = [];
foreach (self::wordCounts($text) as $w => $count) $words[] = ['sym' => self::chars((string) $w), 'n' => $count, 'word' => (string) $w];
for ($m = 0; $m < $numMerges; $m++) {
$pairs = [];
foreach ($words as $w) {
for ($i = 0; $i < count($w['sym']) - 1; $i++) {
$key = $w['sym'][$i] . ' ' . $w['sym'][$i + 1];
$pairs[$key] = ($pairs[$key] ?? 0) + $w['n'];
}
}
if (!$pairs) break;
$best = max($pairs);
if ($best < 2) break; // nothing repeats any more
$ties = array_keys($pairs, $best);
sort($ties); // break ties alphabetically, so it's repeatable
[$a, $b] = explode(' ', (string) $ties[0]);
$this->rank[$a . ' ' . $b] = count($this->merges);
$this->merges[] = [$a, $b, $best];
foreach ($words as &$w) $w['sym'] = self::merge($w['sym'], $a, $b);
unset($w);
}
usort($words, fn($x, $y) => $y['n'] <=> $x['n'] ?: strcmp($x['word'], $y['word']));
$this->trainedWords = $words;
}
// ENCODING a new word: apply the learned merges in the order they were
// learned (earliest first), until no learned pair is left.
public function encodeWord(string $word): array
{
$sym = self::chars($word);
while (true) {
$bestRank = PHP_INT_MAX; $bestPair = null;
for ($i = 0; $i < count($sym) - 1; $i++) {
$r = $this->rank[$sym[$i] . ' ' . $sym[$i + 1]] ?? PHP_INT_MAX;
if ($r < $bestRank) { $bestRank = $r; $bestPair = [$sym[$i], $sym[$i + 1]]; }
}
if ($bestPair === null) return $sym;
$sym = self::merge($sym, $bestPair[0], $bestPair[1]);
}
}
public function encode(string $text): array
{
preg_match_all("/[a-z0-9']+/", strtolower($text), $m);
$tokens = [];
foreach ($m[0] as $w) foreach ($this->encodeWord($w) as $t) $tokens[] = $t;
return $tokens;
}
// The final vocabulary: every starting character, then every merge result.
// A token's ID is just its position in this list.
public function vocabulary(string $text): array
{
$chars = [];
foreach (array_keys(self::wordCounts($text)) as $w) foreach (self::chars((string) $w) as $c) $chars[$c] = true;
$vocab = array_keys($chars);
sort($vocab);
foreach ($this->merges as [$a, $b]) $vocab[] = $a . $b;
return array_values(array_unique(array_map('strval', $vocab)));
}
}