Tokenisation Algorithms

This page is for students who want to implement tokenisers themselves. The visualiser shows what each method produces; here we cover how each one works — the pseudocode, and interactive demos.

The main idea is that subword tokenisers have two separate phases: (i) Training learns a vocabulary (and sometimes merge rules) from a corpus, for which you do once, and (ii) Encoding applies that vocabulary to new text.

Jump to

The tokenisation pipeline

Most tokenisers go through the same four stages:

Stage overview
# 1. NORMALISE   raw text  -> cleaned text
#    lowercase, strip accents, unicode, remove space
# 2. PRE-TOKENISE cleaned  -> chunks  (usually "words" + whitespace)
#    split on spaces/punctuation
# 3. MODEL        chunks   -> tokens  (BPE / WordPiece / Unigram / ...)
#    the actual tokenisation algorithm
# 4. POST-PROCESS tokens   -> ids     (look up each token in the vocab)
#    add special tokens like [CLS], [SEP], <s>, </s>

Note that pre-tokenisation matters: BPE and WordPiece first split a sentence into words so that a merge could not span a space, whereas SentencePiece intentionally skips this step and treats the space as just another character.

Baselines rule-based

Word, character and sentence tokenisation need no training — they are rules.

Character

Every character is a token. Tiny, fixed vocabulary; very long sequences.

Character tokenise
function char_tokenise(text):
    return list(text)        # split into characters
Word

Split on whitespace and (usually) peel punctuation off as its own token.

Word tokenise
function word_tokenise(text):
    # one or more letters/digits  OR  a single punctuation char
    return regex_findall(r"[A-Za-z0-9]+|[^\sA-Za-z0-9]", text)
Sentence

Break on . ! ? — but not apply to abbreviations (Dr., e.g.) that contain a full stop without ending a sentence.

Sentence tokenise
function sentence_tokenise(text):
    sentences = []
    buffer = ""
    for word in split_keeping_spaces(text):
        buffer += word
        if ends_with(buffer, [".", "!", "?"])
           and last_word(buffer) not in ABBREVIATIONS:
            sentences.append(trim(buffer))
            buffer = ""
    if trim(buffer): sentences.append(trim(buffer))
    return sentences
Try it
Interactive · baselines
Text
Tokens
Vocab size Char: tiny · Word: huge (every form) · Sentence: unbounded
Out-of-vocab Char: never · Word: frequent (any unseen word)
Training No need — just rules

Byte Pair Encoding subword

Key idea: start from individual characters and repeatedly merge the most frequent adjacent pair into a new symbol. Each merge becomes a rule. The list of merges is the vocabulary.
Training — learn the merges

Represent each word as a sequence of characters plus an end-of-word marker (here </w>).

BPE training
function bpe_train(corpus, num_merges):
    # word -> frequency, each word as a sequence of symbols
    vocab = count_words(corpus)            # {("l","o","w","</w>"): 5, ...}
    merges = []                          # a list of learned rules

    repeat num_merges times:
        pairs = count_adjacent_pairs(vocab)   # {("e","s"): 9, ("s","t"): 7, ...}
        if pairs is empty: break
        best = argmax(pairs)               # most frequent pair
        vocab = merge_pair(best, vocab)     # replace every "e","s" with "es"
        merges.append(best)

    return merges                      

function count_adjacent_pairs(vocab):
    counts = {}
    for word, freq in vocab:
        for (a, b) in consecutive_pairs(word):
            counts[(a, b)] += freq      # sum up the frequencies of adjacent symbols if they appear in multiple words
    return counts
Encoding — apply the merges

To tokenise new text, split each word into characters and apply the learned merges in the order they were learned — more frequent merges apply first.

BPE encode
function bpe_encode(word, merges):
    symbols = list(word) + ["</w>"]
    for (a, b) in merges:            # learned order is essential
        symbols = merge_pair_in((a, b), symbols)
    return symbols
Example

Learning to compress the word lowest, given these were the top pairs found in the corpus:

start:      l · o · w · e · s · t
merge e+s:  l · o · w · es · t
merge l+o:  lo · w · es · t
merge lo+w: low · es · t
merge es+t: low · est
result: [ low , est ]

A rare word like "lowestest" would simply stop merging earlier and come out as low + est + est — this is how BPE handles out-of-vocabulary words.

Try it — train BPE step by step

Edit the corpus, then press Step to apply one merge at a time. The middle panel shows the live adjacent-pair counts; watch the word table rebuild and the merge list grow.

Interactive · BPE trainer
Corpus
Words → current symbols
Adjacent-pair counts
Learned merges (in order)
Merge choice Most frequent adjacent pair
Training cost ~O(merges × pairs)
Used by GPT-2/3/4 (byte-level), RoBERTa

WordPiece subword

Key idea: almost identical to BPE during training, but instead of merging the most frequent pair it merges the pair that most increases the likelihood of the training data.
Training — Learn the vocabulary

For a candidate pair (a, b), BPE asks "how often does ab appear?". WordPiece instead asks "how much does ab appear significantly?" using the score below. The score tells how far they co-occur more than by chance (see Pointwise Mutual Information).

WordPiece merge score
# pick the pair maximising this likelihood
score(a, b) = freq(a, b) / ( freq(a) × freq(b) )

