Enter any short text and set a target vocabulary size, then step through the BPE training loop one merge at a time. Each step shows you the most frequent pair and the updated token sequence.
<!-- BPE tokenizer demo — train a vocabulary step by step -->
<div class="controls">
<label for="corpus-input">Training text</label>
<textarea id="corpus-input" rows="3" placeholder="Enter some text…">the cat sat on the mat the cat ate the rat</textarea>
<div class="row">
<label for="vocab-size">Target vocab size:</label>
<input id="vocab-size" type="number" min="10" max="60" value="30" />
<button id="btn-init" type="button">Initialize</button>
</div>
</div>
<div class="panel" id="panel" style="display:none">
<div class="merge-row" id="merge-info"></div>
<div class="token-wrap" id="token-display"></div>
<div class="vocab-section">
<div class="vocab-label">Vocabulary <span id="vocab-count"></span></div>
<div id="vocab-display" class="vocab-chips"></div>
</div>
<div class="btns">
<button id="btn-step" type="button">Step (one merge)</button>
<button id="btn-run" type="button">Run all merges</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="status" id="status"></div>
</div>
/* root styles — tokens, vocab chips, controls */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.controls { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .8rem; }
label { font-weight: 600; font-size: .85rem; color: #444; }
textarea { width: 100%; border: 1px solid #cdd9e3; border-radius: 6px; padding: .4rem .6rem;
font: 13px/1.4 system-ui, sans-serif; resize: vertical; }
input[type=number] { width: 64px; border: 1px solid #cdd9e3; border-radius: 6px;
padding: .35rem .5rem; font-size: 13px; }
.row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .85rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .4; cursor: not-allowed; }
.panel { margin-top: .4rem; }
/* merge announcement banner */
.merge-row { background: #eef3fb; border: 1px solid #bcd0ef; border-radius: 8px;
padding: .5rem .8rem; font-size: .9rem; min-height: 2rem; margin-bottom: .6rem; }
.merge-row .hi { font-weight: 700; color: #1d3557; background: #d0e4ff;
border-radius: 4px; padding: 1px 4px; }
/* scrollable token sequence display */
.token-wrap { display: flex; flex-wrap: wrap; gap: 3px; margin-bottom: .7rem;
max-height: 120px; overflow-y: auto; border: 1px solid #e0e6ed;
border-radius: 8px; padding: .4rem; background: #fafbfc; }
.tok { border-radius: 4px; padding: 1px 5px; font: 13px/1.6 ui-monospace, monospace;
border: 1px solid transparent; }
.tok-char { background: #e8eef3; border-color: #cdd9e3; color: #1d3557; }
.tok-new { background: #d0f0d9; border-color: #7ecb9a; color: #145528; }
.tok-sep { color: #999; font-size: 11px; align-self: center; }
/* vocabulary chips panel */
.vocab-section { margin: .4rem 0 .6rem; }
.vocab-label { font-weight: 600; font-size: .82rem; color: #555; margin-bottom: .3rem; }
.vocab-chips { display: flex; flex-wrap: wrap; gap: 3px; max-height: 90px; overflow-y: auto; }
.chip { font: 12px/1.5 ui-monospace, monospace; background: #f0f4f8; border: 1px solid #d0d9e4;
border-radius: 4px; padding: 1px 6px; color: #334; }
.chip.new-chip { background: #d0f0d9; border-color: #7ecb9a; color: #145528; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .4rem; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.2em; }
.status.done { color: #0a7d33; }
.status.info { color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Byte-Pair Encoding training loop
// 1. Tokenize corpus into characters (+ </w> word boundary)
// 2. Count all adjacent symbol pairs across the token sequences
// 3. Merge the most frequent pair; add the merged symbol to vocab. Repeat.
let tokens = []; // current token sequences (one list of symbols per word)
let vocab = new Set(); // current vocabulary set
let merges = []; // ordered merge table (replayed at inference time)
let targetVocab = 30;
let running = false;
// Initialize: split text into words, then into characters
function initTokens(text) {
// extract lowercase words
const words = text.trim().toLowerCase().match(/[a-z']+/g) || [];
tokens = [];
vocab = new Set();
merges = [];
for (const w of words) {
const chars = w.split('');
// append </w> to mark end of word (standard BPE convention)
chars.push('</w>');
tokens.push(chars);
chars.forEach(c => vocab.add(c));
}
return tokens.length > 0;
}
// Count frequency of every adjacent symbol pair in the corpus
function countPairs() {
const freq = new Map();
for (const word of tokens) {
for (let i = 0; i < word.length - 1; i++) {
const key = word[i] + '\x00' + word[i + 1];
freq.set(key, (freq.get(key) || 0) + 1);
}
}
return freq;
}
// Find the single most-frequent adjacent pair
function bestPair(freq) {
let best = null, bestCount = 0;
for (const [k, v] of freq) {
if (v > bestCount) { best = k; bestCount = v; }
}
return best ? { pair: best.split('\x00'), count: bestCount } : null;
}
// Replace every occurrence of (a, b) with the merged symbol ab
function applyMerge(a, b) {
const merged = a + b;
for (const word of tokens) {
for (let i = 0; i < word.length - 1; ) {
if (word[i] === a && word[i + 1] === b) {
word.splice(i, 2, merged);
} else {
i++;
}
}
}
vocab.add(merged);
merges.push([a, b]);
}
// Render the current token sequences (first 12 words)
function renderTokens(highlight) {
const el = document.getElementById('token-display');
el.innerHTML = '';
// show only the first 12 words to keep the display manageable
const display = tokens.slice(0, 12);
for (let wi = 0; wi < display.length; wi++) {
if (wi > 0) {
const sep = document.createElement('span');
sep.className = 'tok tok-sep'; sep.textContent = '|';
el.appendChild(sep);
}
for (const t of display[wi]) {
const span = document.createElement('span');
const isNew = highlight && t === highlight[0] + highlight[1];
span.className = 'tok ' + (isNew ? 'tok-new' : 'tok-char');
span.textContent = t;
el.appendChild(span);
}
}
if (tokens.length > 12) {
const dots = document.createElement('span');
dots.className = 'tok tok-sep'; dots.textContent = '…';
el.appendChild(dots);
}
}
function renderVocab(newToken) {
const el = document.getElementById('vocab-display');
el.innerHTML = '';
document.getElementById('vocab-count').textContent =
'(' + vocab.size + ' / ' + targetVocab + ')';
// sort by length desc so multi-char tokens appear first
const sorted = [...vocab].sort((a, b) => b.length - a.length || a.localeCompare(b));
for (const v of sorted) {
const chip = document.createElement('span');
chip.className = 'chip' + (v === newToken ? ' new-chip' : '');
chip.textContent = v;
el.appendChild(chip);
}
}
function setStatus(msg, cls) {
const el = document.getElementById('status');
el.textContent = msg; el.className = 'status ' + (cls || '');
}
function step() {
if (vocab.size >= targetVocab) {
setStatus('Vocabulary complete — all merges applied.', 'done');
document.getElementById('btn-step').disabled = true;
document.getElementById('btn-run').disabled = true;
return false;
}
const freq = countPairs();
if (!freq.size) {
setStatus('No more pairs to merge.', 'done');
document.getElementById('btn-step').disabled = true;
document.getElementById('btn-run').disabled = true;
return false;
}
const top = bestPair(freq);
const merged = top.pair[0] + top.pair[1];
applyMerge(top.pair[0], top.pair[1]);
document.getElementById('merge-info').innerHTML =
fmt('Merged: <span class="hi">%v%</span> + <span class="hi">%v2%</span> → <span class="hi">%merged%</span> (×%count%)', { v: top.pair[0], v2: top.pair[1], merged: merged, count: top.count });
renderTokens(top.pair);
renderVocab(merged);
setStatus(fmt('Merge %length% applied.', { length: merges.length }), 'info');
return true;
}
document.getElementById('btn-init').addEventListener('click', () => {
const text = document.getElementById('corpus-input').value;
targetVocab = parseInt(document.getElementById('vocab-size').value, 10) || 30;
if (!initTokens(text)) { setStatus('Please enter some text first.', 'info'); return; }
document.getElementById('panel').style.display = 'block';
document.getElementById('merge-info').innerHTML = 'Ready — click Step to run one merge at a time.';
document.getElementById('btn-step').disabled = false;
document.getElementById('btn-run').disabled = false;
renderTokens(null);
renderVocab(null);
setStatus(fmt('Initialized with %size% base symbols. Click Step or Run all merges.', { size: vocab.size }), 'info');
});
document.getElementById('btn-step').addEventListener('click', step);
document.getElementById('btn-run').addEventListener('click', async () => {
if (running) return;
running = true;
document.getElementById('btn-run').disabled = true;
// animate each merge with a short delay so changes are visible
while (step()) {
await new Promise(r => setTimeout(r, 60));
}
running = false;
});
document.getElementById('btn-reset').addEventListener('click', () => {
document.getElementById('panel').style.display = 'none';
document.getElementById('merge-info').innerHTML = '';
tokens = []; vocab = new Set(); merges = [];
running = false;
});
<!-- BPE tokenizer demo — train a vocabulary step by step -->
<div class="controls">
<label for="corpus-input">Training text</label>
<textarea id="corpus-input" rows="3" placeholder="Enter some text…">the cat sat on the mat the cat ate the rat</textarea>
<div class="row">
<label for="vocab-size">Target vocab size:</label>
<input id="vocab-size" type="number" min="10" max="60" value="30" />
<button id="btn-init" type="button">Initialize</button>
</div>
</div>
<div class="panel" id="panel" style="display:none">
<div class="merge-row" id="merge-info"></div>
<div class="token-wrap" id="token-display"></div>
<div class="vocab-section">
<div class="vocab-label">Vocabulary <span id="vocab-count"></span></div>
<div id="vocab-display" class="vocab-chips"></div>
</div>
<div class="btns">
<button id="btn-step" type="button">Step (one merge)</button>
<button id="btn-run" type="button">Run all merges</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="status" id="status"></div>
</div>
/* root styles — tokens, vocab chips, controls */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.controls { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .8rem; }
label { font-weight: 600; font-size: .85rem; color: #444; }
textarea { width: 100%; border: 1px solid #cdd9e3; border-radius: 6px; padding: .4rem .6rem;
font: 13px/1.4 system-ui, sans-serif; resize: vertical; }
input[type=number] { width: 64px; border: 1px solid #cdd9e3; border-radius: 6px;
padding: .35rem .5rem; font-size: 13px; }
.row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .85rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff;
border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .4; cursor: not-allowed; }
.panel { margin-top: .4rem; }
/* merge announcement banner */
.merge-row { background: #eef3fb; border: 1px solid #bcd0ef; border-radius: 8px;
padding: .5rem .8rem; font-size: .9rem; min-height: 2rem; margin-bottom: .6rem; }
.merge-row .hi { font-weight: 700; color: #1d3557; background: #d0e4ff;
border-radius: 4px; padding: 1px 4px; }
/* scrollable token sequence display */
.token-wrap { display: flex; flex-wrap: wrap; gap: 3px; margin-bottom: .7rem;
max-height: 120px; overflow-y: auto; border: 1px solid #e0e6ed;
border-radius: 8px; padding: .4rem; background: #fafbfc; }
.tok { border-radius: 4px; padding: 1px 5px; font: 13px/1.6 ui-monospace, monospace;
border: 1px solid transparent; }
.tok-char { background: #e8eef3; border-color: #cdd9e3; color: #1d3557; }
.tok-new { background: #d0f0d9; border-color: #7ecb9a; color: #145528; }
.tok-sep { color: #999; font-size: 11px; align-self: center; }
/* vocabulary chips panel */
.vocab-section { margin: .4rem 0 .6rem; }
.vocab-label { font-weight: 600; font-size: .82rem; color: #555; margin-bottom: .3rem; }
.vocab-chips { display: flex; flex-wrap: wrap; gap: 3px; max-height: 90px; overflow-y: auto; }
.chip { font: 12px/1.5 ui-monospace, monospace; background: #f0f4f8; border: 1px solid #d0d9e4;
border-radius: 4px; padding: 1px 6px; color: #334; }
.chip.new-chip { background: #d0f0d9; border-color: #7ecb9a; color: #145528; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .4rem; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.2em; }
.status.done { color: #0a7d33; }
.status.info { color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Byte-Pair Encoding training loop
// 1. Tokenize corpus into characters (+ </w> word boundary)
// 2. Count all adjacent symbol pairs across the token sequences
// 3. Merge the most frequent pair; add the merged symbol to vocab. Repeat.
let tokens = []; // current token sequences (one list of symbols per word)
let vocab = new Set(); // current vocabulary set
let merges = []; // ordered merge table (replayed at inference time)
let targetVocab = 30;
let running = false;
// Initialize: split text into words, then into characters
function initTokens(text) {
// extract lowercase words
const words = text.trim().toLowerCase().match(/[a-z']+/g) || [];
tokens = [];
vocab = new Set();
merges = [];
for (const w of words) {
const chars = w.split('');
// append </w> to mark end of word (standard BPE convention)
chars.push('</w>');
tokens.push(chars);
chars.forEach(c => vocab.add(c));
}
return tokens.length > 0;
}
// Count frequency of every adjacent symbol pair in the corpus
function countPairs() {
const freq = new Map();
for (const word of tokens) {
for (let i = 0; i < word.length - 1; i++) {
const key = word[i] + '\x00' + word[i + 1];
freq.set(key, (freq.get(key) || 0) + 1);
}
}
return freq;
}
// Find the single most-frequent adjacent pair
function bestPair(freq) {
let best = null, bestCount = 0;
for (const [k, v] of freq) {
if (v > bestCount) { best = k; bestCount = v; }
}
return best ? { pair: best.split('\x00'), count: bestCount } : null;
}
// Replace every occurrence of (a, b) with the merged symbol ab
function applyMerge(a, b) {
const merged = a + b;
for (const word of tokens) {
for (let i = 0; i < word.length - 1; ) {
if (word[i] === a && word[i + 1] === b) {
word.splice(i, 2, merged);
} else {
i++;
}
}
}
vocab.add(merged);
merges.push([a, b]);
}
// Render the current token sequences (first 12 words)
function renderTokens(highlight) {
const el = document.getElementById('token-display');
el.innerHTML = '';
// show only the first 12 words to keep the display manageable
const display = tokens.slice(0, 12);
for (let wi = 0; wi < display.length; wi++) {
if (wi > 0) {
const sep = document.createElement('span');
sep.className = 'tok tok-sep'; sep.textContent = '|';
el.appendChild(sep);
}
for (const t of display[wi]) {
const span = document.createElement('span');
const isNew = highlight && t === highlight[0] + highlight[1];
span.className = 'tok ' + (isNew ? 'tok-new' : 'tok-char');
span.textContent = t;
el.appendChild(span);
}
}
if (tokens.length > 12) {
const dots = document.createElement('span');
dots.className = 'tok tok-sep'; dots.textContent = '…';
el.appendChild(dots);
}
}
function renderVocab(newToken) {
const el = document.getElementById('vocab-display');
el.innerHTML = '';
document.getElementById('vocab-count').textContent =
'(' + vocab.size + ' / ' + targetVocab + ')';
// sort by length desc so multi-char tokens appear first
const sorted = [...vocab].sort((a, b) => b.length - a.length || a.localeCompare(b));
for (const v of sorted) {
const chip = document.createElement('span');
chip.className = 'chip' + (v === newToken ? ' new-chip' : '');
chip.textContent = v;
el.appendChild(chip);
}
}
function setStatus(msg, cls) {
const el = document.getElementById('status');
el.textContent = msg; el.className = 'status ' + (cls || '');
}
function step() {
if (vocab.size >= targetVocab) {
setStatus('Vocabulary complete — all merges applied.', 'done');
document.getElementById('btn-step').disabled = true;
document.getElementById('btn-run').disabled = true;
return false;
}
const freq = countPairs();
if (!freq.size) {
setStatus('No more pairs to merge.', 'done');
document.getElementById('btn-step').disabled = true;
document.getElementById('btn-run').disabled = true;
return false;
}
const top = bestPair(freq);
const merged = top.pair[0] + top.pair[1];
applyMerge(top.pair[0], top.pair[1]);
document.getElementById('merge-info').innerHTML =
fmt('Merged: <span class="hi">%v%</span> + <span class="hi">%v2%</span> → <span class="hi">%merged%</span> (×%count%)', { v: top.pair[0], v2: top.pair[1], merged: merged, count: top.count });
renderTokens(top.pair);
renderVocab(merged);
setStatus(fmt('Merge %length% applied.', { length: merges.length }), 'info');
return true;
}
document.getElementById('btn-init').addEventListener('click', () => {
const text = document.getElementById('corpus-input').value;
targetVocab = parseInt(document.getElementById('vocab-size').value, 10) || 30;
if (!initTokens(text)) { setStatus('Please enter some text first.', 'info'); return; }
document.getElementById('panel').style.display = 'block';
document.getElementById('merge-info').innerHTML = 'Ready — click Step to run one merge at a time.';
document.getElementById('btn-step').disabled = false;
document.getElementById('btn-run').disabled = false;
renderTokens(null);
renderVocab(null);
setStatus(fmt('Initialized with %size% base symbols. Click Step or Run all merges.', { size: vocab.size }), 'info');
});
document.getElementById('btn-step').addEventListener('click', step);
document.getElementById('btn-run').addEventListener('click', async () => {
if (running) return;
running = true;
document.getElementById('btn-run').disabled = true;
// animate each merge with a short delay so changes are visible
while (step()) {
await new Promise(r => setTimeout(r, 60));
}
running = false;
});
document.getElementById('btn-reset').addEventListener('click', () => {
document.getElementById('panel').style.display = 'none';
document.getElementById('merge-info').innerHTML = '';
tokens = []; vocab = new Set(); merges = [];
running = false;
});
Watch how common substrings — "th", "he", "er" — get merged first, followed by full syllables and eventually whole common words. The same merge table produced here is exactly what a real tokenizer applies at inference time: it replays every merge in order on new text, splitting into the longest matching tokens.
Comments
Loading comments...