Bouw en bewaar je eigen Nederlandse tokenizer
Train een echte byte-BPE, bewaar hem in JSON en meet tokenlengtes en exacte Unicode-roundtrips op aparte tekst.
De opdracht, leerdoelen en voorkennis
- Je bouwt de complete training- en inferentieroute van een byte-BPE.
- Je meet compressie zonder de ongeziene tekst voor training te gebruiken.
Lees eerst: Gebruik de geleerde merges in hun vaste rangorde.
Nu bouw je een complete tokenizer die Nederlandse tekst exact kan teruggeven. Het notebook bevat drie eigen oefendocumenten; een aparte tekst blijft buiten training. Je leert merges, bewaart ze, laadt dezelfde ID-betekenissen terug en meet wat er werkelijk verandert.
Zie het project als een kleine proef met vaste meetafspraken. Minder tokens betekent minder eenheden voor een taalmodel, maar bewijst niet dat tokenizerbestand plus tekst minder opslagruimte kosten. Ook bewijst deze kleine proef geen betere taalmodelkwaliteit.
De voorbeelden hieronder gebruiken jouw functies uit de vorige lessen; de notebookcellen bouwen die functies opnieuw. Eerst volgen we een corpus dat met de hand te controleren is. Daarna voer je dezelfde route uit op de langere, meegeleverde teksten.
1. Tel en vervang op het kleine corpus
Neem de documenten “abab” en “ba”. Ab en ba komen ieder twee keer voor, maar hun numerieke paren zijn (97,98) en (98,97). De vaste tie-break kiest ab; één merge geeft [256,256] en [98,97].
small = ['abab','ba']
seqs = [list(t.encode('utf-8'))
for t in small]
print(pair_counts(seqs))
# {(97,98):2,(98,97):2}
merged = merge_pair(seqs[0],
(97,98),256)
print(merged)
# [256,256]
Maak pair_counts en merge_pair in de eerste cel, zonder lijsten of corpus te veranderen. Tellingen mogen overlappen; vervangingen verbruiken ieder token hoogstens één keer. Het corpus bevat bewust accenten, witruimte en een emoji, zodat bytes niet met codepunten verward raken.
012. Train uitsluitend op trainingsdocumenten
Met drie merges leert ons kleine corpus vervolgens ba→257 en abab→258. De totale lengtes zijn 6 vóór training en daarna 4,3,2. Die geschiedenis komt uit de echte tokenreeksen, niet uit een vooraf getekende dalende lijn.
model = train_bpe(small,budget=3)
print(model['merges'])
# [(97,98,256),(98,97,257),
# (256,256,258)]
print(model['lengths'])
# [6,4,3,2]
Maak train_bpe met dezelfde grenzen: maximaal 64 documenten van 4096 UTF-8-bytes en een exact integerbudget van 0 tot 128. Bewaar lengths vanaf vóór de eerste merge en stop eerder als er geen paar over is. Neem heldout nergens op in de lijst waarop je traint; bewaren als aparte variabele voorkomt nog niet dat je hem per ongeluk toevoegt.
023. Gebruik de geleerde ID-betekenissen
Voor “ababa” gebruikt deze tokenizer het nieuwe token voor abab plus byte97 voor de laatste a. Dat geeft [258,97], die samen exact dezelfde vijf bytes vormen. Deze nieuwe tekst bepaalt de merges niet opnieuw.
rules = model['merges']
tokens = encode('ababa',rules)
print(tokens) # [258,97]
print(decode(tokens,rules))
# ababa
Vul vocabulary, encode en decode in. Valideer de rangorde en verwijzingen, behoud alle basisbytes en controleer de 4096-bytegrens vóór concatenatie. Een onbekend Unicode-woord kan via zijn UTF-8-bytes worden gecodeerd; dat betekent niet dat een taalmodel de betekenis al kent.
034. Herstel exact hetzelfde model
Sla de regels op als JSON met versie 1 en laad ze terug als lijst tuples. Valideer beide kanten, inclusief de bestandsgrootte en verkeerde veldtypes. Een save/load hoort geen nieuwe training of sortering te doen: rij 258 moet abab blijven betekenen.
serialized = save_tokenizer(
model['merges'])
restored = load_tokenizer(
serialized)
print(restored == model['merges'])
# True
print(encode('ababa',restored))
# [258,97]
Fouten worden ValueError, ook als de JSON-parser afbreekt op een diep geneste invoer onder de tekstgrens. Gebruik hiervoor gerichte parserfoutafhandeling. Dit eigen formaat is geen claim van compatibiliteit met tiktoken of Hugging Face.
045. Meet echte aantallen en echte roundtrips
Laat experiment(documents,unseen,budget) trainen, opslaan, herstellen en teksten werkelijk encoderen en decoderen. Controleer ieder trainingsdocument, unseen, de lege tekst en een aanvullend Unicode-randgeval. Een vast True of een vergelijking van alleen lengtes kan tekstverlies niet aantonen.
Voor de kleine proef met unseen “ababa” zijn de trainingsbytes 6 en trainingtokens 2. De aparte tekst heeft vijf bytes en twee tokens. Verander je de argumenten, dan moeten deze rapportwaarden mee veranderen; gebruik dus geen globale corpusmetingen in de functie.
measured = experiment(
small,'ababa',3)
tb = measured['train_bytes']
tt = measured['train_tokens']
hb = measured['heldout_bytes']
ht = measured['heldout_tokens']
print(tb,tt) # 6 2
print(hb,ht) # 5 2
print(measured['roundtrips'])
# True
De werkelijke meegeleverde proef gebruikt 32 merges. Daar meet je 626 trainingsbytes naar 364 tokens en 86 heldout-bytes naar 56 tokens. Dat zijn meetwaarden voor dit corpus en deze instellingen, geen universele compressiefactor.
Je presentatie tekent de echte lengthsgeschiedenis en een aparte byte/tokenvergelijking voor heldout. Controleer de data achter beide assen; een bestaande plot met andere waarden is geen bewijs. Behoud de presentatie onder experiment en wijzig de gegeven teksten niet om een score te halen.
samples = small+[
'ababa','','e\u0301 🙂']
ok = []
for t in samples:
ids = encode(t,restored)
decoded = decode(ids,restored)
ok.append(decoded == t)
print(ok)
# [True,True,True,True,True]
05Wat neem je mee?
- Training leert merges; inferentie gebruikt hun vaste rangorde.
- Exacte tekst en vaste ID-betekenissen moeten ook na JSON-herstel kloppen.
- Rapporteer berekende gegevens en figuren, met heldout buiten training.
Hierna verbind je tokenrepresentaties aan positie-informatie en attention.
Bronnen: Sennrich, Haddow en Birch — subword-BPE (2016) ↗ Karpathy — byte-BPE als onderwijsimplementatie ↗ · Gecontroleerd 2026-10-28.
Maak de twee bouwstenen en inspecteer het corpus
Dit notebook bouwt de tokenizer volledig zelf. Open bovenaan de opdracht voor zes codevoorbeelden die dezelfde kleine proef doorlopen: documenten “abab” en “ba”, drie merges, opslag en een exacte tekst-roundtrip. Daarna pas je de route toe op de drie gegeven Nederlandse teksten.
Maak pair_counts en merge_pair. In het kleine corpus komen ab en ba ieder twee keer voor; de vaste numerieke tie-break kiest ab. De eerste reeks wordt [256,256], de tweede blijft [98,97]; tel nooit over de documentgrens en vervang zonder overlap.
Laat de gegeven imports, train_documents en heldout intact. Gebruik nieuwe resultaatlijsten; een helper die bij geen match de oorspronkelijke lijst teruggeeft, kan later het corpus onbedoeld veranderen. De heldout-tekst is geen extra trainingsdocument.
Bekijk voorbeeld
import json
import matplotlib.pyplot as plt
train_documents=[
'De kleine robot leert kijken. De robot ziet een kat en een fiets. De kat ligt naast de fiets. Een leerling verandert de invoer en bekijkt de uitvoer. De leerling schrijft Python en controleert de berekening.\n',
'Een model leert patronen uit voorbeelden. Een goed voorbeeld heeft een duidelijke invoer en een controleerbaar doel. We bewaren de testset apart. We bekijken het verlies tijdens het leren en kiezen een model met validatie.\n',
'In de werkplaats bouwen we een tokenizer. De tokenizer bewaart alle spaties, leestekens en nieuwe regels. Ook café, ideeën en een vrolijke 🙂 blijven dezelfde tekst na coderen en teruglezen.\n'
]
heldout='De leerling ziet een vrolijke kat bij het café.\nEen onbekend woord: wolkenfiets! 🙂'
def pair_counts(sequences):
counts={}
for tokens in sequences:
for pair in zip(tokens,tokens[1:]):
counts[pair]=counts.get(pair,0)+1
return counts
def merge_pair(tokens,pair,new_id):
result=[]
i=0
while i<len(tokens):
if i+1<len(tokens) and (tokens[i],tokens[i+1])==pair:
result.append(new_id)
i+=2
else:
result.append(tokens[i])
i+=1
return resultLeer alleen uit de trainingsdocumenten
Drie merges op “abab” en “ba” leveren [(97,98,256),(98,97,257),(256,256,258)] en lengths [6,4,3,2]. De laatste twee tokens blijven in afzonderlijke documenten staan; er ontstaat geen paar meer tussen hen.
Maak train_bpe(documents,budget=20). Start met UTF-8-byte-ID’s, tel na iedere merge opnieuw en kies op dalende frequentie met numeriek lexicografisch kleinste paar als tie-break. Geef regel r ID 256+r en bewaar de lengte vóór training plus na iedere echte merge.
Valideer maximaal 64 documenten van elk 4096 UTF-8-bytes en een exact integerbudget van 0 tot 128 met ValueError. “é” telt twee bytes, niet één; verander de aangeleverde documenten niet. Een leeg corpus of budget nul kan gewoon een lege mergelijst opleveren.
Bekijk voorbeeld
def train_bpe(documents,budget=20):
if type(budget) is not int or not 0<=budget<=128:
raise ValueError('Gebruik 0 tot 128 merges')
if not isinstance(documents,list) or len(documents)>64 or any(not isinstance(text,str) or len(text.encode('utf-8'))>4096 for text in documents):
raise ValueError('Gebruik maximaal 64 teksten van elk 4096 UTF-8-bytes')
sequences=[list(text.encode('utf-8')) for text in documents]
merges=[]
lengths=[sum(map(len,sequences))]
for rank in range(budget):
counts=pair_counts(sequences)
if not counts:
break
pair=min(counts,key=lambda p:(-counts[p],p))
new_id=256+rank
sequences=[merge_pair(tokens,pair,new_id) for tokens in sequences]
merges.append((pair[0],pair[1],new_id))
lengths.append(sum(map(len,sequences)))
return {'merges':merges,'lengths':lengths}Implementeer de vaste tokenwoordenschat en roundtrip
De drie kleine regels maken ID258 tot de bytes van abab. Nieuwe tekst “ababa” wordt daarom [258,97] en decodeert weer naar dezelfde vijf letters. De encoder kiest trainingsrang en leert niet opnieuw uit deze tekst.
Maak vocabulary, encode en decode volgens de vorige les. Houd alle 256 basisbytes beschikbaar, accepteer alleen geldige regels en controleer de maximale stuklengte van 4096 bytes vóór concatenatie. Decodeer één keer nadat alle tokenbytes achter elkaar staan.
Behoud hoofdletters, spaties, CRLF en samengestelde Unicode. “<|end|>” is hier gewone tekst; de oefentokenizer kent geen speciale besturingsmarker. Een onbekend of niet-geheel token-ID moet ValueError geven, geen stil herstel naar een andere rij.
Bekijk voorbeeld
def vocabulary(merges):
if not isinstance(merges,list) or len(merges)>128:
raise ValueError('Ongeldige mergelijst')
vocab={i:bytes([i]) for i in range(256)}
seen=set()
for rank,rule in enumerate(merges):
if not isinstance(rule,(list,tuple)) or len(rule)!=3 or any(type(n) is not int for n in rule):
raise ValueError('Ongeldige mergeregel')
a,b,new_id=rule
if a not in vocab or b not in vocab or new_id!=256+rank or (a,b) in seen:
raise ValueError('Onbekende of dubbele merge')
if len(vocab[a])+len(vocab[b])>4096:
raise ValueError('Tokenstuk groter dan 4096 bytes')
vocab[new_id]=vocab[a]+vocab[b]
seen.add((a,b))
return vocab
def encode(text,merges):
vocabulary(merges)
ranks={(a,b):(rank,new_id) for rank,(a,b,new_id) in enumerate(merges)}
tokens=list(text.encode('utf-8'))
while len(tokens)>1:
candidates={pair for pair in zip(tokens,tokens[1:]) if pair in ranks}
if not candidates:
break
pair=min(candidates,key=lambda p:ranks[p][0])
tokens=merge_pair(tokens,pair,ranks[pair][1])
return tokens
def decode(tokens,merges):
vocab=vocabulary(merges)
if any(type(t) is not int or t not in vocab for t in tokens):
raise ValueError('Onbekend token-ID')
return b''.join(vocab[t] for t in tokens).decode('utf-8')Bewaar en herstel exact dezelfde ID-betekenissen
Een save/load moet de regels in dezelfde volgorde en met dezelfde ID-betekenis herstellen. Voor het kleine voorbeeld blijft encode(“ababa”,restored) gelijk aan [258,97]. Een gelijk aantal regels alleen bewijst dat niet.
Maak save_tokenizer en load_tokenizer met alleen JSON-data, version=1 en merges. Valideer vóór opslaan en na laden; een echte integer-versie is verplicht en het bestand heeft maximaal 32768 teksttekens. Geef na laden een lijst tuples terug.
Vertaal ook RecursionError uit json.loads naar ValueError: een diep genest maar kort bestand kan de parserlimiet raken. Pretty JSON mag wel; de witruimte van het bestand is geen onderdeel van de token-ID-betekenis. Sorteer de regels niet opnieuw.
Bekijk voorbeeld
def save_tokenizer(merges):
vocabulary(merges)
return json.dumps({'version':1,'merges':merges},separators=(',',':'))
def load_tokenizer(text):
if not isinstance(text,str) or len(text)>32768:
raise ValueError('Ongeldig bestand')
try:
data=json.loads(text)
except RecursionError as error:
raise ValueError('Ongeldig JSON-bestand') from error
if not isinstance(data,dict) or set(data)!={'version','merges'} or type(data['version']) is not int or data['version']!=1:
raise ValueError('Ongeldig modelcontract')
vocabulary(data['merges'])
return [tuple(rule) for rule in data['merges']]Laat echte training, roundtrips en meetwaarden zien
Maak experiment(documents,unseen,budget) en meet uit de herstelde tokenizer. Voor [“abab”,“ba”], unseen “ababa” en budget3 hoort training 6→2 en heldout 5→2 te zijn. De functie moet ook bij andere argumenten werken; gebruik geen globale corpusmetingen.
Rapporteer model, serialized, restored, roundtrips, train_bytes, train_tokens, heldout_bytes en heldout_tokens. Test echte encode/decode-roundtrips van ieder trainingsdocument, unseen, lege tekst en een aanvullend Unicode-randgeval. Neem unseen nergens op in de training.
Behoud de presentatie onder de functie: zij tekent de echte lengthsgeschiedenis en beide heldoutmeetwaarden. De gegeven proef met 32 merges meet 626 bytes naar 364 tokens tijdens training en 86 bytes naar 56 tokens op de aparte tekst. Minder tokens bewijst geen totale opslagbesparing of betere taalmodelkwaliteit.
Bekijk voorbeeld
def experiment(documents,unseen,budget):
model=train_bpe(documents,budget)
serialized=save_tokenizer(model['merges'])
restored=load_tokenizer(serialized)
samples=documents+[unseen,'','e\u0301 é 中文 🙂\n <|end|>']
roundtrips=all(decode(encode(text,restored),restored)==text for text in samples)
return {'model':model,'serialized':serialized,'restored':restored,'roundtrips':roundtrips,
'train_bytes':sum(len(t.encode('utf-8')) for t in documents),
'train_tokens':sum(len(encode(t,restored)) for t in documents),
'heldout_bytes':len(unseen.encode('utf-8')),'heldout_tokens':len(encode(unseen,restored))}
result=experiment(train_documents,heldout,32)
if result is not None:
print('Geleerde merges:',len(result['restored']))
print('Training: bytes → tokens',result['train_bytes'],'→',result['train_tokens'])
print('Ongeziene tekst: bytes → tokens',result['heldout_bytes'],'→',result['heldout_tokens'])
print('Exacte tekst-roundtrips:',result['roundtrips'])
print('Tokenizer als JSON:',len(result['serialized'].encode('utf-8')),'bytes')
fig,axes=plt.subplots(2,1,figsize=(5,5.8))
lengths=result['model']['lengths']
axes[0].plot(range(len(lengths)),lengths,marker='.',color='#278b74')
axes[0].set(xlabel='Aantal geleerde merges',ylabel='Trainingtokens',title='Echte BPE-training')
axes[1].bar(['UTF-8','BPE'],[result['heldout_bytes'],result['heldout_tokens']],color=['#d48e4c','#278b74'])
axes[1].set(ylabel='Aantal eenheden',title='Apart gehouden tekst')
for ax in axes:
ax.tick_params(labelsize=12)
ax.xaxis.label.set_size(13)
ax.yaxis.label.set_size(13)
ax.title.set_size(14)
fig.tight_layout()