Tokens

Why do language models read "unbelievable" as pieces instead of letters or words?

Before a model reads anything, text is chopped into tokens and each token becomes an ID number. This page learns its own tokenizer from the training text, the same way GPT-style tokenizers are built, just much smaller.

Your sentence, as the model sees it

the_34kin52dest_57learn39e5rs_63a1r16e_29un56b2e5l11ie50v20a1b2l11y23_0happ49y23_0rea42de26rs_63
[34, 52, 57, 39, 5, 63, 1, 16, 29, 56, 2, 5, 11, 50, 20, 1, 2, 11, 23, 0, 49, 23, 0, 42, 26, 63]
45letters
26tokens after 40 merges
64tokens in the vocabulary

_ 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

40
Reset

The merges it learned, in order

Each row glued the most common neighbouring pair in the training text at that moment.

#PairNew tokenSeen
1e + aea15×
2s + _s_13×
3d + ede12×
4i + nin12×
5s + tst11×
6e + _e_10×
7r + _r_9×
8g + _g_8×
9h + e_he_8×
10in + g_ing_8×
11t + he_the_8×
12d + _d_7×
13ea + rear6×
14ear + nearn6×
15i + s_is_6×
16l + earnlearn6×
17st + _st_6×
18e + r_er_5×
19r + earea5×
20st + ustu5×
21a + pap4×
22ap + papp4×
23c + hch4×
24de + r_der_4×
25ea + cheach4×
26h + apphapp4×
27i + eie4×
28k + eke4×
29k + inkin4×
30n + ene4×
31t + _t_4×
32t + eachteach4×
33u + nun4×
34de + st_dest_3×
35l + olo3×
36learn + ing_learning_3×
37lo + wlow3×
38ne + wnew3×
39o + lol3×
40r + s_rs_3×

How the training words are split now

CountWordTokens
8the
the_
6is
is_
3learning
learning_
2and
and_
2reading
reading_
2studying
studying_
1are
are_
1happier
happier_
1happiest
happiest_
1happy
happy_
1kept
kept_
1kind
kind_
1kinder
kinder_
1kindest
kindest_
1learn
learn_
1learner
learner_
1learners
learners_
1low
low_
1lower
lower_
1lowest
lowest_
1model
model_
1new
new_
1newer
newer_
1newest
newest_
1next
next_
1of
of_
1old
old_
1older
older_
1oldest
oldest_
1pieces
pieces_
1predict
predict_
1read
read_
1reader
reader_
1readers
readers_
1student
student_
1students
students_
1studied
studied_
1teach
teach_
1teacher
teacher_
1teachers
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)));
    }
}