Electric Sheaves

Step 7 — Tokenizer and Sampler

Released with Lecture 7 · ~3 hours · Starts from: Step 6 (or solutions/step-06.ipynb).

In what follows, there are blocks of code provided for you. It’s important that you can read the code. If you are not proficient with Python, then I propose the following workflow. For each block of code, keywords are provided for the python syntax which is being used, so you can look up python syntax and functions you don’t recognise. To help, beneath many code blocks you will find Python background foldouts: self-contained tutorials, with live Python boxes you can edit and run right on the page, for syntax that is new in this step (syntax already introduced is covered in earlier steps’ foldouts). Then I suggest that you first experiment with changing the code or printing out intermediate variables, until you have figured out what you think it does. Then point AI toward the page and step number, provide your precise explanation of what you believe each line of the code is doing, and ask for corrections. In this way you will quickly become a code reader (if not a code writer). — Kate

Goal

Replace the two “toy” ends of your pipeline with the real thing: a byte-pair-encoding tokenizer you train yourself (entering the model), and a proper sampler with temperature, top-kk, and nucleus decoding (leaving it). Plus: pool the class’s Step 6 experiments into a homemade scaling plot.

On paper first

  1. BPE as greedy compression. The algorithm: start with the byte/char alphabet; repeatedly find the most frequent adjacent pair of tokens in the corpus and merge it into a new token; stop after MM merges. Argue each merge is the greedy step reducing corpus length (in tokens) the most among all single merges. What quantity per merge would a Huffman-style optimal scheme control instead, and why is BPE’s greediness fine in practice?
  2. Tokenization changes the units of perplexity. Your model reports loss in nats per token. Show that the honest, tokenizer-independent metric is bits per character: bpc=(nats/token)ln2#tokens#chars\text{bpc} = \dfrac{(\text{nats/token})}{\ln 2} \cdot \dfrac{\#\text{tokens}}{\#\text{chars}}, and explain the fallacy of comparing per-token perplexities across different vocabularies (a tokenizer that halves the token count must roughly double the per-token difficulty just to break even).
  3. Sampling as tilting. Temperature τ\tau replaces piezip_i \propto e^{z_i} with piezi/τp_i \propto e^{z_i/\tau}. Identify the limits τ0\tau \to 0 and τ\tau \to \infty, and show sampling at τ\tau is sampling from the distribution minimizing E[-logit]+τ1(entropy penalty)\mathbb E[\text{-logit}] + \tau^{-1}\cdot(\text{entropy penalty})… i.e. the Gibbs variational principle, one more time.

Tasks

7.1 BPE trainer

On the raw Shakespeare text, with the 65-character vocabulary as the base alphabet, implement:

def get_pair_counts(ids):            # ids: list of ints
    ...                              # Ellipsis: you write this — count adjacent pairs

def merge(ids, pair, new_id):        # replace every occurrence of `pair` by `new_id`
    ...

merges = {}                          # empty dictionary; (id, id) -> new_id, in training order
ids = encode(text)                   # your Step 1 char-level encode
for m in range(NUM_MERGES):          # start with NUM_MERGES = 256
    pair = max(get_pair_counts(ids), key=...)   # max with a key= function
    merges[pair] = V + m             # dictionary assignment; the key is a tuple
    ids = merge(ids, pair, V + m)
Python background: max with key=

max(items) compares the items themselves; max(items, key=f) compares f(item) instead and returns the item (not the score) that scores highest. Iterating over a dictionary yields its keys, so with a count dictionary, max(counts, key=counts.get) means “the key with the largest count” — precisely the most-frequent-pair step of BPE. (min and sorted accept key= the same way.)

words = ['to', 'be', 'or', 'not']
print(max(words))                    # alphabetical winner
print(max(words, key=len))           # the longest word wins instead
counts = {'th': 8, 'he': 5, 'e ': 11}
print(max(counts))                   # max over KEYS: alphabetical again
print(max(counts, key=counts.get))   # the key with the biggest count
Python background: tuples as dictionary keys

Dictionary keys can be any immutable value — strings, numbers, and tuples all work (lists do not: they can change, so they cannot be looked up reliably). A tuple key like ('t', 'h') is exactly what the merge table needs: the pair itself indexes its new token id.

merges = {}                    # an empty dictionary
merges[('t', 'h')] = 65        # a tuple as the key
merges[('h', 'e')] = 66
print(merges)
print(merges[('t', 'h')])
pair = ('h', 'e')
print(pair in merges)          # membership test works on keys

Log the first 30 merges with their string forms. You’ll watch English assemble itself out of pure frequency counting — the first ten on Shakespeare are

 0 'e '   1 'th'   2 't '   3 's '   4 'd '
 5 ', '   6 'ou'   7 'er'   8 'in'   9 'y '

then an, :\n, or, o , en, \n\n, … Three things to notice and write a sentence about: (i) word-final patterns like e , t , s dominate the very top — the space is the most predictable character in English; (ii) :\n and \n\n are the model discovering the play format (SPEAKER: then newline, blank line between speeches) with no notion of what a play is; (iii) by merge 28 you get whole words (and ), i.e. BPE crosses from morphology into vocabulary on its own.

Report the compression ratio chars/token at M{64,256,512,1024}M \in \{64, 256, 512, 1024\} merges.

7.2 Encoder / decoder

7.3 Retrain on BPE tokens

Retokenize the corpus with M=256M = 256 merges (V=321V' = 321, 568,210 tokens), and retrain your Step 6 model (same config otherwise; the embedding and unembedding grow to VV'). Two things to confront:

7.4 The sampler

One function, replacing plain multinomial sampling:

def sample_next(logits, temperature=1.0, top_k=None, top_p=None):  # None default = optional argument
    ...
Python background: None as an optional-argument default

None is Python’s “no value here” object. Defaulting an argument to None is the standard way to mark it optional: inside the function you test if top_k is not None: and apply the feature only when the caller actually supplied a value. (Use is None / is not None for this test — None is a unique object, and identity is the idiomatic check.)

def describe(name, nickname=None):     # nickname is optional
    if nickname is None:
        return name
    return name + ', known as ' + nickname
print(describe('Katherine'))
print(describe('Katherine', nickname='Kate'))
x = None
print(x is None)
print(x == 0, x == '')     # None is not 0, not empty string — it's its own thing

Then a decoding gallery from your trained model — same prompt ("ROMEO:"), generations at: τ{0.3,0.8,1.0,1.5}\tau \in \{0.3, 0.8, 1.0, 1.5\}; greedy (τ0\tau \to 0); top-k=5k{=}5; top-p=0.9p{=}0.9. Annotate: where does greedy fall into a loop? (It will — Holtzman et al.’s degeneration, on your own model.) Where does τ=1.5\tau{=}1.5 lose English? Which setting reads best, and what does that say about the mismatch between “highest likelihood” and “best text”?

7.5 Homemade scaling law

Pool the class’s Step 6.5 tables (or run your own sweep: d{32,64,128,256}d \in \{32, 64, 128, 256\} at L=4L=4). Plot best val loss vs parameter count on log–log axes and fit a line through the non-saturated points. Report your exponent; Kaplan et al. got 0.076\approx 0.076 for loss vs params (very different data and scale — the point is the straightness, not the number). One paragraph: where must your line break down, at each end, on 1MB of Shakespeare? (Irreducible entropy on one end; the Lecture 7 data-bottleneck discussion on the other.)

Checkpoints

If you’re stuck

Going further (optional)

Catch-up

solutions/step-06.ipynb gives you a trained checkpoint (load with torch.load); 7.1–7.2 and 7.4 need no training at all, so this step is another good re-entry point.