function wordpiece_train(corpus, vocab_size):
    vocab = init_with_characters(corpus)  # {"p", "l", "a", "y", "i", ...}
    while size(vocab) < vocab_size:
        pairs = count_adjacent_pairs(corpus, vocab)
        best  = argmax(pairs, key = score)   # likelihood, not frequency
        if best is none: break
        vocab.add(merge(best))
    return vocab                          # a set of pieces
Encoding — apply the vocabulary

This is where WordPiece differs most from BPE. There is no merge list available; instead you scan each word left-to-right and take the longest piece in the vocabulary that fits. The ## prefix is added to any subword that doesn't start a word.

WordPiece encode (greedy longest-match)
function wordpiece_encode(word, vocab):
    tokens = []
    start = 0
    while start < len(word):
        end = len(word)
        piece = none
        while start < end:                # reduce the window size from the right
            sub = word[start:end]
            if start > 0: sub = "##" + sub  # mark any subword that doesn't start a word 
            if sub in vocab:
                piece = sub; break
            end -= 1
        if piece is none:
            return ["[UNK]"]            # whole word unmatchable
        tokens.append(piece)
        start = end
    return tokens
Example

Encoding playing using a vocabulary containing play and ##ing:

start:      playing
window 7:   playing ✗
window 6:  playin ✗
window 5:  playi ✗
window 4:   play
remaining:   ing
window 3: ##ing
result: [ play , ##ing ]
Try it — greedy longest-match, step by step

Set a word and a vocabulary, then Step to reduce the window size one character at a time. WordPiece takes the longest vocab piece that fits; ## marks a word continuation.

Interactive · WordPiece encoder
Word
Vocab
Word & search window
Vocabulary
+ every single character (a–z, ##a–##z) as fallback
Tokens so far
Merge choice Highest likelihood score
Encoding Greedy longest-match
Used by BERT, ELECTRA

SentencePiece subword

Key idea: SentencePiece addresses the fact that not all languages use spaces to separate words (e.g., Japanese or Chinese). Unlike BPE and WordPiece, SentencePiece doesn't involve pre-tokenisation, and encodes the space itself as a visible marker.
Training — Learn the vocabulary

Start from a BPE-based vocabulary, then segment a word with BPE, count how often each wordpiece appears, estimate its probability, and remove rare pieces.

Unigram training (sketch)
function unigram_train(corpus, vocab_size):
    # Start with many candidate pieces: ["p", "l", "a", "y", "play", "ing", "ed", "er"]
    V = bpe_train(corpus, large_vocab_size)

    p = init_probs(V)

    while size(V) > vocab_size:
        piece_counts = {}

        # Segment each word using the current vocabulary
        for word in corpus:
            pieces = bpe_encode(word, merges)

            # Count how often each piece is used
            for piece in pieces:
                piece_counts[piece] += 1

        # Update probabilities
        logp =  normalise_to_log_probs(piece_counts)

        V = remove_low_prob_pieces(V, logp)

    return V, logp
Encoding — Apply the vocabulary

The encoder considers many possible ways to split a word. It scores each possible split by summing the log probabilities of word pieces, and uses the Viterbi algorithm to efficiently find the highest-scoring segmentation.

Unigram encode
function unigram_encode(word, logp):
    # word = "playing"
    # logp = {"play": -1.2, "ing": -0.8, "pla": -2.0, "y": -5.0}
    N = len(word)
    best = array(N + 1, fill = -infinity)
    # best[i] = best score for segmenting word[:i] into 1+ tokens
    # best[4] = best score for "play"
    back = array(N + 1, fill = -1)
    # back[i] = where the last token starts
    # back[7] = 4 means last token = word[4:7] = "ing"
    best[0] = 0
    for i in 1 .. N:
        # compute best segmentation for word[:i]
        # i=7 → "playing"
        for j in 0 .. i-1:
            piece = word[j:i]
            # candidate token
            # i=7 → {"playing", "laying", "aying", "ying", "ing", "ng", "g"}
            if piece in logp:
                cand = best[j] + logp[piece]
                # score(full token sequence) = score(previous tokens) + score(current token)
                # cand = best[4] + logp["ing"] = -1.2 + (-0.8) = -2.0 
                if cand > best[i]:
                    best[i] = cand # update best score: best[7] = -2.0
                    back[i] = j # remember where last token starts: back[7] = 4
    return reconstruct(back, word)
    # reconstruct segmentation using back pointers
    # back[7] = 4, back[4] = 0
    # | play | ing |
    # 0      4     7
    # result = [play, ing]
Example

Segmenting playing with the vocab {play:-1.2, ing:-0.8, pla:-2.0, y:-5.0}:

candidate A: pla · y · ing   → -2.0 + (-5.0) + (-0.8) = -7.8
candidate B: play · ing     → -1.2 + (-0.8) = -2.0 ✓ highest
Viterbi returns: [ play , ing ]
Try it — Viterbi best segmentation, step by step

Step moves to next characters, one at a time. At each time, it remembers the best way to split the word up to that point. Finally, it reconstructs the best split by tracing those remembered positions.

Interactive · Viterbi
Word
Vocab
Best cumulative log-prob at each position
Candidates ending at current position
Best segmentation
Vocabulary choice BPE merge rules
Encoding Viterbi (highest-scoring split)
Used by T5, XLNet, mBART, Llama-1/2