# Build vocabulary from text
text = "hello world"
chars = sorted(set(text))
print(f"Unique characters: {chars}")
print(f"Vocabulary size: {len(chars)}")Unique characters: [' ', 'd', 'e', 'h', 'l', 'o', 'r', 'w']
Vocabulary size: 8
d3 = import("../../assets/vendor/d3/d3.esm.js")
// =============================================================================
// THEME DETECTION
// =============================================================================
// Reactive value that tracks Quarto's native dark mode and re-renders diagrams
// when the theme toggles. Generators.observe makes this a live OJS dependency:
// any cell referencing isDarkMode re-runs whenever the body/html class changes.
isDarkMode = Generators.observe(notify => {
const check = () =>
document.body.classList.contains('quarto-dark') ||
document.documentElement.classList.contains('quarto-dark');
notify(check());
const observer = new MutationObserver(() => notify(check()));
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect();
})
// =============================================================================
// CSS VARIABLE UTILITIES
// =============================================================================
// Function to read CSS custom property values from the document.
// Reads from <body> because Quarto applies the .quarto-dark class there, so the
// dark-mode variable overrides resolve on the body element, not <html>.
getCSSVar = function(name, fallback = null) {
if (typeof document === 'undefined') return fallback;
const value = getComputedStyle(document.body).getPropertyValue(name).trim();
return value || fallback;
}
// =============================================================================
// THEME OBJECT
// =============================================================================
// Object containing all diagram colors read from CSS variables
// Falls back to hardcoded values if CSS vars not available
diagramTheme = {
// Light-mode fallback values (used if CSS vars are unavailable)
const lightFallbacks = {
nodeFill: '#f5f5f4',
nodeFillHover: '#e7e5e4',
nodeStroke: '#d6d3d1',
nodeText: '#1c1917',
edgeStroke: '#78716c',
highlight: '#f97316',
highlightGlow: 'rgba(249, 115, 22, 0.3)',
highlightBg: 'rgba(249, 115, 22, 0.1)',
accent: '#0ea5e9',
accentGlow: 'rgba(14, 165, 233, 0.3)',
textOnHighlight: '#1c1917',
textOnAccent: '#1c1917',
// Text sitting on a fully-saturated *status* fill (break-red / run-green /
// any deep status hue). Unlike textOnHighlight/textOnAccent (dark in both
// themes, because the orange highlight & sky accent stay mid-tone), the
// status hues flip: DEEP in light (#dc2626/#059669 → white reads) but
// BRIGHTENED in dark (#f87171/#34d399 → dark text reads). So this token
// flips too — white in light, near-black in dark — clearing AA on the
// status fills in both themes. One token so figures stop hard-coding "#fff".
textOnFill: '#ffffff',
bg: '#fafaf9',
bgSecondary: '#f5f5f4',
// Semantic colors for status/feedback — aliased onto the canonical status/
// phase hues (DESIGN_LANGUAGE.md §2/pillar 2): error≡--pg-break (was
// already byte-identical), success≡--pg-run, info≡--ph-attn (2026-07-11,
// was ad-hoc #16a34a/#2563eb — a "seventh blue" and a second green).
error: '#dc2626',
errorBg: 'rgba(220, 38, 38, 0.1)',
success: '#059669',
successBg: 'rgba(5, 150, 105, 0.1)',
info: '#0284c7',
infoBg: 'rgba(2, 132, 199, 0.1)',
// Pipeline phase palette (DESIGN_LANGUAGE §2) — the site's canonical accent
// grammar, mirrored from the playground north star (playground/ui/kit.js).
// Any diagram that colors a pipeline stage reads its hue from here so lesson
// figures and the debugger share one palette. Status hues (run/break) too.
phaseTok: '#7c3aed',
phaseEmb: '#4f46e5',
phaseAttn: '#0284c7',
phaseMlp: '#0d9488',
phaseNorm: '#64748b',
phaseLogits: '#d97706',
phaseSample: '#e11d48',
run: '#059669',
break: '#dc2626'
};
// Dark-mode fallbacks + brighter semantic colors for readability on dark.
const darkFallbacks = {
nodeFill: '#292524',
nodeFillHover: '#3f3a36',
nodeStroke: '#57534e',
nodeText: '#fafaf9',
edgeStroke: '#a8a29e',
highlight: '#fb923c',
highlightGlow: 'rgba(251, 146, 60, 0.4)',
highlightBg: 'rgba(251, 146, 60, 0.18)',
accent: '#38bdf8',
accentGlow: 'rgba(56, 189, 248, 0.4)',
textOnHighlight: '#1c1917',
textOnAccent: '#1c1917',
// Dark twin: status fills brighten on dark (#f87171/#34d399), so dark text
// reads on them — the flip described on the light textOnFill above.
textOnFill: '#1c1917',
bg: 'transparent',
bgSecondary: '#1c1917',
error: '#f87171',
errorBg: 'rgba(248, 113, 113, 0.18)',
success: '#34d399',
successBg: 'rgba(52, 211, 153, 0.18)',
info: '#38bdf8',
infoBg: 'rgba(56, 189, 248, 0.18)',
// Phase palette — dark twin (brighter for legibility on dark surfaces),
// mirroring the playground's .quarto-dark --ph-*/--pg-* values exactly.
phaseTok: '#a78bfa',
phaseEmb: '#818cf8',
phaseAttn: '#38bdf8',
phaseMlp: '#2dd4bf',
phaseNorm: '#94a3b8',
phaseLogits: '#fbbf24',
phaseSample: '#fb7185',
run: '#34d399',
break: '#f87171'
};
// Referencing isDarkMode here makes this cell reactive: it recomputes (and all
// diagrams that read it re-render) whenever the theme is toggled.
const fallbacks = isDarkMode ? darkFallbacks : lightFallbacks;
// Resolved once so `bgOpaque` can be derived from them (an object literal
// can't reference its own siblings).
const bg = getCSSVar('--diagram-bg', fallbacks.bg);
const bgSecondary = getCSSVar('--diagram-bg-secondary', fallbacks.bgSecondary);
return {
nodeFill: getCSSVar('--diagram-node-fill', fallbacks.nodeFill),
nodeFillHover: getCSSVar('--diagram-hover-fill', fallbacks.nodeFillHover),
nodeStroke: getCSSVar('--diagram-node-stroke', fallbacks.nodeStroke),
nodeText: getCSSVar('--diagram-node-text', fallbacks.nodeText),
edgeStroke: getCSSVar('--diagram-edge-stroke', fallbacks.edgeStroke),
highlight: getCSSVar('--diagram-highlight', fallbacks.highlight),
highlightGlow: getCSSVar('--diagram-highlight-glow', fallbacks.highlightGlow),
highlightBg: getCSSVar('--diagram-highlight-bg', fallbacks.highlightBg),
accent: getCSSVar('--diagram-accent', fallbacks.accent),
accentGlow: getCSSVar('--diagram-accent-glow', fallbacks.accentGlow),
textOnHighlight: fallbacks.textOnHighlight,
textOnAccent: fallbacks.textOnAccent,
textOnFill: fallbacks.textOnFill,
bg,
bgSecondary,
// `bg`, but guaranteed PAINTABLE. `--diagram-bg` is deliberately
// `transparent` in dark mode so a figure sits *on* the article surface
// instead of stamping a light slab onto it — which is right for a figure's
// own background rect, and wrong everywhere `bg` is used as *ink*:
//
// * a mark's **halo ring** (`<circle stroke=theme.bg stroke-width=2>`),
// drawn so a point stays legible where it crosses its own line — with a
// transparent stroke there is no ring at all;
// * **knockout text** (`<text fill=theme.bg>`), the page-colored label on
// an active/filled node — with a transparent fill the label is *invisible*.
//
// Both silently broke in dark and were fine in light, which is why they
// survived eight page sweeps. This token keeps `bg` when it is paintable and
// falls back to the opaque `bgSecondary` (#f5f5f4 light / #1c1917 dark) when
// it is not, so ink survives both themes. Rule: a figure's own background
// rect fills `bg`; anything that PAINTS (stroke or text fill) uses
// `bgOpaque` — never `theme.bg`, never a bare "#fff".
bgOpaque: (!bg || bg === 'transparent' || bg === 'none') ? bgSecondary : bg,
// Semantic colors — now real --diagram-* tokens (2026-07-11), aliased onto
// --pg-break/--pg-run/--ph-attn at the shared layer so they're overridable
// and theme-reactive the same way every other diagram color is.
error: getCSSVar('--diagram-error', fallbacks.error),
errorBg: getCSSVar('--diagram-error-bg', fallbacks.errorBg),
success: getCSSVar('--diagram-success', fallbacks.success),
successBg: getCSSVar('--diagram-success-bg', fallbacks.successBg),
info: getCSSVar('--diagram-info', fallbacks.info),
infoBg: getCSSVar('--diagram-info-bg', fallbacks.infoBg),
// Primary accent — the site's canonical UI accent (embeddings indigo,
// DESIGN_LANGUAGE §2: `--accent-primary ≈ --ph-emb`). Several lessons
// (m01/m04/m06) already read `theme.primary` for their featured series, but
// the token was never defined here, so it resolved to `undefined` and those
// marks fell back to browser-default black in both themes. Defining it fixes
// that everywhere, theme-reactively.
primary: getCSSVar('--ph-emb', fallbacks.phaseEmb),
// Pipeline phase palette + status hues. Read from the canonical --ph-*/--pg-*
// tokens where present (they get seeded into custom.scss as the shared token
// work lands), else the phase fallbacks above — so a figure is on-palette and
// theme-reactive whether or not the site tokens exist yet. Grouped under
// `phase` (matching playground/ui/kit.js) plus flat run/break status hues.
phase: {
tok: getCSSVar('--ph-tok', fallbacks.phaseTok),
emb: getCSSVar('--ph-emb', fallbacks.phaseEmb),
attn: getCSSVar('--ph-attn', fallbacks.phaseAttn),
mlp: getCSSVar('--ph-mlp', fallbacks.phaseMlp),
norm: getCSSVar('--ph-norm', fallbacks.phaseNorm),
logits: getCSSVar('--ph-logits', fallbacks.phaseLogits),
sample: getCSSVar('--ph-sample', fallbacks.phaseSample)
},
run: getCSSVar('--pg-run', fallbacks.run),
break: getCSSVar('--pg-break', fallbacks.break),
isDark: isDarkMode
};
}
// =============================================================================
// SVG PRIMITIVES
// =============================================================================
// Creates a group with rounded rect and text
// Options: {x, y, width, height, label, sublabel, id, theme, rx, ry, className}
createNode = function(svg, options) {
const {
x = 0,
y = 0,
width = 100,
height = 50,
label = '',
sublabel = '',
id = null,
theme = diagramTheme,
rx = 6,
ry = 6,
className = 'diagram-node'
} = options;
// Create group
const g = svg.append('g')
.attr('class', className)
.attr('transform', `translate(${x}, ${y})`);
if (id) g.attr('id', id);
// Add rectangle
g.append('rect')
.attr('x', -width / 2)
.attr('y', -height / 2)
.attr('width', width)
.attr('height', height)
.attr('rx', rx)
.attr('ry', ry)
.attr('fill', theme.nodeFill)
.attr('stroke', theme.nodeStroke)
.attr('stroke-width', 1.5);
// Add main label
if (label) {
const labelY = sublabel ? -6 : 0;
g.append('text')
.attr('x', 0)
.attr('y', labelY)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', theme.nodeText)
.attr('font-size', '12px')
.attr('font-weight', '500')
.attr('pointer-events', 'none')
.text(label);
}
// Add sublabel
if (sublabel) {
g.append('text')
.attr('x', 0)
.attr('y', 10)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', theme.nodeText)
.attr('font-size', '10px')
.attr('opacity', 0.7)
.attr('pointer-events', 'none')
.text(sublabel);
}
return g;
}
// Creates a path with arrowhead marker
// Options: {x1, y1, x2, y2, label, theme, curved, curvature, id, className, dashed}
createArrow = function(svg, options) {
const {
x1 = 0,
y1 = 0,
x2 = 100,
y2 = 0,
label = '',
theme = diagramTheme,
curved = false,
curvature = 0.3,
id = null,
className = 'diagram-edge',
dashed = false
} = options;
// Create unique marker ID
const markerId = `arrow-${Math.random().toString(36).substr(2, 9)}`;
// Ensure defs exists
let defs = svg.select('defs');
if (defs.empty()) {
defs = svg.append('defs');
}
// Add arrowhead marker
defs.append('marker')
.attr('id', markerId)
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', theme.edgeStroke);
// Create group for arrow
const g = svg.append('g')
.attr('class', className);
if (id) g.attr('id', id);
// Calculate path
let pathD;
if (curved) {
// Quadratic Bezier curve
const midX = (x1 + x2) / 2;
const midY = (y1 + y2) / 2;
const dx = x2 - x1;
const dy = y2 - y1;
// Perpendicular offset for curve
const cx = midX - dy * curvature;
const cy = midY + dx * curvature;
pathD = `M${x1},${y1} Q${cx},${cy} ${x2},${y2}`;
} else {
// Straight line
pathD = `M${x1},${y1} L${x2},${y2}`;
}
// Add path
const path = g.append('path')
.attr('d', pathD)
.attr('fill', 'none')
.attr('stroke', theme.edgeStroke)
.attr('stroke-width', 1.5)
.attr('marker-end', `url(#${markerId})`);
if (dashed) {
path.attr('stroke-dasharray', '5,3');
}
// Add label if provided
if (label) {
const labelX = (x1 + x2) / 2;
const labelY = (y1 + y2) / 2;
// Offset label perpendicular to line
const angle = Math.atan2(y2 - y1, x2 - x1);
const offsetX = Math.sin(angle) * 12;
const offsetY = -Math.cos(angle) * 12;
g.append('text')
.attr('x', labelX + offsetX)
.attr('y', labelY + offsetY)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', theme.nodeText)
.attr('font-size', '10px')
.text(label);
}
return g;
}
// =============================================================================
// STEP ANIMATION CONTROLLER
// =============================================================================
// Factory function returning controller for step-through animations
// Options: {total, initialStep, speed, loop, onStepChange}
createStepController = function(options = {}) {
const {
total = 1,
initialStep = 0,
speed = 1000,
loop = true,
onStepChange = null
} = options;
let current = initialStep;
let isPlaying = false;
let intervalId = null;
let currentSpeed = speed;
const notifyChange = () => {
if (onStepChange && typeof onStepChange === 'function') {
onStepChange(current);
}
};
const controller = {
get current() { return current; },
get isPlaying() { return isPlaying; },
get total() { return total; },
get speed() { return currentSpeed; },
setStep(step) {
current = Math.max(0, Math.min(total - 1, step));
notifyChange();
return current;
},
next() {
if (current < total - 1) {
current++;
} else if (loop) {
current = 0;
}
notifyChange();
return current;
},
prev() {
if (current > 0) {
current--;
} else if (loop) {
current = total - 1;
}
notifyChange();
return current;
},
play() {
if (isPlaying) return;
isPlaying = true;
intervalId = setInterval(() => {
controller.next();
}, currentSpeed);
},
stop() {
isPlaying = false;
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
},
toggle() {
if (isPlaying) {
controller.stop();
} else {
controller.play();
}
},
reset() {
controller.stop();
current = initialStep;
notifyChange();
},
setSpeed(newSpeed) {
currentSpeed = newSpeed;
if (isPlaying) {
controller.stop();
controller.play();
}
}
};
return controller;
}
// =============================================================================
// FLOW DIAGRAM COMPONENT
// =============================================================================
// Higher-level component for node/edge diagrams
// Options: {nodes, edges, width, height, activeNodes, activeEdges, theme, nodeWidth, nodeHeight, padding}
FlowDiagram = function(options) {
const {
nodes = [],
edges = [],
width = 600,
height = 400,
activeNodes = [],
activeEdges = [],
theme = diagramTheme,
nodeWidth = 100,
nodeHeight = 50,
padding = 20
} = options;
// Create SVG element
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`)
.attr('class', 'flow-diagram');
// Add background
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', theme.bg)
.attr('rx', 8);
// Create defs for markers
const defs = svg.append('defs');
// Standard arrow marker
defs.append('marker')
.attr('id', 'flow-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', theme.edgeStroke);
// Highlighted arrow marker
defs.append('marker')
.attr('id', 'flow-arrow-highlight')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', theme.highlight);
// Edges layer (draw first so nodes appear on top)
const edgesLayer = svg.append('g').attr('class', 'edges-layer');
// Nodes layer
const nodesLayer = svg.append('g').attr('class', 'nodes-layer');
// Draw edges
edges.forEach((edge, i) => {
const sourceNode = nodes.find(n => n.id === edge.source);
const targetNode = nodes.find(n => n.id === edge.target);
if (!sourceNode || !targetNode) return;
const isActive = activeEdges.includes(edge.id) || activeEdges.includes(i);
const edgeColor = isActive ? theme.highlight : theme.edgeStroke;
const markerId = isActive ? 'flow-arrow-highlight' : 'flow-arrow';
// Calculate edge path
const x1 = sourceNode.x;
const y1 = sourceNode.y;
const x2 = targetNode.x;
const y2 = targetNode.y;
// Shorten path to not overlap with node edges
const dx = x2 - x1;
const dy = y2 - y1;
const len = Math.sqrt(dx * dx + dy * dy);
const offsetStart = (nodeWidth / 2) + 5;
const offsetEnd = (nodeWidth / 2) + 10;
const startX = x1 + (dx / len) * offsetStart;
const startY = y1 + (dy / len) * offsetStart;
const endX = x2 - (dx / len) * offsetEnd;
const endY = y2 - (dy / len) * offsetEnd;
const edgeGroup = edgesLayer.append('g')
.attr('class', `edge ${isActive ? 'highlighted' : ''}`);
if (edge.id) edgeGroup.attr('id', edge.id);
// Draw path
let pathD;
if (edge.curved) {
const midX = (startX + endX) / 2;
const midY = (startY + endY) / 2;
const curvature = edge.curvature || 0.2;
const cx = midX - dy * curvature;
const cy = midY + dx * curvature;
pathD = `M${startX},${startY} Q${cx},${cy} ${endX},${endY}`;
} else {
pathD = `M${startX},${startY} L${endX},${endY}`;
}
const path = edgeGroup.append('path')
.attr('d', pathD)
.attr('fill', 'none')
.attr('stroke', edgeColor)
.attr('stroke-width', isActive ? 2.5 : 1.5)
.attr('marker-end', `url(#${markerId})`);
if (edge.dashed) {
path.attr('stroke-dasharray', '5,3');
}
if (isActive) {
path.attr('filter', `drop-shadow(0 0 4px ${theme.highlightGlow})`);
}
// Add label if present
if (edge.label) {
const labelX = (startX + endX) / 2;
const labelY = (startY + endY) / 2;
const angle = Math.atan2(endY - startY, endX - startX);
const offsetX = Math.sin(angle) * 14;
const offsetY = -Math.cos(angle) * 14;
edgeGroup.append('text')
.attr('x', labelX + offsetX)
.attr('y', labelY + offsetY)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', isActive ? theme.highlight : theme.nodeText)
.attr('font-size', '10px')
.text(edge.label);
}
});
// Draw nodes
nodes.forEach((node, i) => {
const isActive = activeNodes.includes(node.id) || activeNodes.includes(i);
const nodeFill = isActive ? theme.highlight : theme.nodeFill;
const nodeStroke = isActive ? theme.highlight : theme.nodeStroke;
const textFill = isActive ? theme.textOnHighlight : theme.nodeText;
const nodeGroup = nodesLayer.append('g')
.attr('class', `node ${isActive ? 'highlighted' : ''}`)
.attr('transform', `translate(${node.x}, ${node.y})`);
if (node.id) nodeGroup.attr('id', node.id);
// Node rectangle
const rect = nodeGroup.append('rect')
.attr('x', -nodeWidth / 2)
.attr('y', -nodeHeight / 2)
.attr('width', node.width || nodeWidth)
.attr('height', node.height || nodeHeight)
.attr('rx', 6)
.attr('ry', 6)
.attr('fill', nodeFill)
.attr('stroke', nodeStroke)
.attr('stroke-width', isActive ? 2 : 1.5);
if (isActive) {
rect.attr('filter', `drop-shadow(0 0 6px ${theme.highlightGlow})`);
}
// Main label
const labelY = node.sublabel ? -6 : 0;
nodeGroup.append('text')
.attr('x', 0)
.attr('y', labelY)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', textFill)
.attr('font-size', '12px')
.attr('font-weight', '500')
.attr('pointer-events', 'none')
.text(node.label || '');
// Sublabel
if (node.sublabel) {
nodeGroup.append('text')
.attr('x', 0)
.attr('y', 10)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', textFill)
.attr('font-size', '10px')
.attr('opacity', isActive ? 0.9 : 0.7)
.attr('pointer-events', 'none')
.text(node.sublabel);
}
});
return svg.node();
}
// =============================================================================
// EXPORTS
// =============================================================================
// Export everything as a single object for lessons to use
diagramLib = {
// Core dependencies
d3,
// Theme utilities
isDarkMode,
getCSSVar,
diagramTheme,
// SVG primitives
createNode,
createArrow,
// Animation controller
createStepController,
// Components
FlowDiagram
}/**
* Segmented step control for visualization stepping.
* @param {Object} options
* @param {number} options.min - Minimum step value (default 0)
* @param {number} options.max - Maximum step value
* @param {number} options.value - Initial value (default min)
* @param {string} options.label - Optional label text
* @returns {number} Current step value (reactive)
*/
stepControl = function({min = 0, max, value, label = null} = {}) {
const initialValue = value ?? min;
const steps = Array.from({length: max - min + 1}, (_, i) => min + i);
const container = htl.html`<div class="step-control">
${label ? htl.html`<span class="step-control-label">${label}</span>` : ''}
<div class="step-control-segments" role="group" aria-label="${label || 'Step control'}">
${steps.map(step => htl.html`<button
class="step-control-segment ${step === initialValue ? 'active' : ''}"
data-step="${step}"
aria-pressed="${step === initialValue}"
tabindex="${step === initialValue ? 0 : -1}"
>${step}</button>`)}
</div>
</div>`;
const segments = container.querySelectorAll('.step-control-segment');
let currentValue = initialValue;
function updateActive(newValue) {
currentValue = newValue;
segments.forEach(seg => {
const isActive = parseInt(seg.dataset.step) === newValue;
seg.classList.toggle('active', isActive);
seg.setAttribute('aria-pressed', isActive);
seg.tabIndex = isActive ? 0 : -1;
});
container.value = newValue;
container.dispatchEvent(new Event('input', {bubbles: true}));
}
// Click handler
segments.forEach(seg => {
seg.addEventListener('click', () => {
updateActive(parseInt(seg.dataset.step));
});
});
// Keyboard navigation
container.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
const next = Math.min(currentValue + 1, max);
updateActive(next);
segments[next - min].focus();
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
const prev = Math.max(currentValue - 1, min);
updateActive(prev);
segments[prev - min].focus();
} else if (e.key === 'Home') {
e.preventDefault();
updateActive(min);
segments[0].focus();
} else if (e.key === 'End') {
e.preventDefault();
updateActive(max);
segments[max - min].focus();
}
});
container.value = initialValue;
return container;
}A language model requires numbers, not text. Tokenization breaks text into tokens and maps each to an integer.
Tokenization converts raw text into integers the model can process. Modern LLMs use subword tokenization - they break text into pieces smaller than words but larger than characters.
Why subword tokenization?
BPE (Byte Pair Encoding) dominates modern tokenization. Philip Gage invented it for data compression in 1994; researchers adapted it for NLP in 2016:
After this module, you can:
<UNK>This module requires familiarity with:
First, build the simplest tokenizer from scratch.
The simplest approach treats each character as a token.
Unique characters: [' ', 'd', 'e', 'h', 'l', 'o', 'r', 'w']
Vocabulary size: 8
stoi (encode): {' ': 0, 'd': 1, 'e': 2, 'h': 3, 'l': 4, 'o': 5, 'r': 6, 'w': 7}
itos (decode): {0: ' ', 1: 'd', 2: 'e', 3: 'h', 4: 'l', 5: 'o', 6: 'r', 7: 'w'}
'hello' -> [3, 2, 4, 4, 5]
[3, 2, 4, 4, 5] -> 'hello'
Original: 'hello world'
Reconstructed: 'hello world'
Perfect round-trip: True
Ten lines of Python produce a complete tokenizer. Every tokenizer — no matter how sophisticated — has these same two operations:
Tokenization achieves compression and semantic grouping:
| Tokenization | Vocabulary Size | Sequence Length | Semantics |
|---|---|---|---|
| Character | ~100 (ASCII) | Very long | None (individual letters) |
| Word | ~1,000,000+ | Short | Strong (whole words) |
| Subword | ~30,000-100,000 | Medium | Moderate (meaningful pieces) |
Our character tokenizer works — but fails at scale.
sample_text = "The transformer architecture revolutionized natural language processing."
char_tokens = list(sample_text)
print(f"Text length: {len(sample_text)} characters")
print(f"Token count: {len(char_tokens)} tokens")
print(f"Compression ratio: {len(sample_text) / len(char_tokens):.2f}x (no compression!)")Text length: 72 characters
Token count: 72 tokens
Compression ratio: 1.00x (no compression!)
Since attention is O(n^2) in sequence length, doubling the sequence length quadruples the compute cost. Character-level tokenization produces the longest possible sequences.
Characters: ['t', 'r', 'a', 'n', 's', 'f', 'o', 'r', 'm', 'e', 'r']
Token count: 11
The model must discover on its own that t-r-a-n-s-f-o-r-m-e-r forms a meaningful unit. Character tokenization provides no semantic guidance. At word-level, “transformer” occupies one token with its own learned representation.
Text: Hello! 😊
Bytes: [72, 101, 108, 108, 111, 33, 32, 240, 159, 152, 138]
Byte count: 11 (emoji = 4 bytes!)
Byte-level tokenization can represent anything, but sequences become even longer. Byte-level tokenization splits a single emoji into 4 tokens.
This is the fundamental tradeoff in tokenization:
Characters: Small vocab, long sequences, no semantics
Words: Huge vocab, short sequences, good semantics, can't handle new words
Subwords: Medium vocab, medium sequences, some semantics, handles new words
BPE merges frequently co-occurring character sequences into single tokens — the sweet spot between characters and words.
Think of BPE as compression that learns common patterns:
bpeMergeDiagram = {
const width = 620;
const height = 180;
// Merge states: each state is an array of tokens
const mergeStates = [
{ tokens: ['h', 'e', 'l', 'l', 'o'], label: 'Initial: Character Tokens', merge: null },
{ tokens: ['h', 'e', 'll', 'o'], label: "Merge 1: 'l' + 'l' → 'll'", merge: ['l', 'l', 'll'] },
{ tokens: ['he', 'll', 'o'], label: "Merge 2: 'h' + 'e' → 'he'", merge: ['h', 'e', 'he'] },
{ tokens: ['he', 'llo'], label: "Merge 3: 'll' + 'o' → 'llo'", merge: ['ll', 'o', 'llo'] },
{ tokens: ['hello'], label: "Merge 4: 'he' + 'llo' → 'hello'", merge: ['he', 'llo', 'hello'] }
];
const state = mergeStates[bpeMergeStep];
const tokens = state.tokens;
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`);
// Background
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', diagramTheme.bg)
.attr('rx', 8);
// Title
svg.append('text')
.attr('x', width / 2)
.attr('y', 28)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '14px')
.attr('font-weight', '600')
.text(state.label);
// Token display area
const tokenY = 90;
const tokenH = 50;
const gap = 8;
// Calculate total width needed for tokens
const tokenWidths = tokens.map(t => Math.max(50, t.length * 22 + 24));
const totalWidth = tokenWidths.reduce((a, b) => a + b, 0) + gap * (tokens.length - 1);
let startX = (width - totalWidth) / 2;
// Draw tokens
tokens.forEach((token, i) => {
const tokenW = tokenWidths[i];
const x = startX + tokenW / 2;
// Check if this token was just merged
const justMerged = state.merge && token === state.merge[2];
const g = svg.append('g')
.attr('transform', `translate(${x}, ${tokenY})`);
// Token box with animation effect for merged tokens
const rect = g.append('rect')
.attr('x', -tokenW / 2)
.attr('y', -tokenH / 2)
.attr('width', tokenW)
.attr('height', tokenH)
.attr('rx', 8)
.attr('fill', justMerged ? diagramTheme.highlight : diagramTheme.nodeFill)
.attr('stroke', justMerged ? diagramTheme.highlight : diagramTheme.nodeStroke)
.attr('stroke-width', justMerged ? 2.5 : 1.5);
if (justMerged) {
rect.attr('filter', `drop-shadow(0 0 8px ${diagramTheme.highlightGlow})`);
}
// Token text
g.append('text')
.attr('x', 0)
.attr('y', 0)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', justMerged ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr('font-size', '18px')
.style('font-family', 'var(--pg-mono)')
.attr('font-weight', '500')
.text(`'${token}'`);
startX += tokenW + gap;
});
// Show merge indicator if applicable
if (state.merge) {
svg.append('text')
.attr('x', width / 2)
.attr('y', height - 25)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.accent)
.attr('font-size', '12px')
.style('font-family', 'var(--pg-mono)')
.text(`Merged: '${state.merge[0]}' + '${state.merge[1]}' → '${state.merge[2]}'`);
} else {
svg.append('text')
.attr('x', width / 2)
.attr('y', height - 25)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '12px')
.attr('opacity', 0.7)
.text(`${tokens.length} tokens`);
}
return svg.node();
}For code, BPE learns patterns like:
def (function definition with space)self. (common in Python classes)return (return statement) (4-space indent)BPE learns to tokenize through this process:
bpeTrainingDiagram = {
const width = 700;
const height = 320;
// Training states showing the BPE algorithm on "low lower lowest"
const trainStates = [
{
phase: 'start',
tokens: ['l', 'o', 'w', ' ', 'l', 'o', 'w', 'e', 'r', ' ', 'l', 'o', 'w', 'e', 's', 't'],
pairs: [["('l','o')", 3], ["('o','w')", 3], ["('w',' ')", 2], ["('w','e')", 2]],
highlight: null,
description: 'Start with individual characters'
},
{
phase: 'count',
tokens: ['l', 'o', 'w', ' ', 'l', 'o', 'w', 'e', 'r', ' ', 'l', 'o', 'w', 'e', 's', 't'],
pairs: [["('l','o')", 3], ["('o','w')", 3], ["('w',' ')", 2], ["('w','e')", 2]],
highlight: "('l','o')",
description: "Count pairs: ('l','o') appears 3 times (most frequent)"
},
{
phase: 'merge',
tokens: ['lo', 'w', ' ', 'lo', 'w', 'e', 'r', ' ', 'lo', 'w', 'e', 's', 't'],
pairs: [["('lo','w')", 3], ["('w',' ')", 2], ["('w','e')", 2]],
highlight: 'lo',
description: "Merge ('l','o') → 'lo' everywhere"
},
{
phase: 'count',
tokens: ['lo', 'w', ' ', 'lo', 'w', 'e', 'r', ' ', 'lo', 'w', 'e', 's', 't'],
pairs: [["('lo','w')", 3], ["('w',' ')", 2], ["('w','e')", 2]],
highlight: "('lo','w')",
description: "Count pairs: ('lo','w') appears 3 times"
},
{
phase: 'merge',
tokens: ['low', ' ', 'low', 'e', 'r', ' ', 'low', 'e', 's', 't'],
pairs: [["('low',' ')", 2], ["('low','e')", 2], ["(' ','low')", 2]],
highlight: 'low',
description: "Merge ('lo','w') → 'low'"
},
{
phase: 'count',
tokens: ['low', ' ', 'low', 'e', 'r', ' ', 'low', 'e', 's', 't'],
pairs: [["('low','e')", 2], ["('low',' ')", 2]],
highlight: "('low','e')",
description: "Count pairs: ('low','e') appears 2 times"
},
{
phase: 'merge',
tokens: ['low', ' ', 'lowe', 'r', ' ', 'lowe', 's', 't'],
pairs: [["('lowe','r')", 1], ["('lowe','s')", 1]],
highlight: 'lowe',
description: "Merge ('low','e') → 'lowe' — Continue until vocab size reached"
}
];
const state = trainStates[bpeTrainStep];
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`);
// Background
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', diagramTheme.bg)
.attr('rx', 8);
// Title / Phase indicator
const phaseColors = {
'start': diagramTheme.nodeStroke,
'count': diagramTheme.accent,
'merge': diagramTheme.highlight
};
svg.append('text')
.attr('x', width / 2)
.attr('y', 28)
.attr('text-anchor', 'middle')
.attr('fill', phaseColors[state.phase])
.attr('font-size', '14px')
.attr('font-weight', '600')
.text(state.phase === 'start' ? 'BPE Training Algorithm' :
state.phase === 'count' ? 'Phase: Count Pairs' : 'Phase: Merge');
// Token display area
const tokenY = 85;
const tokenH = 36;
const gap = 3;
// Calculate token layout
const tokens = state.tokens;
const tokenWidths = tokens.map(t => t === ' ' ? 28 : Math.max(28, t.length * 14 + 16));
const totalWidth = tokenWidths.reduce((a, b) => a + b, 0) + gap * (tokens.length - 1);
const scale = totalWidth > width - 40 ? (width - 40) / totalWidth : 1;
let startX = (width - totalWidth * scale) / 2;
// Draw tokens
tokens.forEach((token, i) => {
const tokenW = tokenWidths[i] * scale;
const x = startX + tokenW / 2;
const isHighlighted = state.highlight === token;
const isSpace = token === ' ';
const g = svg.append('g')
.attr('transform', `translate(${x}, ${tokenY})`);
const rect = g.append('rect')
.attr('x', -tokenW / 2)
.attr('y', -tokenH / 2)
.attr('width', tokenW)
.attr('height', tokenH)
.attr('rx', 5)
.attr('fill', isHighlighted ? diagramTheme.highlight :
isSpace ? diagramTheme.bgSecondary : diagramTheme.nodeFill)
.attr('stroke', isHighlighted ? diagramTheme.highlight : diagramTheme.nodeStroke)
.attr('stroke-width', isHighlighted ? 2 : 1);
if (isHighlighted) {
rect.attr('filter', `drop-shadow(0 0 6px ${diagramTheme.highlightGlow})`);
}
g.append('text')
.attr('x', 0)
.attr('y', 0)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', isHighlighted ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr('font-size', `${11 * scale}px`)
.style('font-family', 'var(--pg-mono)')
.text(isSpace ? '␣' : token);
startX += tokenW + gap * scale;
});
// Pair frequencies section
const pairY = 170;
svg.append('text')
.attr('x', 20)
.attr('y', pairY)
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '12px')
.attr('font-weight', '600')
.text('Pair frequencies:');
const pairs = state.pairs;
const pairGap = 150;
pairs.forEach((pair, i) => {
const x = 20 + i * pairGap;
const isHighlightedPair = state.highlight === pair[0];
svg.append('text')
.attr('x', x)
.attr('y', pairY + 24)
.attr('fill', isHighlightedPair ? diagramTheme.highlight : diagramTheme.nodeText)
.attr('font-size', '12px')
.style('font-family', 'var(--pg-mono)')
.attr('font-weight', isHighlightedPair ? '700' : '400')
.text(`${pair[0]}: ${pair[1]}`);
});
// Description
svg.append('rect')
.attr('x', 20)
.attr('y', height - 65)
.attr('width', width - 40)
.attr('height', 45)
.attr('rx', 6)
.attr('fill', diagramTheme.bgSecondary)
.attr('stroke', diagramTheme.nodeStroke)
.attr('stroke-width', 1);
svg.append('text')
.attr('x', width / 2)
.attr('y', height - 38)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '13px')
.text(state.description);
// Token count
svg.append('text')
.attr('x', width - 20)
.attr('y', 28)
.attr('text-anchor', 'end')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '11px')
.attr('opacity', 0.7)
.text(`${tokens.length} tokens`);
return svg.node();
}BPE is simple - just counting and merging:
Vocabulary size is a hyperparameter:
Once trained, encoding applies merges in the order they were learned:
encodingDiagram = {
const width = 620;
const height = 240;
// Encoding steps showing how merges are applied in order
const encodeSteps = [
{ tokens: ['l', 'o', 'w', 'e', 'r'], label: 'Split to characters', merge: null, ids: null },
{ tokens: ['lo', 'w', 'e', 'r'], label: "Apply merge 1: 'l' + 'o' → 'lo'", merge: ['l', 'o', 'lo'], ids: null },
{ tokens: ['low', 'e', 'r'], label: "Apply merge 2: 'lo' + 'w' → 'low'", merge: ['lo', 'w', 'low'], ids: null },
{ tokens: ['lowe', 'r'], label: "Apply merge 3: 'low' + 'e' → 'lowe'", merge: ['low', 'e', 'lowe'], ids: null },
{ tokens: ['lower'], label: "Apply merge 4: 'lowe' + 'r' → 'lower'", merge: ['lowe', 'r', 'lower'], ids: null },
{ tokens: ['lower'], label: "Look up token IDs", merge: null, ids: [15] }
];
const state = encodeSteps[encodeStep];
const tokens = state.tokens;
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`);
// Background
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', diagramTheme.bg)
.attr('rx', 8);
// Step indicator
svg.append('text')
.attr('x', 20)
.attr('y', 28)
.attr('fill', diagramTheme.accent)
.attr('font-size', '12px')
.attr('font-weight', '600')
.text(`Step ${encodeStep + 1}/6`);
// Title
svg.append('text')
.attr('x', width / 2)
.attr('y', 28)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '14px')
.attr('font-weight', '600')
.text(state.label);
// Arrow showing merge progression
if (encodeStep > 0 && encodeStep < 5) {
// Show the "before" state faded
const prevTokens = encodeSteps[encodeStep - 1].tokens;
const prevY = 70;
const prevGap = 6;
const prevWidths = prevTokens.map(t => Math.max(40, t.length * 16 + 20));
const prevTotal = prevWidths.reduce((a, b) => a + b, 0) + prevGap * (prevTokens.length - 1);
let prevX = (width - prevTotal) / 2;
prevTokens.forEach((token, i) => {
const w = prevWidths[i];
const x = prevX + w / 2;
const isMerging = state.merge && (token === state.merge[0] || token === state.merge[1]);
svg.append('rect')
.attr('x', x - w / 2)
.attr('y', prevY - 16)
.attr('width', w)
.attr('height', 32)
.attr('rx', 5)
.attr('fill', diagramTheme.bgSecondary)
.attr('stroke', isMerging ? diagramTheme.accent : diagramTheme.nodeStroke)
.attr('stroke-width', isMerging ? 2 : 1)
.attr('opacity', 0.6);
svg.append('text')
.attr('x', x)
.attr('y', prevY)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '13px')
.style('font-family', 'var(--pg-mono)')
.attr('opacity', 0.5)
.text(`'${token}'`);
prevX += w + prevGap;
});
// Arrow down
svg.append('path')
.attr('d', `M${width/2},${prevY + 22} L${width/2},${prevY + 45}`)
.attr('stroke', diagramTheme.accent)
.attr('stroke-width', 2)
.attr('marker-end', 'url(#encode-arrow)');
// Arrow marker
const defs = svg.append('defs');
defs.append('marker')
.attr('id', 'encode-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', diagramTheme.accent);
}
// Current tokens (main display)
const tokenY = encodeStep > 0 && encodeStep < 5 ? 150 : 110;
const tokenH = 50;
const gap = 10;
const tokenWidths = tokens.map(t => Math.max(60, t.length * 20 + 28));
const totalWidth = tokenWidths.reduce((a, b) => a + b, 0) + gap * (tokens.length - 1);
let startX = (width - totalWidth) / 2;
tokens.forEach((token, i) => {
const tokenW = tokenWidths[i];
const x = startX + tokenW / 2;
const justMerged = state.merge && token === state.merge[2];
const showId = state.ids !== null;
const g = svg.append('g')
.attr('transform', `translate(${x}, ${tokenY})`);
const rect = g.append('rect')
.attr('x', -tokenW / 2)
.attr('y', -tokenH / 2)
.attr('width', tokenW)
.attr('height', tokenH)
.attr('rx', 8)
.attr('fill', justMerged ? diagramTheme.highlight :
showId ? diagramTheme.accent : diagramTheme.nodeFill)
.attr('stroke', justMerged ? diagramTheme.highlight :
showId ? diagramTheme.accent : diagramTheme.nodeStroke)
.attr('stroke-width', justMerged || showId ? 2.5 : 1.5);
if (justMerged || showId) {
rect.attr('filter', `drop-shadow(0 0 8px ${justMerged ? diagramTheme.highlightGlow : diagramTheme.accentGlow})`);
}
// Token text
g.append('text')
.attr('x', 0)
.attr('y', showId ? -8 : 0)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', justMerged ? diagramTheme.textOnHighlight :
showId ? diagramTheme.textOnAccent : diagramTheme.nodeText)
.attr('font-size', '16px')
.style('font-family', 'var(--pg-mono)')
.attr('font-weight', '500')
.text(`'${token}'`);
// ID display
if (showId && state.ids[i] !== undefined) {
g.append('text')
.attr('x', 0)
.attr('y', 12)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', diagramTheme.textOnAccent)
.attr('font-size', '13px')
.attr('opacity', 0.9)
.text(`ID: ${state.ids[i]}`);
}
startX += tokenW + gap;
});
// Bottom info
const infoY = height - 30;
if (state.ids) {
svg.append('text')
.attr('x', width / 2)
.attr('y', infoY)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.accent)
.attr('font-size', '14px')
.attr('font-weight', '600')
.text(`Output: [${state.ids.join(', ')}]`);
} else {
svg.append('text')
.attr('x', width / 2)
.attr('y', infoY)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '12px')
.attr('opacity', 0.7)
.text(`${tokens.length} token${tokens.length > 1 ? 's' : ''}`);
}
return svg.node();
}BPE can handle words it has never seen:
unknownWordsDiagram = {
const width = 650;
const height = 280;
const isKnown = wordType.includes('lowest');
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`);
// Background
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', diagramTheme.bg)
.attr('rx', 8);
// Title
svg.append('text')
.attr('x', width / 2)
.attr('y', 28)
.attr('text-anchor', 'middle')
.attr('fill', isKnown ? diagramTheme.accent : diagramTheme.highlight)
.attr('font-size', '15px')
.attr('font-weight', '700')
.text(isKnown ? "Known Word: 'lowest'" : "Unknown Word: 'lows'");
if (isKnown) {
// Known word path: direct lookup
const centerY = 100;
// Input word
svg.append('text')
.attr('x', 80)
.attr('y', centerY)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '18px')
.style('font-family', 'var(--pg-mono)')
.attr('font-weight', '600')
.text("'lowest'");
// Arrow
svg.append('path')
.attr('d', `M140,${centerY} L260,${centerY}`)
.attr('stroke', diagramTheme.accent)
.attr('stroke-width', 3)
.attr('marker-end', 'url(#known-arrow)');
// Result box
const resultG = svg.append('g')
.attr('transform', `translate(350, ${centerY})`);
resultG.append('rect')
.attr('x', -70)
.attr('y', -28)
.attr('width', 140)
.attr('height', 56)
.attr('rx', 8)
.attr('fill', diagramTheme.accent)
.attr('filter', `drop-shadow(0 0 10px ${diagramTheme.accentGlow})`);
resultG.append('text')
.attr('x', 0)
.attr('y', -6)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.textOnAccent)
.attr('font-size', '16px')
.style('font-family', 'var(--pg-mono)')
.attr('font-weight', '600')
.text('[16]');
resultG.append('text')
.attr('x', 0)
.attr('y', 14)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.textOnAccent)
.attr('font-size', '11px')
.attr('opacity', 0.9)
.text('Single token');
// Efficiency note
svg.append('text')
.attr('x', width / 2)
.attr('y', centerY + 60)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '12px')
.attr('opacity', 0.8)
.text('Direct vocabulary lookup — maximum efficiency');
// Arrow marker
const defs = svg.append('defs');
defs.append('marker')
.attr('id', 'known-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 8)
.attr('markerHeight', 8)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', diagramTheme.accent);
} else {
// Unknown word path: split and apply merges
const steps = [
{ y: 70, label: "Input", tokens: ["'lows'"], note: null },
{ y: 120, label: "Split", tokens: ["'l'", "'o'", "'w'", "'s'"], note: "Character-level" },
{ y: 170, label: "Merge", tokens: ["'low'", "'s'"], note: "Apply learned merges" },
{ y: 220, label: "IDs", tokens: ["[13, 9]"], note: "Subword tokens" }
];
// Arrow marker
const defs = svg.append('defs');
defs.append('marker')
.attr('id', 'unknown-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', diagramTheme.highlight);
steps.forEach((step, i) => {
// Step label
svg.append('text')
.attr('x', 50)
.attr('y', step.y)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '11px')
.attr('font-weight', '600')
.attr('opacity', 0.7)
.text(step.label);
// Tokens
const tokenGap = 10;
const tokenWidths = step.tokens.map(t => t.startsWith('[') ? 100 : Math.max(40, t.length * 14 + 16));
const totalW = tokenWidths.reduce((a, b) => a + b, 0) + tokenGap * (step.tokens.length - 1);
let startX = 200;
step.tokens.forEach((token, j) => {
const w = tokenWidths[j];
const x = startX + w / 2;
const isResult = i === steps.length - 1;
const rect = svg.append('rect')
.attr('x', x - w / 2)
.attr('y', step.y - 16)
.attr('width', w)
.attr('height', 32)
.attr('rx', 6)
.attr('fill', isResult ? diagramTheme.highlight : diagramTheme.nodeFill)
.attr('stroke', isResult ? diagramTheme.highlight : diagramTheme.nodeStroke)
.attr('stroke-width', isResult ? 2 : 1.5);
if (isResult) {
rect.attr('filter', `drop-shadow(0 0 6px ${diagramTheme.highlightGlow})`);
}
svg.append('text')
.attr('x', x)
.attr('y', step.y)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', isResult ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr('font-size', '13px')
.style('font-family', 'var(--pg-mono)')
.attr('font-weight', '500')
.text(token);
startX += w + tokenGap;
});
// Note
if (step.note) {
svg.append('text')
.attr('x', 480)
.attr('y', step.y)
.attr('text-anchor', 'start')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '11px')
.attr('opacity', 0.6)
.text(step.note);
}
// Arrow to next step
if (i < steps.length - 1) {
svg.append('path')
.attr('d', `M200,${step.y + 18} L200,${steps[i+1].y - 18}`)
.attr('stroke', diagramTheme.highlight)
.attr('stroke-width', 2)
.attr('marker-end', 'url(#unknown-arrow)');
}
});
}
// Why BPE Works section
const whyY = height - 38;
const reasons = isKnown ?
["Common words → single tokens (efficient)"] :
["Rare words → split into subwords (still encodable)", "Never out of vocabulary"];
svg.append('text')
.attr('x', width / 2)
.attr('y', whyY)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '11px')
.attr('font-style', 'italic')
.attr('opacity', 0.7)
.text(reasons.join(' • '));
return svg.node();
}Our character-level tokenizer has a hard limit: it can only encode characters it saw during training. Feed it an accented letter, an emoji, or a Chinese character it never met, and each one collapses to <UNK> — the information is gone, and decoding can never recover it. Real tokenizers (GPT-2, GPT-4’s tiktoken, SentencePiece) fix this once and for all with byte-level BPE.
The idea is a single change of alphabet. Instead of starting from the characters in the training text, start from the 256 possible UTF-8 bytes.
Every string — English, café, 你好, 🚀, a tab, a newline — is stored as a sequence of bytes, and every byte is a number from 0 to 255. So a vocabulary that contains all 256 byte values can represent any text that has ever existed or ever will. A character the tokenizer has never seen is simply a byte sequence built from bytes it already knows.
'A' → codepoint U+0041 → bytes [65]
'é' → codepoint U+00E9 → bytes [195, 169]
'☕' → codepoint U+2615 → bytes [226, 152, 149]
'好' → codepoint U+597D → bytes [229, 165, 189]
A one-byte ASCII letter stays one byte; é is two bytes; ☕ and 好 are three. The multi-byte characters fan out into several byte tokens — a little longer, but never unknown.
Byte-level BPE has no <UNK> token by construction. There is no such thing as an unknown byte — all 256 are in the vocabulary from the start. This is the single reason production tokenizers are byte-level: every possible input is encodable and every round-trip is exact.
There is one wrinkle. BPE merges strings, but raw bytes include control characters — newline, tab, NUL — that are invisible or unsafe to handle as text. GPT-2’s solution (which we reuse) is to remap all 256 bytes to 256 distinct, printable Unicode characters before doing any BPE. Printable bytes map to themselves; the rest are shifted into a visible region starting at U+0100. The map is a bijection, so decoding recovers the exact original bytes.
from tokenizer import bytes_to_unicode
byte_map = bytes_to_unicode()
# Bytes that are normally invisible get a visible stand-in glyph
for b in [ord(" "), ord("\n"), ord("\t"), ord("A")]:
print(f" byte {b:3d} ({chr(b)!r:6}) → visible token {byte_map[b]!r}")
print(f"\n256 bytes → {len(set(byte_map.values()))} distinct printable glyphs") byte 32 (' ' ) → visible token 'Ġ'
byte 10 ('\n' ) → visible token 'Ċ'
byte 9 ('\t' ) → visible token 'ĉ'
byte 65 ('A' ) → visible token 'A'
256 bytes → 256 distinct printable glyphs
ByteLevelBPETokenizertokenizer.py implements this as ByteLevelBPETokenizer. It learns merges exactly like the character-level version — count adjacent pairs, merge the most frequent — but its base alphabet is the 256 byte tokens instead of the characters in the corpus. Watch it succeed on text a character-level tokenizer cannot handle:
from tokenizer import BPETokenizer, ByteLevelBPETokenizer, SPECIAL_TOKENS
# Train BOTH on the same ASCII-only corpus (no accents, no emoji, no CJK)
corpus = "the code returns hello world def class self " * 20
char_tok = BPETokenizer(vocab_size=400, verbose=False)
char_tok.train(corpus, show_progress=False)
byte_tok = ByteLevelBPETokenizer(vocab_size=400, verbose=False)
byte_tok.train(corpus, show_progress=False)
# Now encode a string full of characters neither one saw in training
text = "café ☕ 你好 — 42"
for name, tok in [("Character-level", char_tok), ("Byte-level", byte_tok)]:
ids = tok.encode(text)
unk = sum(1 for i in ids if i == SPECIAL_TOKENS["<UNK>"])
ok = tok.decode(ids) == text
print(f"{name:16}: {len(ids):2d} tokens, {unk} <UNK>, round-trip={ok}")Character-level : 14 tokens, 7 <UNK>, round-trip=False
Byte-level : 23 tokens, 0 <UNK>, round-trip=True
The character-level tokenizer riddles the output with <UNK> and cannot reconstruct the original. The byte-level tokenizer produces zero <UNK> and a perfect round-trip — the whole string survives as bytes. The convenience function demonstrate_byte_level() prints this comparison along with the full UTF-8 breakdown.
Here is the full byte-level pipeline. Step through it to watch each character fan out into its UTF-8 bytes, and each byte become an in-vocabulary token — so nothing is ever unknown.
byteFlowStages = [
{key: "chars", title: "1. Characters", note: "The raw input string, one glyph at a time"},
{key: "bytes", title: "2. UTF-8 bytes", note: "Each character expands to 1–4 bytes (0–255)"},
{key: "tokens", title: "3. Byte tokens", note: "Every byte maps to a printable in-vocab token — no <UNK>"},
{key: "ids", title: "4. Token IDs", note: "Look up each (possibly merged) token's integer ID"}
]byteFlowDiagram = {
const width = 700;
const height = 300;
const theme = diagramTheme;
const stage = byteFlowStep;
const info = byteExBreakdown; // from Python: [{char, utf8_bytes, byte_tokens, ...}]
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%")
.attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect")
.attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
// Title + note
svg.append("text")
.attr("x", width / 2).attr("y", 30)
.attr("text-anchor", "middle")
.attr("fill", theme.highlight)
.attr("font-size", "15px").attr("font-weight", "700")
.text(byteFlowStages[stage].title);
svg.append("text")
.attr("x", width / 2).attr("y", height - 22)
.attr("text-anchor", "middle")
.attr("fill", theme.nodeText)
.attr("font-size", "12px")
.text(byteFlowStages[stage].note);
// Layout: one column per source character, columns sized by byte count
const glyphs = info.filter(d => d.char !== " "); // drop the space for clarity
const weights = glyphs.map(g => Math.max(1, g.num_bytes));
const totalW = weights.reduce((a, b) => a + b, 0);
const usable = width - 80;
const colGap = 10;
let x = 40;
const topY = 70;
const cellH = 34;
glyphs.forEach((g, gi) => {
const colW = (weights[gi] / totalW) * (usable - colGap * (glyphs.length - 1));
const cx = x + colW / 2;
// Row 1: the character (always shown)
const charActive = stage >= 0;
drawBox(svg, cx, topY, Math.min(colW, 60), cellH, g.char,
charActive ? theme.nodeFill : theme.bgSecondary,
stage === 0 ? theme.highlight : theme.nodeStroke,
theme.nodeText, "16px");
if (stage >= 1) {
// Row 2: the UTF-8 bytes for this character, laid out across the column
const nb = g.utf8_bytes.length;
const bw = (colW - (nb - 1) * 4) / nb;
g.utf8_bytes.forEach((b, bi) => {
const bx = x + bi * (bw + 4) + bw / 2;
drawBox(svg, bx, topY + 55, Math.min(bw, 46), cellH, String(b),
theme.bgSecondary,
stage === 1 ? theme.accent : theme.nodeStroke,
theme.nodeText, "12px");
// connector char -> byte
svg.append("path")
.attr("d", `M${cx},${topY + cellH / 2} L${bx},${topY + 55 - cellH / 2}`)
.attr("stroke", theme.edgeStroke).attr("stroke-width", 1).attr("opacity", 0.5);
});
}
if (stage >= 2) {
// Row 3: the printable byte-token glyph for each byte
const nb = g.byte_tokens.length;
const bw = (colW - (nb - 1) * 4) / nb;
g.byte_tokens.forEach((t, bi) => {
const bx = x + bi * (bw + 4) + bw / 2;
const active = stage === 2;
drawBox(svg, bx, topY + 110, Math.min(bw, 46), cellH, t,
active ? theme.highlight : theme.nodeFill,
active ? theme.highlight : theme.nodeStroke,
active ? theme.textOnHighlight : theme.nodeText, "13px");
});
}
x += colW + colGap;
});
if (stage >= 3) {
// Final: the integer IDs as one summary row
svg.append("text")
.attr("x", width / 2).attr("y", topY + 150)
.attr("text-anchor", "middle")
.attr("fill", theme.accent)
.attr("font-size", "14px").attr("font-weight", "600")
.text(`IDs: [${byteExIds.join(", ")}]`);
}
function drawBox(svg, cx, cy, w, h, label, fill, stroke, textFill, fontSize) {
const g = svg.append("g").attr("transform", `translate(${cx},${cy})`);
g.append("rect")
.attr("x", -w / 2).attr("y", -h / 2)
.attr("width", w).attr("height", h).attr("rx", 5)
.attr("fill", fill).attr("stroke", stroke).attr("stroke-width", 1.5);
g.append("text")
.attr("text-anchor", "middle").attr("dominant-baseline", "central")
.attr("fill", textFill).attr("font-size", fontSize)
.text(label);
return g;
}
return svg.node();
}Type anything — accents, emoji, other scripts, code with tabs — and watch it expand into UTF-8 bytes. However exotic the input, the byte count is finite and every byte is a known token, so the “unknown bytes” count stays at zero.
byteExplorerData = {
const encoder = new TextEncoder(); // UTF-8, built into the browser
const chars = Array.from(byteExplorerInput); // splits astral emoji correctly
const rows = chars.map(ch => {
const bytes = Array.from(encoder.encode(ch));
return {
char: ch,
code: ch.codePointAt(0),
bytes
};
});
const totalBytes = rows.reduce((a, r) => a + r.bytes.length, 0);
return {rows, totalChars: chars.length, totalBytes};
}byteExplorerView = {
const theme = diagramTheme;
const {rows, totalChars, totalBytes} = byteExplorerData;
const container = html`<div style="margin: 12px 0;"></div>`;
const stats = html`<div style="font-family: var(--pg-mono); font-size: 13px; margin-bottom: 12px; color: ${theme.nodeText};">
<strong>${totalChars}</strong> character${totalChars === 1 ? "" : "s"}
→ <strong>${totalBytes}</strong> UTF-8 byte${totalBytes === 1 ? "" : "s"}
·
<span style="color: ${theme.success || theme.accent}; font-weight: 600;">0 unknown bytes</span>
<span style="opacity: 0.7;">(always — every byte is in the vocabulary)</span>
</div>`;
container.appendChild(stats);
const grid = html`<div style="display: flex; flex-wrap: wrap; gap: 8px;"></div>`;
rows.forEach(r => {
const multi = r.bytes.length > 1;
const cell = html`<div style="
border: 1px solid ${multi ? (theme.highlight) : theme.nodeStroke};
border-radius: 6px; padding: 6px 8px; text-align: center;
background: ${theme.bgSecondary}; min-width: 44px;">
<div style="font-size: 18px; color: ${theme.nodeText};">${r.char === " " ? "␣" : r.char}</div>
<div style="font-size: 10px; color: ${theme.edgeStroke}; margin: 2px 0;">U+${r.code.toString(16).toUpperCase().padStart(4, "0")}</div>
<div style="font-family: var(--pg-mono); font-size: 11px; color: ${multi ? theme.highlight : theme.accent};">
${r.bytes.join(" ")}
</div>
</div>`;
grid.appendChild(cell);
});
container.appendChild(grid);
return container;
}🚀 — one character becomes 4 bytes (highlighted), yet it is still fully encodable and reversible.e (1 byte) with é (2 bytes). ASCII is cheap; everything else costs a little more length in exchange for universal coverage.你好 or مرحبا. The “unknown bytes” count never moves off zero — that’s the guarantee a character-level tokenizer can’t make.Because one character can span several bytes, cutting a byte sequence in the middle of a multi-byte character leaves a partial, invalid UTF-8 fragment. ByteLevelBPETokenizer.decode() follows GPT-2 and decodes with errors="replace", turning any such fragment into the replacement character `` rather than crashing. This is why streaming decoders buffer bytes until a full character is available before showing text to the user.
There is a step that runs before BPE ever counts a pair, and it quietly decides the quality of every merge you learn: pre-tokenization. BPE only merges pairs that sit inside one pre-token — it never merges across a boundary. So the rule you use to chop text into pre-tokens is the rule that says which byte sequences are even allowed to become a single token.
Our byte-level tokenizer above split on the simple (\s+|\S+) rule — runs of whitespace or non-whitespace. That lets ugly things merge. Consider "don't": the naive split keeps it as one chunk, so BPE can learn a token that fuses the apostrophe into the word. Numbers are worse — "GPT2" as one chunk invites a "GPT2" token, and "2024" might partly merge into a neighbouring word. And because "the" and " the" (with a leading space) are different chunks that never share structure, the model wastes vocabulary learning both.
GPT-2 fixed this with a hand-crafted regex that pre-splits text into linguistically clean pieces before BPE runs. Its rules, in plain English:
"don't" → "don", "'t"; "I'll" → "I", "'ll"."GPT2" → "GPT", "2"." the" is one piece, so the model learns a single ” the” token and reuses it everywhere a word follows a space.Here is GPT-2’s regex, written for Python’s standard-library re (no third-party dependency). The original uses the Unicode classes \p{L} (letters) and \p{N} (numbers); the standard library spells a letter as [^\W\d_] — a word character that is neither a digit nor an underscore — and a digit as \d:
's|'t|'re|'ve|'m|'ll|'d| ?[^\W\d_]+| ?\d+| ?[^\s\w]+| ?_+|\s+(?!\S)|\s+
└── contractions ──┘ └letters┘ └digits┘ └symbols┘ └_┘ └─ whitespace ─┘
The alternatives are tried left to right, and the pattern is total — every character lands in exactly one piece — so joining the pieces rebuilds the input byte-for-byte. That totality is what preserves the byte-level round-trip guarantee: pre-tokenization changes which merges are possible, never what text comes back.
gpt2_pretokenizetokenizer.py compiles that pattern once and applies it with a single findall. ByteLevelBPETokenizer now uses it by default (pretokenizer="gpt2"); pass pretokenizer="simple" to fall back to the naive split.
from tokenizer import gpt2_pretokenize
print(gpt2_pretokenize("Don't merge GPT2 across_words!"))
print(gpt2_pretokenize(" the café costs €5.50"))
# Totality — the pieces rejoin to the original, so the round-trip is safe:
text = "café ☕ 你好\n\t42"
assert "".join(gpt2_pretokenize(text)) == text
print("round-trip-safe:", "".join(gpt2_pretokenize(text)) == text)['Don', "'t", ' merge', ' GPT', '2', ' across', '_', 'words', '!']
[' the', ' café', ' costs', ' €', '5', '.', '50']
round-trip-safe: True
Now watch pre-tokenization change what BPE learns. Train two byte-level tokenizers on the same text — one with each split — and compare the merges:
from tokenizer import ByteLevelBPETokenizer
corpus = "don't stop. I'll pay $20. don't wait. I'll go. " * 40
gpt2 = ByteLevelBPETokenizer(vocab_size=300, pretokenizer="gpt2")
simple = ByteLevelBPETokenizer(vocab_size=300, pretokenizer="simple")
gpt2.train(corpus, show_progress=False)
simple.train(corpus, show_progress=False)
print("gpt2 merges: ", gpt2.training_stats["num_merges"])
print("simple merges:", simple.training_stats["num_merges"])
# Both still round-trip perfectly — only the learned vocabulary differs:
print("gpt2 round-trip: ", gpt2.decode(gpt2.encode("don't")) == "don't")
print("simple round-trip:", simple.decode(simple.encode("don't")) == "don't")gpt2 merges: 22
simple merges: 22
gpt2 round-trip: True
simple round-trip: True
Type any text and watch GPT-2’s regex carve it into pre-tokens, each coloured by category. A leading space is drawn as · so you can see it ride onto the next word. Every character belongs to exactly one chip — that is the totality that keeps the round-trip exact.
pretokPattern = /'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+/gu
pretokPieces = {
const contractions = new Set(["'s", "'t", "'re", "'ve", "'m", "'ll", "'d"]);
return [...pretokText.matchAll(pretokPattern)].map(m => {
const piece = m[0];
const core = piece.startsWith(" ") ? piece.slice(1) : piece;
let category;
if (contractions.has(piece)) category = "contraction";
else if (piece.trim() === "") category = "space";
else if (/\p{N}/u.test(core[0])) category = "number";
else if (/\p{L}/u.test(core[0])) category = "word";
else category = "punct";
return {piece, category, visible: piece.replace(/ /g, "·").replace(/\n/g, "\\n").replace(/\t/g, "\\t")};
});
}pretokSplitter = {
const theme = diagramTheme;
const colors = {
word: theme.accent,
number: theme.success,
punct: theme.error,
contraction: theme.highlight,
space: theme.edgeStroke
};
const chip = p => html`<span style="
display:inline-block; margin:3px; padding:6px 10px; border-radius:7px;
font-family:var(--pg-mono); font-size:14px; font-weight:600;
color:${theme.textOnAccent ?? '#fff'}; background:${colors[p.category]};
box-shadow:0 1px 2px rgba(0,0,0,0.18);">${p.visible || "∅"}</span>`;
const legendItem = (label, cat) => html`<span style="
display:inline-flex; align-items:center; gap:5px; margin-right:14px; font-size:12px;
color:${theme.nodeText};">
<span style="width:12px;height:12px;border-radius:3px;background:${colors[cat]};display:inline-block;"></span>
${label}</span>`;
return html`<div>
<div style="margin-bottom:10px;">${pretokPieces.map(chip)}</div>
<div style="margin-bottom:6px;">
${legendItem("word", "word")}${legendItem("number", "number")}
${legendItem("punct", "punct")}${legendItem("contraction", "contraction")}
${legendItem("space (·)", "space")}
</div>
<div style="font-size:13px; color:${theme.nodeText};">
<b>${pretokPieces.length}</b> pre-tokens — BPE may merge inside each chip, never across two.
</div>
</div>`;
}wouldn't've — it splits into wouldn, 't, 've, three units BPE keeps separate.Route66 costs $1,024 — letters, digits, and punctuation never share a chip, so no Route66 or 1,024 token can form.the the the — every word after the first is ·the (space + word), one reusable token, distinct from a sentence-initial the.pretokenizer="simple" in the code above and retrain: the merges change, but decode(encode(x)) == x still holds — totality guarantees it.Before examining the code, understand special tokens - reserved tokens with specific meanings in the LLM pipeline:
| Token | Purpose | When Used |
|---|---|---|
<PAD> (ID 0) |
Padding | Batch processing requires same-length sequences. Padding fills shorter sequences. |
<UNK> (ID 1) |
Unknown | Characters not seen during training. Production tokenizers avoid this with byte-level BPE. |
<BOS> (ID 2) |
Beginning of Sequence | Signals the start of text. Helps model distinguish context boundaries. |
<EOS> (ID 3) |
End of Sequence | Signals text completion. Model generates this to stop. Critical for generation. |
The vocabulary reserves these tokens before training begins, ensuring consistent IDs across all tokenizers.
When you chat with an assistant, you think in messages with roles — a system instruction, your question, the model’s reply. The model sees none of that structure. It consumes exactly one flat stream of token IDs, the same as any other text. So how does it know where your turn ends and its turn begins, or which words are the (trusted) system prompt versus the (untrusted) user input?
The answer is a chat template: an agreed convention for flattening a structured conversation into a single stream, using reserved control tokens to mark who is speaking and where each turn starts and stops. The most common one is ChatML, introduced with the OpenAI Chat API and adopted (with variants) by Qwen, Yi, and others. It wraps every turn in two control tokens — <|im_start|> and <|im_end|> — with the role name on the first line:
<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
What is 2+2?<|im_end|>
<|im_start|>assistant
4<|im_end|>
Those <|im_start|> / <|im_end|> markers are not typed characters — they are single, atomic tokens, exactly like <BOS>/<EOS>. The model learns during training that “text after <|im_start|>user is a user turn” and “I should generate until <|im_end|>.” Roles are just tokens; the structure is entirely a tokenization convention.
A chat model has no built-in concept of “messages” or “roles.” A chat template is pure tokenization: it serializes a list of {role, content} turns into one flat ID stream, marking the boundaries with reserved control tokens the model was trained to recognize. Change the template and the model gets confused — the format is part of the contract.
From scratch. tokenizer.py builds this directly. render_chatml flattens the messages; encode_chat keeps each control token atomic and runs the rest through BPE; chatml_segments returns the typed spans the visualizer below draws.
from tokenizer import render_chatml, encode_chat, chatml_segments, CHAT_TOKENS, BPETokenizer
conversation = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is 2+2?"},
]
# 1. Flatten the structured conversation into one string
templated = render_chatml(conversation, add_generation_prompt=True)
print("Rendered ChatML (add_generation_prompt=True):\n")
print(templated)
print("Control tokens:", CHAT_TOKENS)Rendered ChatML (add_generation_prompt=True):
<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
What is 2+2?<|im_end|>
<|im_start|>assistant
Control tokens: ['<|im_start|>', '<|im_end|>']
The trailing <|im_start|>assistant\n is the generation prompt — a dangling, open turn with no content and no end marker. That is what turns a transcript into a prompt: it primes the model to speak next, in the assistant role. Ask for a completion without it and the model has no signal that it is its turn.
# 2. Encode to a flat ID stream — control tokens stay atomic (one ID each)
tok = BPETokenizer(vocab_size=300)
tok.train("you are a helpful assistant what is 2 + 2 system user", show_progress=False)
ids = encode_chat(tok, conversation, add_generation_prompt=True)
print(f"Flat token IDs ({len(ids)} tokens):")
print(ids)
# Added special tokens extend the vocab: they get fresh IDs at the end
from tokenizer import chat_token_ids
print("\nAppended control-token IDs:", chat_token_ids(tok))
print(f"(regular tokens occupy 0..{len(tok.vocab)-1}; the two control tokens sit just past them)")Flat token IDs (66 tokens):
[24, 18, 22, 18, 19, 8, 13, 1, 1, 15, 20, 4, 7, 17, 8, 4, 7, 4, 10, 8, 12, 16, 9, 20, 12, 4, 7, 18, 18, 23, 19, 7, 14, 19, 1, 25, 1, 24, 20, 18, 8, 17, 1, 1, 10, 7, 19, 4, 23, 4, 6, 5, 6, 1, 25, 1, 24, 7, 18, 18, 23, 19, 7, 14, 19, 1]
Appended control-token IDs: {'<|im_start|>': 24, '<|im_end|>': 25}
(regular tokens occupy 0..23; the two control tokens sit just past them)
Each <|im_start|> / <|im_end|> is a single ID in that stream — never a run of <, |, i, m… characters. A real tokenizer protects its special tokens from BPE exactly this way (our split_on_special does the same), and appending them to a pretrained vocabulary gives them fresh IDs at the end so they can never collide with a learned token.
Pick a conversation and toggle the generation prompt. Watch the structured turns flatten into one stream, with the control tokens and role labels highlighted — this is precisely the byte sequence the model reads. (The builder below mirrors chatml_segments from tokenizer.py, whose spans you saw printed above.)
// Mirror tokenizer.py's chatml_segments in JS so the builder is live.
chatSegments = {
const presets = {
qa: [
{role: "system", content: "You are a helpful assistant."},
{role: "user", content: "What is 2+2?"}
],
user: [
{role: "user", content: "Write a haiku about tokens."}
],
multi: [
{role: "system", content: "You are a terse assistant."},
{role: "user", content: "Capital of France?"},
{role: "assistant", content: "Paris."},
{role: "user", content: "And of Japan?"}
]
};
const msgs = presets[chatPreset];
const segs = [];
const emit = (role, content) => {
segs.push({kind: "control", text: "<|im_start|>", role});
segs.push({kind: "role", text: role + "\n", role});
if (content !== null) {
segs.push({kind: "content", text: content, role});
segs.push({kind: "control", text: "<|im_end|>", role});
segs.push({kind: "content", text: "\n", role});
}
};
for (const m of msgs) emit(m.role, m.content);
if (chatGenPrompt) emit("assistant", null);
return segs;
}chatTemplateView = {
const theme = diagramTheme;
const roleColor = {
system: theme.accent,
user: theme.highlight,
assistant: theme.success || "#22c55e"
};
const container = html`<div style="
font-family: var(--pg-mono);
background: ${theme.bg}; border-radius: 12px; padding: 18px 20px;
line-height: 1.9; font-size: 14px; white-space: pre-wrap; word-break: break-word;
border: 1px solid ${theme.edgeStroke};"></div>`;
for (const seg of chatSegments) {
const span = document.createElement("span");
const c = roleColor[seg.role] || theme.nodeText;
if (seg.kind === "control") {
span.textContent = seg.text;
span.style.cssText = `background:${c}; color:${theme.bg}; padding:1px 6px; border-radius:5px; font-weight:700;`;
} else if (seg.kind === "role") {
span.textContent = seg.text;
span.style.cssText = `color:${c}; font-weight:700;`;
} else {
span.textContent = seg.text;
span.style.cssText = `color:${theme.nodeText};`;
}
container.appendChild(span);
}
// Legend
const legend = html`<div style="display:flex; gap:16px; margin-top:14px; font-size:12px; font-family:var(--pg-mono); flex-wrap:wrap;"></div>`;
for (const [role, color] of Object.entries(roleColor)) {
const item = html`<span style="display:inline-flex; align-items:center; gap:6px; color:${theme.nodeText};">
<span style="width:12px;height:12px;border-radius:3px;background:${color};display:inline-block;"></span>${role}</span>`;
legend.appendChild(item);
}
const controlCount = chatSegments.filter(s => s.kind === "control").length;
const note = html`<div style="margin-top:10px; font-size:12px; color:${theme.nodeText}; opacity:0.8; font-family:var(--pg-mono);">
${controlCount} atomic control tokens · ${chatGenPrompt ? "open assistant turn ready for generation" : "closed transcript"}</div>`;
return html`<div>${container}${legend}${note}</div>`;
}<|im_start|>assistant disappears — you now have a transcript, not a prompt. Toggle it back on to prime the model to reply.<|im_start|> … <|im_end|>) plus one for the open generation turn. Every one is a single ID, not the characters you see.A model is trained with one specific chat template. Feed it a conversation formatted with a different one — wrong control tokens, a missing <|im_end|>, the role label in the wrong place — and quality collapses, because the boundaries it learned to rely on are no longer where it expects. When you use a pretrained chat model, always apply its template, not a generic one.
Explore tokenization interactively:
Special tokens: {'<PAD>': 0, '<UNK>': 1, '<BOS>': 2, '<EOS>': 3}
These tokens are reserved at IDs 0-3 before training begins.
The BPETokenizer class has key parameters:
vocab_size: Target vocabulary size (including special tokens)min_frequency: Minimum times a pair must appear to be merged (default: 2). This prevents rare pairs from being merged — if a pair only appears once, it’s likely noise rather than a useful pattern. Higher values create more conservative, generalizable vocabularies.verbose: Print detailed training progress# Simple text to train on
simple_text = "ab cd ab cd ab cd ab cd " * 20
# Create and train tokenizer
# vocab_size includes the 4 special tokens, so effective learned tokens = vocab_size - 4
tokenizer = BPETokenizer(vocab_size=30, verbose=False)
stats = tokenizer.train(simple_text, show_progress=True)
print(f"\nVocab size: {stats['vocab_size']}")
print(f"Merges learned: {stats['num_merges']}")
print(f"Special tokens: {stats['num_special_tokens']}")============================================================
BPE TOKENIZER TRAINING
============================================================
Text length: 480 characters
Target vocab size: 30
Training complete!
Final vocab size: 11
Merges learned: 2
Vocab size: 11
Merges learned: 2
Special tokens: 4
Encoding applies merges in their learned order. This is crucial - the merge order determines how text is split.
# Encode some text
test_text = "ab cd"
ids = tokenizer.encode(test_text)
tokens = [tokenizer.id_to_token(i) for i in ids]
print(f"Text: '{test_text}'")
print(f"Token IDs: {ids}")
print(f"Tokens: {tokens}")
# Decode back
decoded = tokenizer.decode(ids)
print(f"Decoded: '{decoded}'")
print(f"Round-trip successful: {test_text == decoded}")Text: 'ab cd'
Token IDs: [9, 4, 10]
Tokens: ['ab', ' ', 'cd']
Decoded: 'ab cd'
Round-trip successful: True
# With special tokens (used during actual LLM training/inference)
ids_with_special = tokenizer.encode(test_text, add_special_tokens=True)
print(f"\nWith special tokens: {ids_with_special}")
print(f"Tokens: {[tokenizer.id_to_token(i) for i in ids_with_special]}")
# Decoding skips special tokens by default
decoded = tokenizer.decode(ids_with_special, skip_special_tokens=True)
print(f"Decoded (skip special): '{decoded}'")
With special tokens: [2, 9, 4, 10, 3]
Tokens: ['<BOS>', 'ab', ' ', 'cd', '<EOS>']
Decoded (skip special): 'ab cd'
python_code = '''
def fibonacci(n):
"""Calculate the nth Fibonacci number."""
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
def factorial(n):
"""Calculate n factorial."""
if n <= 1:
return 1
return n * factorial(n - 1)
class Calculator:
def __init__(self):
self.result = 0
def add(self, x):
self.result += x
return self
def subtract(self, x):
self.result -= x
return self
# Main execution
if __name__ == "__main__":
print(fibonacci(10))
print(factorial(5))
'''
print(f"Training on {len(python_code)} characters of Python code")Training on 562 characters of Python code
============================================================
BPE TOKENIZER TRAINING
============================================================
Text length: 1,686 characters
Target vocab size: 200
Merge 50: 'self.res' + 'ul' → 'self.resul' (count: 9, progress: 32.1%)
Merge 100: 'sub' + 't' → 'subt' (count: 3, progress: 64.1%)
Training complete!
Final vocab size: 177
Merges learned: 133
Final vocab size: 177
Merges learned: 133
# Look at what code patterns were learned
print("Interesting tokens learned (longest first):")
print("=" * 40)
interesting_patterns = []
for token, id in code_tokenizer.vocab.items():
if len(token) >= 2 and not token.startswith('<'):
interesting_patterns.append((token, id))
# Sort by length (longer = more merged)
interesting_patterns.sort(key=lambda x: len(x[0]), reverse=True)
for token, id in interesting_patterns[:15]:
print(f" {id:3d}: {repr(token)}")Interesting tokens learned (longest first):
========================================
172: 'print(fibonacci(10))'
176: 'print(factorial(5))'
171: 'print(fibonacci(10'
170: 'print(fibonacci(1'
175: 'print(factorial(5'
169: 'print(fibonacci('
174: 'print(factorial('
136: '__init__(self):'
168: 'print(fibonacci'
173: 'print(factorial'
146: 'subtract(self,'
110: 'fibonacci(n):'
122: 'factorial(n):'
123: 'factorial."""'
135: '__init__(self'
def visualize_tokens(tokenizer, text):
"""Show how text is split into tokens with colors."""
ids = tokenizer.encode(text)
tokens = [tokenizer.id_to_token(i) for i in ids]
print(f"Original: {repr(text)}")
print(f"Tokens ({len(tokens)}): {tokens}")
print(f"IDs: {ids}")
print(f"Compression: {len(text)/len(ids):.2f} chars/token")
print()
# Try different code patterns
patterns = [
"def fibonacci(n):",
"self.result = 0",
"return self",
" for i in range(10):",
]
for pattern in patterns:
visualize_tokens(code_tokenizer, pattern)Original: 'def fibonacci(n):'
Tokens (3): ['def', ' ', 'fibonacci(n):']
IDs: [64, 5, 110]
Compression: 5.67 chars/token
Original: 'self.result = 0'
Tokens (5): ['self.result', ' ', '=', ' ', '0']
IDs: [94, 5, 21, 5, 15]
Compression: 3.00 chars/token
Original: 'return self'
Tokens (3): ['return', ' ', 'self']
IDs: [60, 5, 52]
Compression: 3.67 chars/token
Original: ' for i in range(10):'
Tokens (18): [' ', 'f', 'o', 'r', ' ', 'i', ' ', 'in', ' ', 'r', 'a', 'n', '<UNK>', 'e', '(', '1', '0', '):']
IDs: [45, 31, 37, 39, 5, 33, 5, 88, 5, 39, 26, 36, 1, 30, 8, 16, 15, 71]
Compression: 1.28 chars/token
Vocabulary size is one of the most important hyperparameters in tokenization:
Larger vocabulary: - (+) Shorter sequences = faster training, more context in fixed window - (+) Common words as single tokens = better semantic units - (-) Larger embedding table = more parameters, more memory - (-) Rare tokens get few training examples = poor representations
Smaller vocabulary: - (+) Smaller model, faster embedding lookups - (+) Every token well-trained on many examples - (-) Longer sequences = slower training, less context - (-) Words split into less meaningful pieces
test_text = "def calculate_fibonacci(number):\n return fibonacci(number)"
vocab_sizes = [50, 100, 200, 500]
print(f"Text: {repr(test_text)}")
print(f"Text length: {len(test_text)} characters")
print()
for vocab_size in vocab_sizes:
tok = BPETokenizer(vocab_size=vocab_size, verbose=False)
tok.train(python_code * 5, show_progress=False)
ids = tok.encode(test_text)
tokens = [tok.id_to_token(i) for i in ids]
print(f"Vocab size {vocab_size}:")
print(f" Tokens: {len(ids)}")
print(f" Ratio: {len(test_text)/len(ids):.1f} chars/token")
print(f" Sample: {[tok.id_to_token(i) for i in ids[:5]]}...")
print()Text: 'def calculate_fibonacci(number):\n return fibonacci(number)'
Text length: 61 characters
Vocab size 50:
Tokens: 54
Ratio: 1.1 chars/token
Sample: ['d', 'e', 'f', ' ', 'c']...
Vocab size 100:
Tokens: 27
Ratio: 2.3 chars/token
Sample: ['def', ' ', 'c', 'al', 'c']...
Vocab size 200:
Tokens: 27
Ratio: 2.3 chars/token
Sample: ['def', ' ', 'c', 'al', 'c']...
Vocab size 500:
Tokens: 27
Ratio: 2.3 chars/token
Sample: ['def', ' ', 'c', 'al', 'c']...
Real-world vocabulary sizes: - GPT-2: 50,257 tokens - GPT-4: ~100,000 tokens - Llama 2: 32,000 tokens - Claude: ~100,000 tokens
import tempfile
import os
# Save tokenizer
save_path = tempfile.mktemp(suffix='.json')
code_tokenizer.save(save_path)
# Load it back
loaded = BPETokenizer.load(save_path)
# Verify it works the same
test = "def test():"
original_ids = code_tokenizer.encode(test)
loaded_ids = loaded.encode(test)
print(f"\nOriginal encoding: {original_ids}")
print(f"Loaded encoding: {loaded_ids}")
print(f"Match: {original_ids == loaded_ids}")
# Cleanup
os.unlink(save_path)Tokenizer saved to /var/folders/hl/bw75m5hd5xvfyx8j9qd71vjw0000gn/T/tmpa34j3azp.json
Tokenizer loaded from /var/folders/hl/bw75m5hd5xvfyx8j9qd71vjw0000gn/T/tmpa34j3azp.json
Vocab size: 177
Original encoding: [64, 5, 41, 30, 40, 41, 8, 71]
Loaded encoding: [64, 5, 41, 30, 40, 41, 8, 71]
Match: True
Watch BPE tokenization step by step. Type text and see how it gets broken into tokens through iterative pair merging.
This interactive demo uses a simplified, pre-defined set of common English merge rules — not dynamically computed merges. A real tokenizer learns merges from a training corpus, but the mechanism shown here is identical. The Python implementation above (BPETokenizer) demonstrates actual BPE training.
bpeMerges = [
// Common letter pairs
["t", "h", "th"],
["h", "e", "he"],
["i", "n", "in"],
["e", "r", "er"],
["a", "n", "an"],
["r", "e", "re"],
["o", "n", "on"],
["e", "s", "es"],
["o", "r", "or"],
["t", "i", "ti"],
["e", "n", "en"],
["a", "t", "at"],
["e", "d", "ed"],
["o", "u", "ou"],
["i", "s", "is"],
["i", "t", "it"],
["a", "l", "al"],
["a", "r", "ar"],
["s", "t", "st"],
["l", "l", "ll"],
["l", "e", "le"],
["n", "d", "nd"],
// Common trigrams
["th", "e", "the"],
["in", "g", "ing"],
["an", "d", "and"],
["ti", "on", "tion"],
["er", "s", "ers"],
["he", "r", "her"],
["ll", "o", "llo"],
["he", "ll", "hell"],
["hell", "o", "hello"],
["w", "or", "wor"],
["wor", "l", "worl"],
["worl", "d", "world"]
]
// Apply a single merge to token list
function applyMerge(tokens, left, right, merged) {
const result = [];
let i = 0;
while (i < tokens.length) {
if (i < tokens.length - 1 && tokens[i] === left && tokens[i + 1] === right) {
result.push(merged);
i += 2;
} else {
result.push(tokens[i]);
i += 1;
}
}
return result;
}
// Apply merges up to a certain step
function tokenizeWithSteps(text, maxStep) {
// Start with character-level tokens (preserve spaces)
let tokens = text.split('');
const steps = [{ tokens: [...tokens], mergeApplied: null }];
for (let i = 0; i < Math.min(maxStep, bpeMerges.length); i++) {
const [left, right, merged] = bpeMerges[i];
const newTokens = applyMerge(tokens, left, right, merged);
// Only record step if something changed
if (newTokens.length !== tokens.length) {
tokens = newTokens;
steps.push({
tokens: [...tokens],
mergeApplied: `"${left}" + "${right}" → "${merged}"`
});
}
}
return { finalTokens: tokens, steps };
}
// Fully tokenize (all merges)
function tokenize(text) {
let tokens = text.split('');
for (const [left, right, merged] of bpeMerges) {
tokens = applyMerge(tokens, left, right, merged);
}
return tokens;
}// Widget theme - uses diagramTheme from _diagram-lib.qmd which already handles dark mode
theme = {
const t = diagramTheme;
return {
textPrimary: t.nodeText,
textMuted: t.edgeStroke,
// Was an ad-hoc flat hex (#e5e7eb, Tailwind gray-200 — off the diagram's
// warm-stone palette) in light mode only, paired with an unrelated
// translucent tint in dark. Both branches now derive from the diagram's
// own tokenized fill-alt (--diagram-hover-fill via diagramTheme.nodeFillHover)
// so "space" highlights read as the same material in both themes.
spaceBg: t.nodeFillHover,
spaceBorder: t.edgeStroke,
tokenBorder: 50,
tokenLightness: t.isDark ? 25 : 85,
historyBg: t.bgSecondary,
stepBg: t.bg === 'transparent' ? t.bgSecondary : t.bg,
stepBorderInitial: t.edgeStroke,
stepBorderMerge: t.accent,
stepTextMuted: t.edgeStroke,
tokenStepBg: t.isDark ? 'rgba(56, 189, 248, 0.15)' : 'rgba(14, 165, 233, 0.15)',
spaceStepBg: t.nodeFillHover,
isDark: t.isDark
};
}viewof inputText = Inputs.text({
label: "Enter text",
value: "hello world",
placeholder: "Type something...",
width: 400
})
viewof showSteps = Inputs.toggle({
label: "Show step-by-step",
value: true
})
viewof maxMergeStep = Inputs.range([0, bpeMerges.length], {
value: bpeMerges.length,
step: 1,
label: "Merge steps to apply",
disabled: !showSteps
})result = tokenizeWithSteps(inputText.toLowerCase(), showSteps ? maxMergeStep : bpeMerges.length)
finalTokens = result.finalTokens
tokenizationSteps = result.steps
// Stats
charCount = inputText.length
tokenCount = finalTokens.length
compressionRatio = charCount > 0 ? (charCount / tokenCount).toFixed(2) : 0// Token visualization as colored boxes
tokenVisualization = html`
<div style="margin: 20px 0; color: ${theme.textPrimary};">
<strong>Tokens (${tokenCount}):</strong>
<div style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px;">
${finalTokens.map((token, i) => {
// Color based on token length (longer = more merged)
const hue = Math.min(token.length * 30, 200);
const color = `hsl(${hue}, 70%, ${theme.tokenLightness}%)`;
const isSpace = token === ' ';
return html`<span style="
background: ${isSpace ? theme.spaceBg : color};
padding: 4px 8px;
border-radius: 4px;
font-family: var(--pg-mono);
font-size: 14px;
color: ${theme.textPrimary};
border: 1px solid ${isSpace ? theme.spaceBorder : `hsl(${hue}, ${theme.tokenBorder}%, ${theme.isDark ? 50 : 60}%)`};
">${isSpace ? '␣' : token}</span>`;
})}
</div>
</div>
`// Step-by-step view (when enabled)
mergeHistory = showSteps && maxMergeStep > 0 ? html`
<div style="margin-top: 20px; padding: 15px; background: ${theme.historyBg}; border-radius: 8px; color: ${theme.textPrimary};">
<strong>Merge History:</strong>
<div style="font-family: var(--pg-mono); font-size: 13px; margin-top: 10px;">
${tokenizationSteps.map((step, i) => html`
<div style="margin: 8px 0; padding: 8px; background: ${theme.stepBg}; border-radius: 4px; border-left: 3px solid ${i === 0 ? theme.stepBorderInitial : theme.stepBorderMerge};">
<div style="color: ${theme.textMuted}; font-size: 11px; margin-bottom: 4px;">
${i === 0 ? 'Initial (characters)' : `Step ${i}: ${step.mergeApplied}`}
</div>
<div style="display: flex; flex-wrap: wrap; gap: 2px;">
${step.tokens.map(t => html`<span style="background: ${t === ' ' ? theme.spaceStepBg : theme.tokenStepBg}; padding: 2px 6px; border-radius: 3px; color: ${theme.textPrimary};">${t === ' ' ? '␣' : t}</span>`)}
</div>
<div style="color: ${theme.stepTextMuted}; font-size: 11px; margin-top: 4px;">${step.tokens.length} tokens</div>
</div>
`)}
</div>
</div>
` : html``Common words merge well: Type “the” or “and” - they become single tokens quickly due to high-frequency merges.
Step through merges: Enable “Show step-by-step” and slide the merge steps from 0 to max. Watch how character pairs combine into larger tokens.
Rare words stay split: Type “xyz” or uncommon words - they remain as characters because those patterns weren’t in the training data.
Compression varies: Compare “the the the” (high compression) vs “qxz qxz qxz” (low compression). Common patterns compress better.
Spaces are preserved: Notice that spaces remain as separate tokens (shown as ␣). This is typical BPE behavior.
BPE achieves better compression on repetitive text. This matters because better compression = shorter sequences = more context in the model’s window.
# Train on repetitive vs varied text and compare compression
texts = {
"repetitive": "the the the " * 100,
"varied": " ".join([f"word{i}" for i in range(100)]),
"code": python_code,
}
print("Compression comparison:")
print("=" * 40)
for name, text in texts.items():
tok = BPETokenizer(vocab_size=200, verbose=False)
tok.train(text, show_progress=False)
ids = tok.encode(text)
ratio = len(text) / len(ids)
print(f"{name:12s}: {ratio:.2f} chars/token")
print("\nNote: Repetitive text compresses best because BPE learns")
print("common patterns. Code has structure but more variety.")Compression comparison:
========================================
repetitive : 2.00 chars/token
varied : 2.38 chars/token
code : 2.70 chars/token
Note: Repetitive text compresses best because BPE learns
common patterns. Code has structure but more variety.
The first merges reveal the most frequent patterns in your data. For English text, you’ll often see common letter pairs like ‘th’, ‘he’, ‘in’.
# What patterns are learned first?
sample_text = "hello world hello world hello world " * 10
tok = BPETokenizer(vocab_size=50, verbose=False)
tok.train(sample_text, show_progress=False)
print("First 10 merges (most frequent patterns):")
for i, ((a, b), merged) in enumerate(list(tok.merges.items())[:10]):
print(f" {i+1}. '{a}' + '{b}' = '{merged}'")
print("\nNotice: Common substrings merge first, eventually")
print("forming complete words like 'hello' and 'world'.")First 10 merges (most frequent patterns):
1. 'h' + 'e' = 'he'
2. 'he' + 'l' = 'hel'
3. 'hel' + 'l' = 'hell'
4. 'hell' + 'o' = 'hello'
5. 'w' + 'o' = 'wo'
6. 'wo' + 'r' = 'wor'
7. 'wor' + 'l' = 'worl'
8. 'worl' + 'd' = 'world'
Notice: Common substrings merge first, eventually
forming complete words like 'hello' and 'world'.
Our simple tokenizer can only encode characters it saw during training. Characters not in the vocabulary become <UNK> tokens. This exercise demonstrates the problem — and why production tokenizers use byte-level BPE to solve it.
# What happens with characters not in training?
tokenizer = BPETokenizer(vocab_size=50, verbose=False)
tokenizer.train("hello world", show_progress=False)
# Try encoding text with emoji
test = "hello world" # Safe text
try:
ids = tokenizer.encode(test)
print(f"'{test}' -> {ids}")
print(f"Decoded: '{tokenizer.decode(ids)}'")
except Exception as e:
print(f"Error: {e}")
# Now try with a character not in training
test2 = "hello 123"
ids = tokenizer.encode(test2)
tokens = [tokenizer.id_to_token(i) for i in ids]
print(f"\n'{test2}' -> {ids}")
print(f"Tokens: {tokens}")
print("\nNotice: '1', '2', '3' become <UNK> (ID 1) because they")
print("weren't in the training data.")
print("\nThe byte-level tokenizer we built above has no such failure mode.")'hello world' -> [7, 6, 8, 8, 9, 4, 11, 9, 10, 8, 5]
Decoded: 'hello world'
'hello 123' -> [7, 6, 8, 8, 9, 4, 1, 1, 1]
Tokens: ['h', 'e', 'l', 'l', 'o', ' ', '<UNK>', '<UNK>', '<UNK>']
Notice: '1', '2', '3' become <UNK> (ID 1) because they
weren't in the training data.
The byte-level tokenizer we built above has no such failure mode.
# The same unseen text, encoded with the byte-level tokenizer we built
from tokenizer import ByteLevelBPETokenizer
byte_tokenizer = ByteLevelBPETokenizer(vocab_size=400, verbose=False)
byte_tokenizer.train("hello world", show_progress=False)
for probe in ["hello 123", "hello ☕", "你好"]:
ids = byte_tokenizer.encode(probe)
unk = sum(1 for i in ids if i == SPECIAL_TOKENS["<UNK>"])
print(f" {probe!r:12} → {len(ids)} tokens, {unk} <UNK>, "
f"round-trip={byte_tokenizer.decode(ids) == probe}")
print("\nOperating on UTF-8 bytes (0-255) means any input is encodable —")
print("this is exactly how tiktoken and SentencePiece avoid <UNK>.") 'hello 123' → 9 tokens, 0 <UNK>, round-trip=True
'hello ☕' → 9 tokens, 0 <UNK>, round-trip=True
'你好' → 6 tokens, 0 <UNK>, round-trip=True
Operating on UTF-8 bytes (0-255) means any input is encodable —
this is exactly how tiktoken and SentencePiece avoid <UNK>.
Whitespace is tricky in tokenization. Our tokenizer preserves it, but notice how spaces can be part of tokens.
# Whitespace is significant in tokenization
code_tok = BPETokenizer(vocab_size=100, verbose=False)
code_tok.train("def foo():\n return 1\ndef bar():\n return 2", show_progress=False)
# See how indentation is tokenized
samples = [
"def foo():",
" return", # 4 spaces
" x", # 8 spaces
]
for sample in samples:
ids = code_tok.encode(sample)
tokens = [code_tok.id_to_token(i) for i in ids]
print(f"{repr(sample):20s} -> {tokens}")
print("\nIn production tokenizers, leading spaces often attach to")
print("the following word: ' hello' is one token, not ' ' + 'hello'")'def foo():' -> ['def', ' ', 'f', 'o', 'o', '():']
' return' -> [' ', ' ', 'return']
' x' -> [' ', ' ', ' ', ' ', '<UNK>']
In production tokenizers, leading spaces often attach to
the following word: ' hello' is one token, not ' ' + 'hello'
Tokenization occupies the first stage of the LLM pipeline:
llmPipelineDiagram = {
const width = 720;
const height = 200;
// Pipeline stages
const stages = [
{ id: 'input', x: 60, label: 'Raw Text', sublabel: "'def hello():'", group: 'Input' },
{ id: 'split', x: 180, label: 'Split', sublabel: "['def',' ','hello',...]", group: 'Tokenization' },
{ id: 'lookup', x: 300, label: 'Look up IDs', sublabel: '[42, 5, 128, ...]', group: 'Tokenization' },
{ id: 'embed', x: 430, label: 'Embeddings', sublabel: 'Module 04', group: 'Model' },
{ id: 'transform', x: 550, label: 'Transformer', sublabel: 'Module 06', group: 'Model' },
{ id: 'decode', x: 670, label: 'Decode', sublabel: 'Back to text', group: 'Output' }
];
// Stage descriptions
const descriptions = [
"Start: Raw source code text as input",
"Tokenization: Split text into subword tokens using BPE",
"Tokenization: Convert tokens to integer IDs via vocabulary lookup",
"Model: Map token IDs to dense vector embeddings",
"Model: Process embeddings through transformer layers",
"Output: Decode predicted token IDs back to readable text"
];
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`);
// Background
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', diagramTheme.bg)
.attr('rx', 8);
// Group backgrounds
const groups = [
{ name: 'Input', x1: 20, x2: 120, color: diagramTheme.nodeStroke },
{ name: 'Tokenization', x1: 130, x2: 360, color: diagramTheme.highlight },
{ name: 'Model', x1: 370, x2: 610, color: diagramTheme.accent },
{ name: 'Output', x1: 620, x2: 710, color: diagramTheme.nodeStroke }
];
groups.forEach(group => {
const isActive = stages.filter(s => s.group === group.name)
.some((s, i) => stages.indexOf(s) === pipelineStep);
svg.append('rect')
.attr('x', group.x1)
.attr('y', 25)
.attr('width', group.x2 - group.x1)
.attr('height', 95)
.attr('rx', 6)
.attr('fill', 'transparent')
.attr('stroke', isActive ? group.color : diagramTheme.nodeStroke)
.attr('stroke-width', isActive ? 2 : 1)
.attr('stroke-dasharray', isActive ? 'none' : '4,2')
.attr('opacity', isActive ? 1 : 0.4);
svg.append('text')
.attr('x', (group.x1 + group.x2) / 2)
.attr('y', 40)
.attr('text-anchor', 'middle')
.attr('fill', isActive ? group.color : diagramTheme.nodeText)
.attr('font-size', '10px')
.attr('font-weight', isActive ? '600' : '400')
.attr('opacity', isActive ? 1 : 0.5)
.text(group.name);
});
// Arrow marker
const defs = svg.append('defs');
defs.append('marker')
.attr('id', 'pipeline-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 5)
.attr('markerHeight', 5)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', diagramTheme.edgeStroke);
defs.append('marker')
.attr('id', 'pipeline-arrow-active')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 5)
.attr('markerHeight', 5)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', diagramTheme.highlight);
// Draw nodes
const nodeY = 85;
const nodeW = 90;
const nodeH = 48;
stages.forEach((stage, i) => {
const isActive = i === pipelineStep;
const isPast = i < pipelineStep;
const g = svg.append('g')
.attr('transform', `translate(${stage.x}, ${nodeY})`);
const rect = g.append('rect')
.attr('x', -nodeW / 2)
.attr('y', -nodeH / 2)
.attr('width', nodeW)
.attr('height', nodeH)
.attr('rx', 6)
.attr('fill', isActive ? diagramTheme.highlight :
isPast ? diagramTheme.bgSecondary : diagramTheme.nodeFill)
.attr('stroke', isActive ? diagramTheme.highlight :
isPast ? diagramTheme.accent : diagramTheme.nodeStroke)
.attr('stroke-width', isActive ? 2.5 : 1.5);
if (isActive) {
rect.attr('filter', `drop-shadow(0 0 10px ${diagramTheme.highlightGlow})`);
}
// Main label
g.append('text')
.attr('x', 0)
.attr('y', -6)
.attr('text-anchor', 'middle')
.attr('fill', isActive ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr('font-size', '11px')
.attr('font-weight', '600')
.text(stage.label);
// Sublabel
g.append('text')
.attr('x', 0)
.attr('y', 10)
.attr('text-anchor', 'middle')
.attr('fill', isActive ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr('font-size', '9px')
.style('font-family', 'var(--pg-mono)')
.attr('opacity', isActive ? 0.9 : 0.6)
.text(stage.sublabel);
// Draw arrow to next stage
if (i < stages.length - 1) {
const nextStage = stages[i + 1];
const arrowActive = i === pipelineStep - 1;
svg.append('path')
.attr('d', `M${stage.x + nodeW/2 + 5},${nodeY} L${nextStage.x - nodeW/2 - 10},${nodeY}`)
.attr('stroke', arrowActive ? diagramTheme.highlight : diagramTheme.edgeStroke)
.attr('stroke-width', arrowActive ? 2 : 1.5)
.attr('marker-end', `url(#${arrowActive ? 'pipeline-arrow-active' : 'pipeline-arrow'})`);
}
});
// Description
svg.append('rect')
.attr('x', 20)
.attr('y', height - 50)
.attr('width', width - 40)
.attr('height', 35)
.attr('rx', 6)
.attr('fill', diagramTheme.bgSecondary);
svg.append('text')
.attr('x', width / 2)
.attr('y', height - 28)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '12px')
.text(descriptions[pipelineStep]);
return svg.node();
}Everything so far accepts one premise: learn a fixed vocabulary of subwords, then look every piece of text up in it. BPE is a brilliant way to build that vocabulary — but the vocabulary itself is a frozen table, chosen by compression frequency, not by how hard to predict each piece of text actually is. The frontier is now questioning that premise directly.
The Byte Latent Transformer (BLT, Meta 2024) throws the tokenizer away. It runs straight on raw UTF-8 bytes — no vocabulary, no <UNK>, no merge rules — and groups those bytes into patches whose boundaries it chooses dynamically, per input, by how surprising each next byte is. A patch is BLT’s unit of compute, the way a token is BPE’s.
Read the bytes of predicts one at a time. After predic, the next byte is almost certainly t — you barely need a model to guess it. But the very first byte of a new word could be almost anything. Predictable stretches carry little information; the surprising bytes are where the real decisions happen.
BLT measures that surprise with a small, separate byte-level language model and uses it as a ruler: wherever the next-byte surprise spikes, start a new patch; wherever bytes are predictable, let them ride together in one big patch. The result is that the expensive model runs once per patch — so compute flows to the hard-to-predict regions and skims over the easy ones. A fixed tokenizer can’t do this: its boundaries are baked in before it ever sees your sentence.
A BPE token is a static unit chosen once, at training time, by frequency. A BLT patch is a dynamic unit chosen at inference time, per sequence, by predictive difficulty. Same goal — cut the byte stream into chunks the big model processes — but the cut is made by entropy, not by a lookup table.
The “surprise” of the byte at position t is the Shannon entropy of the model’s prediction for it, over all 256 possible byte values:
H(x_t) = -\sum_{v=0}^{255} p_e(x_t = v \mid x_{<t}) \, \log_2 p_e(x_t = v \mid x_{<t})
where p_e is the small entropy model. With base-2 logs, H is in bits and ranges from 0 (the model is certain which byte comes next) to \log_2 256 = 8 (a uniform guess). BLT turns that signal into patch boundaries with one of two rules:
The threshold is the single knob that sets the average patch size: raise it and patches grow (fewer, cheaper global steps); lower it and they shrink (more, finer-grained steps).
Patching is the idea; the model wraps it in three pieces. Step through them:
bltArchSteps = [
{label: "Raw bytes", sub: "no tokenizer", caption: "The input is the raw UTF-8 byte stream — every byte 0–255 is a valid, known input. There is no vocabulary and no <UNK>."},
{label: "Entropy patcher", sub: "small byte LM", caption: "A small, separate byte-level model scores next-byte entropy H(xₜ). Boundaries land where the surprise spikes — a cheap preprocessing pass."},
{label: "Local encoder", sub: "bytes → patch", caption: "A lightweight transformer pools the bytes of each patch into one patch vector. Small model, runs on every byte, but only locally."},
{label: "Latent transformer",sub: "the compute lives here", caption: "The big, expensive transformer runs autoregressively over PATCH vectors — one step per patch, not per byte. Fewer patches ⇒ less compute."},
{label: "Local decoder", sub: "patch → bytes", caption: "A lightweight transformer expands each predicted patch vector back into raw output bytes. Byte-level in, byte-level out."}
]bltArchDiagram = {
const width = 760, height = 220;
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%")
.attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect")
.attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const n = bltArchSteps.length;
const boxW = 118, boxH = 62, gap = (width - 40 - n * boxW) / (n - 1);
const y = 46;
const defs = svg.append("defs");
defs.append("marker")
.attr("id", "blt-arch-arrow").attr("viewBox", "0 -5 10 10")
.attr("refX", 8).attr("refY", 0).attr("markerWidth", 5).attr("markerHeight", 5)
.attr("orient", "auto").append("path").attr("d", "M0,-5L10,0L0,5")
.attr("fill", theme.edgeStroke);
bltArchSteps.forEach((s, i) => {
const x = 20 + i * (boxW + gap);
const active = i === bltArchStep;
// The latent transformer (stage 3) is the compute-heavy stage — draw it taller.
const heavy = i === 3;
const h = heavy ? boxH + 14 : boxH;
const yy = heavy ? y - 7 : y;
const g = svg.append("g");
const rect = g.append("rect")
.attr("x", x).attr("y", yy).attr("width", boxW).attr("height", h).attr("rx", 8)
.attr("fill", active ? theme.highlight : theme.nodeFill)
.attr("stroke", active ? theme.highlight : (heavy ? theme.accent : theme.nodeStroke))
.attr("stroke-width", active ? 2.5 : (heavy ? 2 : 1.5));
if (active) rect.attr("filter", `drop-shadow(0 0 10px ${theme.highlightGlow})`);
g.append("text")
.attr("x", x + boxW / 2).attr("y", yy + h / 2 - 4).attr("text-anchor", "middle")
.attr("fill", active ? theme.textOnHighlight : theme.nodeText)
.attr("font-size", "12px").attr("font-weight", "600").text(s.label);
g.append("text")
.attr("x", x + boxW / 2).attr("y", yy + h / 2 + 13).attr("text-anchor", "middle")
.attr("fill", active ? theme.textOnHighlight : theme.edgeStroke)
.attr("font-size", "9.5px").attr("opacity", active ? 0.9 : 0.7).text(s.sub);
if (i < n - 1) {
svg.append("path")
.attr("d", `M${x + boxW + 3},${y + boxH / 2} L${x + boxW + gap - 4},${y + boxH / 2}`)
.attr("stroke", theme.edgeStroke).attr("stroke-width", 1.5)
.attr("marker-end", "url(#blt-arch-arrow)");
}
});
svg.append("rect")
.attr("x", 20).attr("y", height - 58).attr("width", width - 40).attr("height", 42)
.attr("rx", 6).attr("fill", theme.bgSecondary);
// Word-wrap the caption into up to two lines.
const words = bltArchSteps[bltArchStep].caption.split(" ");
const lines = ["", ""];
let li = 0;
words.forEach(w => {
if ((lines[li] + " " + w).trim().length > 82 && li === 0) li = 1;
lines[li] = (lines[li] + " " + w).trim();
});
svg.append("text")
.attr("x", width / 2).attr("y", height - 38).attr("text-anchor", "middle")
.attr("fill", theme.nodeText).attr("font-size", "11px").text(lines[0]);
svg.append("text")
.attr("x", width / 2).attr("y", height - 24).attr("text-anchor", "middle")
.attr("fill", theme.nodeText).attr("font-size", "11px").text(lines[1]);
return svg.node();
}The tokenizer chapter’s whole job — cutting text into the units a big model processes — is here done by the entropy patcher, and the “vocabulary” is just the 256 byte values. Everything you built with BPE still teaches the ideas; BLT is one answer to what if the cut were learned end-to-end from bytes instead.
We can build the whole patcher from scratch. The entropy model is the one part BLT trains as a real (small) transformer; here we stand it in with a count-based n-gram byte model — it conditions the next byte on the previous few, backs off to shorter context when it hasn’t seen a longer one, and gives us a genuine entropy signal to threshold. All of this lives in blt.py.
uniform over 256 bytes: 8.0 bits
certain (one-hot): 0.0 bits
Train the tiny entropy model on a little coherent corpus, then read off the per-byte surprise of a sentence built from words it has seen:
corpus = (
"the model learns to predict the next token from the previous tokens. "
"a language model predicts the next token. the model reads the tokens and "
"learns the patterns in the tokens. attention lets the model read every "
"token. the transformer predicts tokens one token at a time. "
) * 6
entropy_model = ByteEntropyModel(order=4).train(corpus)
demo = "the model predicts the next token"
ents = entropy_model.entropies(demo)
# Show the surprise of each byte — spikes at word starts, dips inside words.
for ch, h in list(zip(demo, ents))[:14]:
bar = "█" * int(h * 3)
print(f"{ch!r:5} {h:4.1f} bits {bar}")'t' 3.9 bits ███████████
'h' 2.8 bits ████████
'e' 0.6 bits █
' ' 0.6 bits █
'm' 2.5 bits ███████
'o' 1.2 bits ███
'd' 1.2 bits ███
'e' 1.2 bits ███
'l' 1.2 bits ███
' ' 1.2 bits ███
'p' 2.6 bits ███████
'r' 3.3 bits █████████
'e' 1.2 bits ███
'd' 2.0 bits █████
Now turn that signal into patches with the global rule, and measure the payoff — the big transformer runs once per patch, so the average patch size is (roughly) the factor by which its work shrinks versus running on every byte:
from blt import (
patch_boundaries_global, patches_from_boundaries,
average_patch_size, relative_global_compute,
)
theta = 2.0 # bits
boundaries = patch_boundaries_global(ents, theta)
patches = patches_from_boundaries(list(demo.encode()), boundaries)
avg = average_patch_size(boundaries, len(demo.encode()))
print(f"θ = {theta} bits → {len(patches)} patches, avg {avg:.2f} bytes/patch")
print(f"global transformer compute vs per-byte: {relative_global_compute(avg):.2f}×")
print("patches:", [bytes(p).decode() for p in patches])θ = 2.0 bits → 14 patches, avg 2.36 bytes/patch
global transformer compute vs per-byte: 0.42×
patches: ['t', 'he ', 'model ', 'p', 'redict', 's', ' the ', 'n', 'e', 'x', 't', ' ', 't', 'oken']
Raise theta and the patches merge; lower it and they split — one knob trading model resolution against compute.
Below is the same demo sentence with each byte tinted by its entropy (calm → hot). Drag the threshold and watch the patch boundaries move: bytes stay glued together in the predictable, cool stretches and split apart where the surprise runs hot. The live stats show how the average patch size — and the big model’s compute — respond.
// Compute patch start indices from the entropy strip + threshold, in JS, so the
// widget stays live without re-running Python. Mirrors patch_boundaries_* in blt.py.
bltBoundaries = {
const H = bltEntropies, n = H.length, starts = [0];
for (let t = 1; t < n; t++) {
const cut = bltRule === "global" ? H[t] > bltTheta : (H[t] - H[t - 1]) > bltTheta;
if (cut) starts.push(t);
}
return starts;
}bltPatchStrip = {
const theme = diagramTheme;
const chars = bltChars, H = bltEntropies, n = chars.length;
const starts = new Set(bltBoundaries);
// calm (low entropy) → hot (high entropy)
const heat = d3.scaleLinear().domain([0, bltEntropyMax]).range([0, 1]).clamp(true);
const color = h => d3.interpolateRgb(theme.bgSecondary, theme.highlight)(heat(h));
const container = html`<div style="margin: 12px 0;"></div>`;
// Byte cells, boxed into patches by a left border wherever a new patch starts.
const strip = html`<div style="display:flex; flex-wrap:wrap; gap:2px; align-items:flex-end;"></div>`;
chars.forEach((ch, i) => {
const isStart = starts.has(i);
const cell = html`<div title="H = ${H[i].toFixed(2)} bits" style="
min-width: 20px; text-align:center; padding:6px 4px 4px;
background:${color(H[i])};
border-radius:3px;
border-left:${isStart ? `3px solid ${theme.accent}` : "3px solid transparent"};
margin-left:${isStart && i > 0 ? "8px" : "0"};">
<div style="font-family:var(--pg-mono); font-size:14px; color:${theme.nodeText};">${ch === " " ? "␣" : ch}</div>
<div style="font-family:var(--pg-mono); font-size:8px; color:${theme.nodeText}; opacity:0.55;">${H[i].toFixed(1)}</div>
</div>`;
strip.appendChild(cell);
});
container.appendChild(strip);
// Live stats.
const numPatches = starts.size;
const avg = n / numPatches;
const rel = 1 / avg;
const stats = html`<div style="font-family:var(--pg-mono); font-size:13px; margin-top:14px; color:${theme.nodeText};">
<strong>${numPatches}</strong> patches
· avg <strong>${avg.toFixed(2)}</strong> bytes/patch
· global compute
<span style="color:${theme.accent}; font-weight:600;">${rel.toFixed(2)}×</span>
<span style="opacity:0.65;">of per-byte</span>
</div>`;
container.appendChild(stats);
// Legend.
const legend = html`<div style="display:flex; align-items:center; gap:8px; font-family:var(--pg-mono); font-size:11px; margin-top:8px; color:${theme.nodeText}; opacity:0.75;">
<span>calm</span>
<span style="display:inline-block; width:120px; height:10px; border-radius:5px;
background:linear-gradient(90deg, ${color(0)}, ${color(bltEntropyMax)});"></span>
<span>surprising</span>
<span style="margin-left:12px; border-left:3px solid ${theme.accent}; padding-left:6px;">= new patch</span>
</div>`;
container.appendChild(legend);
return container;
}0, almost every byte starts its own patch (byte-level, maximum compute). Near the top, the whole sentence collapses into one or two patches (cheap, but the big model sees very coarse units).m of model, p of predicts — because the first byte of a word is the surprising one. The predictable tails (odel, oken) stay glued.The patcher’s ruler is its own small byte LM, trained before the main model and frozen during patching (a cheap preprocessing pass over the data). Two things to keep straight: high entropy means the next byte is hard to predict, not that the content is important or that any answer is “wrong”; and our count-based n-gram stand-in falls back toward uniform (≈8 bits) on byte contexts it never saw, so on truly novel text a real trained entropy transformer gives a smoother, better signal than this toy. The mechanism — threshold the surprise to place boundaries — is identical.
BLT is the first byte-level architecture to match a strong BPE-tokenized model (Llama 3) at scale in a compute-controlled study up to 8B parameters, while using up to ~50% fewer inference FLOPs by spending them only where bytes are hard. Dropping the fixed vocabulary also buys robustness to noisy or unusual text and strong character-level manipulation — with no <UNK> and no tokenizer to train.
Core Papers:
Key takeaways:
<UNK> and an exact round-trip. This is the layout GPT-2, tiktoken, and SentencePiece all use.<|im_start|>/<|im_end|> control tokens to mark turns. Roles are just tokens; the add_generation_prompt open turn is what primes the model to reply. The template is part of the model’s contract.H(xₜ) spikes, so the big transformer runs once per patch and compute flows to the hard-to-predict bytes. A patch is BLT’s dynamic answer to BPE’s static token.Even the byte-level tokenizer we built still differs from production tokenizers in several ways:
| Our Tokenizer | Production Tokenizers |
|---|---|
| Byte-level BPE (built above) — plus a simpler character-level version | Byte-level BPE (same core idea) |
| GPT-2 regex pre-tokenization (built above) | GPT-2 regex pre-tokenization (same) |
| Python dict lookups | Optimized Rust/C++ (tiktoken is 10x+ faster) |
| Trained on tiny corpora | Trained on trillions of tokens |
Pre-tokenization — which we built above with gpt2_pretokenize — is the step that keeps merges linguistically clean by bounding BPE to one pre-token at a time. The main gap that remains is speed: production tokenizers run the same regex and BPE in optimized Rust/C++ (tiktoken is 10×+ faster than our teaching Python) and cache merges aggressively, but the algorithm is exactly the one you just built.
Module 04: Embeddings converts token IDs into dense vectors that capture meaning. Each token becomes a learnable vector in high-dimensional space.
---
title: "Module 03: Tokenization"
format:
html:
code-fold: false
toc: true
ipynb: default
jupyter: python3
---
{{< include ../_diagram-lib.qmd >}}
{{< include ../_components/step-control.qmd >}}
## Introduction
A language model requires numbers, not text. Tokenization breaks text into tokens and maps each to an integer.
**Tokenization** converts raw text into integers the model can process. Modern LLMs use **subword tokenization** - they break text into pieces smaller than words but larger than characters.
Why subword tokenization?
- **Word-level**: Cannot handle new words (OOV problem), huge vocabulary needed (millions for multilingual)
- **Character-level**: Sequences become 4-5x longer, attention cost explodes O(n^2), model learns spelling from scratch
- **Subword**: Best of both worlds - handles new words via decomposition, reasonable sequence length
**BPE (Byte Pair Encoding)** dominates modern tokenization. Philip Gage invented it for data compression in 1994; researchers adapted it for NLP in 2016:
1. Start with individual characters as the initial vocabulary
2. Count all adjacent token pairs in the training corpus
3. Merge the most frequent pair into a new token
4. Add the merged token to the vocabulary
5. Repeat until vocabulary size reached
### What You'll Learn
After this module, you can:
- Build a character-level tokenizer from scratch
- Understand why subword tokenization outperforms alternatives
- Implement the BPE algorithm for training and encoding
- Build **byte-level BPE** that encodes any text with no `<UNK>`
- Add **GPT-2 regex pre-tokenization** to keep merges linguistically clean
- Handle special tokens (PAD, UNK, BOS, EOS)
- Serialize a role-tagged conversation with a **ChatML chat template**
- Recognize trade-offs in vocabulary size
### Prerequisites
This module requires familiarity with:
- [Module 01: Tensors](../m01_tensors/lesson.qmd) — Basic tensor operations and shapes
First, build the simplest tokenizer from scratch.
## The Simplest Tokenizer
The simplest approach treats each character as a token.
```{python}
# Build vocabulary from text
text = "hello world"
chars = sorted(set(text))
print(f"Unique characters: {chars}")
print(f"Vocabulary size: {len(chars)}")
```
```{python}
# The core of any tokenizer: two lookup tables
stoi = {ch: i for i, ch in enumerate(chars)} # string to integer
itos = {i: ch for i, ch in enumerate(chars)} # integer to string
print("stoi (encode):", stoi)
print("itos (decode):", itos)
```
```{python}
# Encode: text -> integers
def encode(text):
return [stoi[ch] for ch in text]
# Decode: integers -> text
def decode(ids):
return ''.join(itos[i] for i in ids)
# Try it out
encoded = encode("hello")
print(f"'hello' -> {encoded}")
print(f"{encoded} -> '{decode(encoded)}'")
```
```{python}
# Round-trip test
original = "hello world"
reconstructed = decode(encode(original))
print(f"Original: '{original}'")
print(f"Reconstructed: '{reconstructed}'")
print(f"Perfect round-trip: {original == reconstructed}")
```
Ten lines of Python produce a complete tokenizer. Every tokenizer — no matter how sophisticated — has these same two operations:
- **encode**: text to token IDs
- **decode**: token IDs back to text
### The Key Insight
Tokenization achieves **compression** and **semantic grouping**:
| Tokenization | Vocabulary Size | Sequence Length | Semantics |
|-------------|-----------------|-----------------|-----------|
| Character | ~100 (ASCII) | Very long | None (individual letters) |
| Word | ~1,000,000+ | Short | Strong (whole words) |
| Subword | ~30,000-100,000 | Medium | Moderate (meaningful pieces) |
## Why Characters Aren't Enough
Our character tokenizer works — but fails at scale.
### Problem 1: Long Sequences
```{python}
sample_text = "The transformer architecture revolutionized natural language processing."
char_tokens = list(sample_text)
print(f"Text length: {len(sample_text)} characters")
print(f"Token count: {len(char_tokens)} tokens")
print(f"Compression ratio: {len(sample_text) / len(char_tokens):.2f}x (no compression!)")
```
Since attention is O(n^2) in sequence length, doubling the sequence length quadruples the compute cost. Character-level tokenization produces the longest possible sequences.
### Problem 2: No Semantic Units
```{python}
# The model sees this:
word = "transformer"
char_view = list(word)
print(f"Characters: {char_view}")
print(f"Token count: {len(char_view)}")
```
The model must discover on its own that `t-r-a-n-s-f-o-r-m-e-r` forms a meaningful unit. Character tokenization provides no semantic guidance. At word-level, "transformer" occupies one token with its own learned representation.
### Problem 3: Vocabulary Explosion for Bytes
```{python}
# If we go to byte-level (handling all Unicode)
text_with_emoji = "Hello! \U0001F60A"
byte_view = text_with_emoji.encode('utf-8')
print(f"Text: {text_with_emoji}")
print(f"Bytes: {list(byte_view)}")
print(f"Byte count: {len(byte_view)} (emoji = 4 bytes!)")
```
Byte-level tokenization can represent anything, but sequences become even longer. Byte-level tokenization splits a single emoji into 4 tokens.
### The Tradeoff
This is the fundamental tradeoff in tokenization:
```
Characters: Small vocab, long sequences, no semantics
Words: Huge vocab, short sequences, good semantics, can't handle new words
Subwords: Medium vocab, medium sequences, some semantics, handles new words
```
**BPE merges frequently co-occurring character sequences into single tokens** — the sweet spot between characters and words.
## Intuition: Learning Patterns Through Merging
Think of BPE as compression that learns common patterns:
```{ojs}
//| echo: false
// Interactive BPE merging visualization for "hello"
viewof bpeMergeStep = stepControl({min: 0, max: 4, value: 0, label: "Merge Step"})
```
```{ojs}
//| echo: false
bpeMergeDiagram = {
const width = 620;
const height = 180;
// Merge states: each state is an array of tokens
const mergeStates = [
{ tokens: ['h', 'e', 'l', 'l', 'o'], label: 'Initial: Character Tokens', merge: null },
{ tokens: ['h', 'e', 'll', 'o'], label: "Merge 1: 'l' + 'l' → 'll'", merge: ['l', 'l', 'll'] },
{ tokens: ['he', 'll', 'o'], label: "Merge 2: 'h' + 'e' → 'he'", merge: ['h', 'e', 'he'] },
{ tokens: ['he', 'llo'], label: "Merge 3: 'll' + 'o' → 'llo'", merge: ['ll', 'o', 'llo'] },
{ tokens: ['hello'], label: "Merge 4: 'he' + 'llo' → 'hello'", merge: ['he', 'llo', 'hello'] }
];
const state = mergeStates[bpeMergeStep];
const tokens = state.tokens;
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`);
// Background
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', diagramTheme.bg)
.attr('rx', 8);
// Title
svg.append('text')
.attr('x', width / 2)
.attr('y', 28)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '14px')
.attr('font-weight', '600')
.text(state.label);
// Token display area
const tokenY = 90;
const tokenH = 50;
const gap = 8;
// Calculate total width needed for tokens
const tokenWidths = tokens.map(t => Math.max(50, t.length * 22 + 24));
const totalWidth = tokenWidths.reduce((a, b) => a + b, 0) + gap * (tokens.length - 1);
let startX = (width - totalWidth) / 2;
// Draw tokens
tokens.forEach((token, i) => {
const tokenW = tokenWidths[i];
const x = startX + tokenW / 2;
// Check if this token was just merged
const justMerged = state.merge && token === state.merge[2];
const g = svg.append('g')
.attr('transform', `translate(${x}, ${tokenY})`);
// Token box with animation effect for merged tokens
const rect = g.append('rect')
.attr('x', -tokenW / 2)
.attr('y', -tokenH / 2)
.attr('width', tokenW)
.attr('height', tokenH)
.attr('rx', 8)
.attr('fill', justMerged ? diagramTheme.highlight : diagramTheme.nodeFill)
.attr('stroke', justMerged ? diagramTheme.highlight : diagramTheme.nodeStroke)
.attr('stroke-width', justMerged ? 2.5 : 1.5);
if (justMerged) {
rect.attr('filter', `drop-shadow(0 0 8px ${diagramTheme.highlightGlow})`);
}
// Token text
g.append('text')
.attr('x', 0)
.attr('y', 0)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', justMerged ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr('font-size', '18px')
.style('font-family', 'var(--pg-mono)')
.attr('font-weight', '500')
.text(`'${token}'`);
startX += tokenW + gap;
});
// Show merge indicator if applicable
if (state.merge) {
svg.append('text')
.attr('x', width / 2)
.attr('y', height - 25)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.accent)
.attr('font-size', '12px')
.style('font-family', 'var(--pg-mono)')
.text(`Merged: '${state.merge[0]}' + '${state.merge[1]}' → '${state.merge[2]}'`);
} else {
svg.append('text')
.attr('x', width / 2)
.attr('y', height - 25)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '12px')
.attr('opacity', 0.7)
.text(`${tokens.length} tokens`);
}
return svg.node();
}
```
```{ojs}
//| echo: false
md`**Token count:** ${['h','e','l','l','o'].length - bpeMergeStep} → ${bpeMergeStep === 4 ? '1 token (fully merged)' : `${5 - bpeMergeStep} tokens`}`
```
For code, BPE learns patterns like:
- `def ` (function definition with space)
- `self.` (common in Python classes)
- `return ` (return statement)
- ` ` (4-space indent)
## The BPE Training Algorithm
BPE learns to tokenize through this process:
```{ojs}
//| echo: false
// Interactive BPE training algorithm visualization
viewof bpeTrainStep = stepControl({min: 0, max: 6, value: 0, label: "Training Step"})
```
```{ojs}
//| echo: false
bpeTrainingDiagram = {
const width = 700;
const height = 320;
// Training states showing the BPE algorithm on "low lower lowest"
const trainStates = [
{
phase: 'start',
tokens: ['l', 'o', 'w', ' ', 'l', 'o', 'w', 'e', 'r', ' ', 'l', 'o', 'w', 'e', 's', 't'],
pairs: [["('l','o')", 3], ["('o','w')", 3], ["('w',' ')", 2], ["('w','e')", 2]],
highlight: null,
description: 'Start with individual characters'
},
{
phase: 'count',
tokens: ['l', 'o', 'w', ' ', 'l', 'o', 'w', 'e', 'r', ' ', 'l', 'o', 'w', 'e', 's', 't'],
pairs: [["('l','o')", 3], ["('o','w')", 3], ["('w',' ')", 2], ["('w','e')", 2]],
highlight: "('l','o')",
description: "Count pairs: ('l','o') appears 3 times (most frequent)"
},
{
phase: 'merge',
tokens: ['lo', 'w', ' ', 'lo', 'w', 'e', 'r', ' ', 'lo', 'w', 'e', 's', 't'],
pairs: [["('lo','w')", 3], ["('w',' ')", 2], ["('w','e')", 2]],
highlight: 'lo',
description: "Merge ('l','o') → 'lo' everywhere"
},
{
phase: 'count',
tokens: ['lo', 'w', ' ', 'lo', 'w', 'e', 'r', ' ', 'lo', 'w', 'e', 's', 't'],
pairs: [["('lo','w')", 3], ["('w',' ')", 2], ["('w','e')", 2]],
highlight: "('lo','w')",
description: "Count pairs: ('lo','w') appears 3 times"
},
{
phase: 'merge',
tokens: ['low', ' ', 'low', 'e', 'r', ' ', 'low', 'e', 's', 't'],
pairs: [["('low',' ')", 2], ["('low','e')", 2], ["(' ','low')", 2]],
highlight: 'low',
description: "Merge ('lo','w') → 'low'"
},
{
phase: 'count',
tokens: ['low', ' ', 'low', 'e', 'r', ' ', 'low', 'e', 's', 't'],
pairs: [["('low','e')", 2], ["('low',' ')", 2]],
highlight: "('low','e')",
description: "Count pairs: ('low','e') appears 2 times"
},
{
phase: 'merge',
tokens: ['low', ' ', 'lowe', 'r', ' ', 'lowe', 's', 't'],
pairs: [["('lowe','r')", 1], ["('lowe','s')", 1]],
highlight: 'lowe',
description: "Merge ('low','e') → 'lowe' — Continue until vocab size reached"
}
];
const state = trainStates[bpeTrainStep];
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`);
// Background
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', diagramTheme.bg)
.attr('rx', 8);
// Title / Phase indicator
const phaseColors = {
'start': diagramTheme.nodeStroke,
'count': diagramTheme.accent,
'merge': diagramTheme.highlight
};
svg.append('text')
.attr('x', width / 2)
.attr('y', 28)
.attr('text-anchor', 'middle')
.attr('fill', phaseColors[state.phase])
.attr('font-size', '14px')
.attr('font-weight', '600')
.text(state.phase === 'start' ? 'BPE Training Algorithm' :
state.phase === 'count' ? 'Phase: Count Pairs' : 'Phase: Merge');
// Token display area
const tokenY = 85;
const tokenH = 36;
const gap = 3;
// Calculate token layout
const tokens = state.tokens;
const tokenWidths = tokens.map(t => t === ' ' ? 28 : Math.max(28, t.length * 14 + 16));
const totalWidth = tokenWidths.reduce((a, b) => a + b, 0) + gap * (tokens.length - 1);
const scale = totalWidth > width - 40 ? (width - 40) / totalWidth : 1;
let startX = (width - totalWidth * scale) / 2;
// Draw tokens
tokens.forEach((token, i) => {
const tokenW = tokenWidths[i] * scale;
const x = startX + tokenW / 2;
const isHighlighted = state.highlight === token;
const isSpace = token === ' ';
const g = svg.append('g')
.attr('transform', `translate(${x}, ${tokenY})`);
const rect = g.append('rect')
.attr('x', -tokenW / 2)
.attr('y', -tokenH / 2)
.attr('width', tokenW)
.attr('height', tokenH)
.attr('rx', 5)
.attr('fill', isHighlighted ? diagramTheme.highlight :
isSpace ? diagramTheme.bgSecondary : diagramTheme.nodeFill)
.attr('stroke', isHighlighted ? diagramTheme.highlight : diagramTheme.nodeStroke)
.attr('stroke-width', isHighlighted ? 2 : 1);
if (isHighlighted) {
rect.attr('filter', `drop-shadow(0 0 6px ${diagramTheme.highlightGlow})`);
}
g.append('text')
.attr('x', 0)
.attr('y', 0)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', isHighlighted ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr('font-size', `${11 * scale}px`)
.style('font-family', 'var(--pg-mono)')
.text(isSpace ? '␣' : token);
startX += tokenW + gap * scale;
});
// Pair frequencies section
const pairY = 170;
svg.append('text')
.attr('x', 20)
.attr('y', pairY)
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '12px')
.attr('font-weight', '600')
.text('Pair frequencies:');
const pairs = state.pairs;
const pairGap = 150;
pairs.forEach((pair, i) => {
const x = 20 + i * pairGap;
const isHighlightedPair = state.highlight === pair[0];
svg.append('text')
.attr('x', x)
.attr('y', pairY + 24)
.attr('fill', isHighlightedPair ? diagramTheme.highlight : diagramTheme.nodeText)
.attr('font-size', '12px')
.style('font-family', 'var(--pg-mono)')
.attr('font-weight', isHighlightedPair ? '700' : '400')
.text(`${pair[0]}: ${pair[1]}`);
});
// Description
svg.append('rect')
.attr('x', 20)
.attr('y', height - 65)
.attr('width', width - 40)
.attr('height', 45)
.attr('rx', 6)
.attr('fill', diagramTheme.bgSecondary)
.attr('stroke', diagramTheme.nodeStroke)
.attr('stroke-width', 1);
svg.append('text')
.attr('x', width / 2)
.attr('y', height - 38)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '13px')
.text(state.description);
// Token count
svg.append('text')
.attr('x', width - 20)
.attr('y', 28)
.attr('text-anchor', 'end')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '11px')
.attr('opacity', 0.7)
.text(`${tokens.length} tokens`);
return svg.node();
}
```
## The Math
BPE is simple - just counting and merging:
```python
# Count pair frequencies
pairs = count_pairs(tokens) # {'he': 50, 'el': 30, 'll': 80, ...}
# Find most frequent
best_pair = max(pairs, key=pairs.get) # ('l', 'l')
# Merge everywhere
tokens = merge(tokens, best_pair, 'll')
```
**Vocabulary size** is a hyperparameter:
- Too small: Sequences too long, less meaning per token
- Too large: Many rare tokens, harder to learn
- Typical: 8K-50K tokens for LLMs
## Encoding New Text
Once trained, encoding applies merges in the order they were learned:
```{ojs}
//| echo: false
// Interactive encoding demonstration for "lower"
viewof encodeStep = stepControl({min: 0, max: 5, value: 0, label: "Encode Step"})
```
```{ojs}
//| echo: false
encodingDiagram = {
const width = 620;
const height = 240;
// Encoding steps showing how merges are applied in order
const encodeSteps = [
{ tokens: ['l', 'o', 'w', 'e', 'r'], label: 'Split to characters', merge: null, ids: null },
{ tokens: ['lo', 'w', 'e', 'r'], label: "Apply merge 1: 'l' + 'o' → 'lo'", merge: ['l', 'o', 'lo'], ids: null },
{ tokens: ['low', 'e', 'r'], label: "Apply merge 2: 'lo' + 'w' → 'low'", merge: ['lo', 'w', 'low'], ids: null },
{ tokens: ['lowe', 'r'], label: "Apply merge 3: 'low' + 'e' → 'lowe'", merge: ['low', 'e', 'lowe'], ids: null },
{ tokens: ['lower'], label: "Apply merge 4: 'lowe' + 'r' → 'lower'", merge: ['lowe', 'r', 'lower'], ids: null },
{ tokens: ['lower'], label: "Look up token IDs", merge: null, ids: [15] }
];
const state = encodeSteps[encodeStep];
const tokens = state.tokens;
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`);
// Background
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', diagramTheme.bg)
.attr('rx', 8);
// Step indicator
svg.append('text')
.attr('x', 20)
.attr('y', 28)
.attr('fill', diagramTheme.accent)
.attr('font-size', '12px')
.attr('font-weight', '600')
.text(`Step ${encodeStep + 1}/6`);
// Title
svg.append('text')
.attr('x', width / 2)
.attr('y', 28)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '14px')
.attr('font-weight', '600')
.text(state.label);
// Arrow showing merge progression
if (encodeStep > 0 && encodeStep < 5) {
// Show the "before" state faded
const prevTokens = encodeSteps[encodeStep - 1].tokens;
const prevY = 70;
const prevGap = 6;
const prevWidths = prevTokens.map(t => Math.max(40, t.length * 16 + 20));
const prevTotal = prevWidths.reduce((a, b) => a + b, 0) + prevGap * (prevTokens.length - 1);
let prevX = (width - prevTotal) / 2;
prevTokens.forEach((token, i) => {
const w = prevWidths[i];
const x = prevX + w / 2;
const isMerging = state.merge && (token === state.merge[0] || token === state.merge[1]);
svg.append('rect')
.attr('x', x - w / 2)
.attr('y', prevY - 16)
.attr('width', w)
.attr('height', 32)
.attr('rx', 5)
.attr('fill', diagramTheme.bgSecondary)
.attr('stroke', isMerging ? diagramTheme.accent : diagramTheme.nodeStroke)
.attr('stroke-width', isMerging ? 2 : 1)
.attr('opacity', 0.6);
svg.append('text')
.attr('x', x)
.attr('y', prevY)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '13px')
.style('font-family', 'var(--pg-mono)')
.attr('opacity', 0.5)
.text(`'${token}'`);
prevX += w + prevGap;
});
// Arrow down
svg.append('path')
.attr('d', `M${width/2},${prevY + 22} L${width/2},${prevY + 45}`)
.attr('stroke', diagramTheme.accent)
.attr('stroke-width', 2)
.attr('marker-end', 'url(#encode-arrow)');
// Arrow marker
const defs = svg.append('defs');
defs.append('marker')
.attr('id', 'encode-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', diagramTheme.accent);
}
// Current tokens (main display)
const tokenY = encodeStep > 0 && encodeStep < 5 ? 150 : 110;
const tokenH = 50;
const gap = 10;
const tokenWidths = tokens.map(t => Math.max(60, t.length * 20 + 28));
const totalWidth = tokenWidths.reduce((a, b) => a + b, 0) + gap * (tokens.length - 1);
let startX = (width - totalWidth) / 2;
tokens.forEach((token, i) => {
const tokenW = tokenWidths[i];
const x = startX + tokenW / 2;
const justMerged = state.merge && token === state.merge[2];
const showId = state.ids !== null;
const g = svg.append('g')
.attr('transform', `translate(${x}, ${tokenY})`);
const rect = g.append('rect')
.attr('x', -tokenW / 2)
.attr('y', -tokenH / 2)
.attr('width', tokenW)
.attr('height', tokenH)
.attr('rx', 8)
.attr('fill', justMerged ? diagramTheme.highlight :
showId ? diagramTheme.accent : diagramTheme.nodeFill)
.attr('stroke', justMerged ? diagramTheme.highlight :
showId ? diagramTheme.accent : diagramTheme.nodeStroke)
.attr('stroke-width', justMerged || showId ? 2.5 : 1.5);
if (justMerged || showId) {
rect.attr('filter', `drop-shadow(0 0 8px ${justMerged ? diagramTheme.highlightGlow : diagramTheme.accentGlow})`);
}
// Token text
g.append('text')
.attr('x', 0)
.attr('y', showId ? -8 : 0)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', justMerged ? diagramTheme.textOnHighlight :
showId ? diagramTheme.textOnAccent : diagramTheme.nodeText)
.attr('font-size', '16px')
.style('font-family', 'var(--pg-mono)')
.attr('font-weight', '500')
.text(`'${token}'`);
// ID display
if (showId && state.ids[i] !== undefined) {
g.append('text')
.attr('x', 0)
.attr('y', 12)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', diagramTheme.textOnAccent)
.attr('font-size', '13px')
.attr('opacity', 0.9)
.text(`ID: ${state.ids[i]}`);
}
startX += tokenW + gap;
});
// Bottom info
const infoY = height - 30;
if (state.ids) {
svg.append('text')
.attr('x', width / 2)
.attr('y', infoY)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.accent)
.attr('font-size', '14px')
.attr('font-weight', '600')
.text(`Output: [${state.ids.join(', ')}]`);
} else {
svg.append('text')
.attr('x', width / 2)
.attr('y', infoY)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '12px')
.attr('opacity', 0.7)
.text(`${tokens.length} token${tokens.length > 1 ? 's' : ''}`);
}
return svg.node();
}
```
## Handling Unknown Words
BPE can handle words it has never seen:
```{ojs}
//| echo: false
// Toggle between known and unknown word handling
viewof wordType = Inputs.radio(["Known Word: 'lowest'", "Unknown Word: 'lows'"], {
value: "Known Word: 'lowest'",
label: "Word type"
})
```
```{ojs}
//| echo: false
unknownWordsDiagram = {
const width = 650;
const height = 280;
const isKnown = wordType.includes('lowest');
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`);
// Background
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', diagramTheme.bg)
.attr('rx', 8);
// Title
svg.append('text')
.attr('x', width / 2)
.attr('y', 28)
.attr('text-anchor', 'middle')
.attr('fill', isKnown ? diagramTheme.accent : diagramTheme.highlight)
.attr('font-size', '15px')
.attr('font-weight', '700')
.text(isKnown ? "Known Word: 'lowest'" : "Unknown Word: 'lows'");
if (isKnown) {
// Known word path: direct lookup
const centerY = 100;
// Input word
svg.append('text')
.attr('x', 80)
.attr('y', centerY)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '18px')
.style('font-family', 'var(--pg-mono)')
.attr('font-weight', '600')
.text("'lowest'");
// Arrow
svg.append('path')
.attr('d', `M140,${centerY} L260,${centerY}`)
.attr('stroke', diagramTheme.accent)
.attr('stroke-width', 3)
.attr('marker-end', 'url(#known-arrow)');
// Result box
const resultG = svg.append('g')
.attr('transform', `translate(350, ${centerY})`);
resultG.append('rect')
.attr('x', -70)
.attr('y', -28)
.attr('width', 140)
.attr('height', 56)
.attr('rx', 8)
.attr('fill', diagramTheme.accent)
.attr('filter', `drop-shadow(0 0 10px ${diagramTheme.accentGlow})`);
resultG.append('text')
.attr('x', 0)
.attr('y', -6)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.textOnAccent)
.attr('font-size', '16px')
.style('font-family', 'var(--pg-mono)')
.attr('font-weight', '600')
.text('[16]');
resultG.append('text')
.attr('x', 0)
.attr('y', 14)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.textOnAccent)
.attr('font-size', '11px')
.attr('opacity', 0.9)
.text('Single token');
// Efficiency note
svg.append('text')
.attr('x', width / 2)
.attr('y', centerY + 60)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '12px')
.attr('opacity', 0.8)
.text('Direct vocabulary lookup — maximum efficiency');
// Arrow marker
const defs = svg.append('defs');
defs.append('marker')
.attr('id', 'known-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 8)
.attr('markerHeight', 8)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', diagramTheme.accent);
} else {
// Unknown word path: split and apply merges
const steps = [
{ y: 70, label: "Input", tokens: ["'lows'"], note: null },
{ y: 120, label: "Split", tokens: ["'l'", "'o'", "'w'", "'s'"], note: "Character-level" },
{ y: 170, label: "Merge", tokens: ["'low'", "'s'"], note: "Apply learned merges" },
{ y: 220, label: "IDs", tokens: ["[13, 9]"], note: "Subword tokens" }
];
// Arrow marker
const defs = svg.append('defs');
defs.append('marker')
.attr('id', 'unknown-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', diagramTheme.highlight);
steps.forEach((step, i) => {
// Step label
svg.append('text')
.attr('x', 50)
.attr('y', step.y)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '11px')
.attr('font-weight', '600')
.attr('opacity', 0.7)
.text(step.label);
// Tokens
const tokenGap = 10;
const tokenWidths = step.tokens.map(t => t.startsWith('[') ? 100 : Math.max(40, t.length * 14 + 16));
const totalW = tokenWidths.reduce((a, b) => a + b, 0) + tokenGap * (step.tokens.length - 1);
let startX = 200;
step.tokens.forEach((token, j) => {
const w = tokenWidths[j];
const x = startX + w / 2;
const isResult = i === steps.length - 1;
const rect = svg.append('rect')
.attr('x', x - w / 2)
.attr('y', step.y - 16)
.attr('width', w)
.attr('height', 32)
.attr('rx', 6)
.attr('fill', isResult ? diagramTheme.highlight : diagramTheme.nodeFill)
.attr('stroke', isResult ? diagramTheme.highlight : diagramTheme.nodeStroke)
.attr('stroke-width', isResult ? 2 : 1.5);
if (isResult) {
rect.attr('filter', `drop-shadow(0 0 6px ${diagramTheme.highlightGlow})`);
}
svg.append('text')
.attr('x', x)
.attr('y', step.y)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'central')
.attr('fill', isResult ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr('font-size', '13px')
.style('font-family', 'var(--pg-mono)')
.attr('font-weight', '500')
.text(token);
startX += w + tokenGap;
});
// Note
if (step.note) {
svg.append('text')
.attr('x', 480)
.attr('y', step.y)
.attr('text-anchor', 'start')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '11px')
.attr('opacity', 0.6)
.text(step.note);
}
// Arrow to next step
if (i < steps.length - 1) {
svg.append('path')
.attr('d', `M200,${step.y + 18} L200,${steps[i+1].y - 18}`)
.attr('stroke', diagramTheme.highlight)
.attr('stroke-width', 2)
.attr('marker-end', 'url(#unknown-arrow)');
}
});
}
// Why BPE Works section
const whyY = height - 38;
const reasons = isKnown ?
["Common words → single tokens (efficient)"] :
["Rare words → split into subwords (still encodable)", "Never out of vocabulary"];
svg.append('text')
.attr('x', width / 2)
.attr('y', whyY)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '11px')
.attr('font-style', 'italic')
.attr('opacity', 0.7)
.text(reasons.join(' • '));
return svg.node();
}
```
## Byte-Level BPE: Tokenizing Any Text
Our character-level tokenizer has a hard limit: it can only encode characters it saw during training. Feed it an accented letter, an emoji, or a Chinese character it never met, and each one collapses to `<UNK>` — the information is gone, and decoding can never recover it. Real tokenizers (GPT-2, GPT-4's `tiktoken`, SentencePiece) fix this once and for all with **byte-level BPE**.
The idea is a single change of alphabet. Instead of starting from the *characters* in the training text, start from the 256 possible **UTF-8 bytes**.
### Intuition: 256 Bytes Cover Everything
Every string — English, `café`, `你好`, `🚀`, a tab, a newline — is stored as a sequence of bytes, and every byte is a number from 0 to 255. So a vocabulary that contains all 256 byte values can represent **any** text that has ever existed or ever will. A character the tokenizer has never seen is simply a byte sequence built from bytes it already knows.
```{python}
# Any character is just UTF-8 bytes
for ch in ["A", "é", "☕", "好"]:
b = ch.encode("utf-8")
print(f" {ch!r:6} → codepoint U+{ord(ch):04X} → bytes {list(b)}")
```
A one-byte ASCII letter stays one byte; `é` is two bytes; `☕` and `好` are three. The multi-byte characters fan out into several byte tokens — a little longer, but **never unknown**.
::: {.callout-note}
## Key Insight
Byte-level BPE has **no `<UNK>` token by construction.** There is no such thing as an unknown byte — all 256 are in the vocabulary from the start. This is the single reason production tokenizers are byte-level: every possible input is encodable and every round-trip is exact.
:::
### The bytes-to-unicode Trick
There is one wrinkle. BPE merges *strings*, but raw bytes include control characters — newline, tab, `NUL` — that are invisible or unsafe to handle as text. GPT-2's solution (which we reuse) is to remap all 256 bytes to 256 **distinct, printable** Unicode characters before doing any BPE. Printable bytes map to themselves; the rest are shifted into a visible region starting at U+0100. The map is a bijection, so decoding recovers the exact original bytes.
```{python}
from tokenizer import bytes_to_unicode
byte_map = bytes_to_unicode()
# Bytes that are normally invisible get a visible stand-in glyph
for b in [ord(" "), ord("\n"), ord("\t"), ord("A")]:
print(f" byte {b:3d} ({chr(b)!r:6}) → visible token {byte_map[b]!r}")
print(f"\n256 bytes → {len(set(byte_map.values()))} distinct printable glyphs")
```
### From Scratch: `ByteLevelBPETokenizer`
`tokenizer.py` implements this as `ByteLevelBPETokenizer`. It learns merges exactly like the character-level version — count adjacent pairs, merge the most frequent — but its base alphabet is the 256 byte tokens instead of the characters in the corpus. Watch it succeed on text a character-level tokenizer cannot handle:
```{python}
from tokenizer import BPETokenizer, ByteLevelBPETokenizer, SPECIAL_TOKENS
# Train BOTH on the same ASCII-only corpus (no accents, no emoji, no CJK)
corpus = "the code returns hello world def class self " * 20
char_tok = BPETokenizer(vocab_size=400, verbose=False)
char_tok.train(corpus, show_progress=False)
byte_tok = ByteLevelBPETokenizer(vocab_size=400, verbose=False)
byte_tok.train(corpus, show_progress=False)
# Now encode a string full of characters neither one saw in training
text = "café ☕ 你好 — 42"
for name, tok in [("Character-level", char_tok), ("Byte-level", byte_tok)]:
ids = tok.encode(text)
unk = sum(1 for i in ids if i == SPECIAL_TOKENS["<UNK>"])
ok = tok.decode(ids) == text
print(f"{name:16}: {len(ids):2d} tokens, {unk} <UNK>, round-trip={ok}")
```
The character-level tokenizer riddles the output with `<UNK>` and cannot reconstruct the original. The byte-level tokenizer produces **zero** `<UNK>` and a **perfect** round-trip — the whole string survives as bytes. The convenience function `demonstrate_byte_level()` prints this comparison along with the full UTF-8 breakdown.
```{python}
# The round-trip is exact, byte for byte
decoded = byte_tok.decode(byte_tok.encode(text))
print(f"Original: {text!r}")
print(f"Reconstructed: {decoded!r}")
print(f"Exact match: {decoded == text}")
```
### Text → Bytes → Tokens → IDs
Here is the full byte-level pipeline. Step through it to watch each character fan out into its UTF-8 bytes, and each byte become an in-vocabulary token — so nothing is ever unknown.
```{python}
#| echo: false
#| output: false
# Bridge a concrete example to the diagram below
from tokenizer import byte_breakdown, ByteLevelBPETokenizer
_ex_text = "café ☕"
_ex_tok = ByteLevelBPETokenizer(vocab_size=400, verbose=False)
_ex_tok.train("the code returns hello world " * 20, show_progress=False)
ojs_define(byteExBreakdown = byte_breakdown(_ex_text))
ojs_define(byteExIds = _ex_tok.encode(_ex_text))
```
```{ojs}
//| echo: false
// Step control for the byte-level pipeline
viewof byteFlowStep = stepControl({min: 0, max: 3, value: 0, label: "Pipeline Stage"})
```
```{ojs}
//| echo: false
byteFlowStages = [
{key: "chars", title: "1. Characters", note: "The raw input string, one glyph at a time"},
{key: "bytes", title: "2. UTF-8 bytes", note: "Each character expands to 1–4 bytes (0–255)"},
{key: "tokens", title: "3. Byte tokens", note: "Every byte maps to a printable in-vocab token — no <UNK>"},
{key: "ids", title: "4. Token IDs", note: "Look up each (possibly merged) token's integer ID"}
]
```
```{ojs}
//| echo: false
byteFlowDiagram = {
const width = 700;
const height = 300;
const theme = diagramTheme;
const stage = byteFlowStep;
const info = byteExBreakdown; // from Python: [{char, utf8_bytes, byte_tokens, ...}]
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%")
.attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect")
.attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
// Title + note
svg.append("text")
.attr("x", width / 2).attr("y", 30)
.attr("text-anchor", "middle")
.attr("fill", theme.highlight)
.attr("font-size", "15px").attr("font-weight", "700")
.text(byteFlowStages[stage].title);
svg.append("text")
.attr("x", width / 2).attr("y", height - 22)
.attr("text-anchor", "middle")
.attr("fill", theme.nodeText)
.attr("font-size", "12px")
.text(byteFlowStages[stage].note);
// Layout: one column per source character, columns sized by byte count
const glyphs = info.filter(d => d.char !== " "); // drop the space for clarity
const weights = glyphs.map(g => Math.max(1, g.num_bytes));
const totalW = weights.reduce((a, b) => a + b, 0);
const usable = width - 80;
const colGap = 10;
let x = 40;
const topY = 70;
const cellH = 34;
glyphs.forEach((g, gi) => {
const colW = (weights[gi] / totalW) * (usable - colGap * (glyphs.length - 1));
const cx = x + colW / 2;
// Row 1: the character (always shown)
const charActive = stage >= 0;
drawBox(svg, cx, topY, Math.min(colW, 60), cellH, g.char,
charActive ? theme.nodeFill : theme.bgSecondary,
stage === 0 ? theme.highlight : theme.nodeStroke,
theme.nodeText, "16px");
if (stage >= 1) {
// Row 2: the UTF-8 bytes for this character, laid out across the column
const nb = g.utf8_bytes.length;
const bw = (colW - (nb - 1) * 4) / nb;
g.utf8_bytes.forEach((b, bi) => {
const bx = x + bi * (bw + 4) + bw / 2;
drawBox(svg, bx, topY + 55, Math.min(bw, 46), cellH, String(b),
theme.bgSecondary,
stage === 1 ? theme.accent : theme.nodeStroke,
theme.nodeText, "12px");
// connector char -> byte
svg.append("path")
.attr("d", `M${cx},${topY + cellH / 2} L${bx},${topY + 55 - cellH / 2}`)
.attr("stroke", theme.edgeStroke).attr("stroke-width", 1).attr("opacity", 0.5);
});
}
if (stage >= 2) {
// Row 3: the printable byte-token glyph for each byte
const nb = g.byte_tokens.length;
const bw = (colW - (nb - 1) * 4) / nb;
g.byte_tokens.forEach((t, bi) => {
const bx = x + bi * (bw + 4) + bw / 2;
const active = stage === 2;
drawBox(svg, bx, topY + 110, Math.min(bw, 46), cellH, t,
active ? theme.highlight : theme.nodeFill,
active ? theme.highlight : theme.nodeStroke,
active ? theme.textOnHighlight : theme.nodeText, "13px");
});
}
x += colW + colGap;
});
if (stage >= 3) {
// Final: the integer IDs as one summary row
svg.append("text")
.attr("x", width / 2).attr("y", topY + 150)
.attr("text-anchor", "middle")
.attr("fill", theme.accent)
.attr("font-size", "14px").attr("font-weight", "600")
.text(`IDs: [${byteExIds.join(", ")}]`);
}
function drawBox(svg, cx, cy, w, h, label, fill, stroke, textFill, fontSize) {
const g = svg.append("g").attr("transform", `translate(${cx},${cy})`);
g.append("rect")
.attr("x", -w / 2).attr("y", -h / 2)
.attr("width", w).attr("height", h).attr("rx", 5)
.attr("fill", fill).attr("stroke", stroke).attr("stroke-width", 1.5);
g.append("text")
.attr("text-anchor", "middle").attr("dominant-baseline", "central")
.attr("fill", textFill).attr("font-size", fontSize)
.text(label);
return g;
}
return svg.node();
}
```
### Interactive: Byte Explorer
Type anything — accents, emoji, other scripts, code with tabs — and watch it expand into UTF-8 bytes. However exotic the input, the byte count is finite and every byte is a known token, so the "unknown bytes" count stays at **zero**.
```{ojs}
//| echo: false
viewof byteExplorerInput = Inputs.text({
label: "Enter any text",
value: "café ☕ 你好 🚀",
placeholder: "Try emoji, accents, 中文, tabs…",
width: 420
})
```
```{ojs}
//| echo: false
byteExplorerData = {
const encoder = new TextEncoder(); // UTF-8, built into the browser
const chars = Array.from(byteExplorerInput); // splits astral emoji correctly
const rows = chars.map(ch => {
const bytes = Array.from(encoder.encode(ch));
return {
char: ch,
code: ch.codePointAt(0),
bytes
};
});
const totalBytes = rows.reduce((a, r) => a + r.bytes.length, 0);
return {rows, totalChars: chars.length, totalBytes};
}
```
```{ojs}
//| echo: false
byteExplorerView = {
const theme = diagramTheme;
const {rows, totalChars, totalBytes} = byteExplorerData;
const container = html`<div style="margin: 12px 0;"></div>`;
const stats = html`<div style="font-family: var(--pg-mono); font-size: 13px; margin-bottom: 12px; color: ${theme.nodeText};">
<strong>${totalChars}</strong> character${totalChars === 1 ? "" : "s"}
→ <strong>${totalBytes}</strong> UTF-8 byte${totalBytes === 1 ? "" : "s"}
·
<span style="color: ${theme.success || theme.accent}; font-weight: 600;">0 unknown bytes</span>
<span style="opacity: 0.7;">(always — every byte is in the vocabulary)</span>
</div>`;
container.appendChild(stats);
const grid = html`<div style="display: flex; flex-wrap: wrap; gap: 8px;"></div>`;
rows.forEach(r => {
const multi = r.bytes.length > 1;
const cell = html`<div style="
border: 1px solid ${multi ? (theme.highlight) : theme.nodeStroke};
border-radius: 6px; padding: 6px 8px; text-align: center;
background: ${theme.bgSecondary}; min-width: 44px;">
<div style="font-size: 18px; color: ${theme.nodeText};">${r.char === " " ? "␣" : r.char}</div>
<div style="font-size: 10px; color: ${theme.edgeStroke}; margin: 2px 0;">U+${r.code.toString(16).toUpperCase().padStart(4, "0")}</div>
<div style="font-family: var(--pg-mono); font-size: 11px; color: ${multi ? theme.highlight : theme.accent};">
${r.bytes.join(" ")}
</div>
</div>`;
grid.appendChild(cell);
});
container.appendChild(grid);
return container;
}
```
::: {.callout-tip}
## Try This
1. **Emoji fan out**: type `🚀` — one character becomes **4** bytes (highlighted), yet it is still fully encodable and reversible.
2. **Accents cost two**: compare `e` (1 byte) with `é` (2 bytes). ASCII is cheap; everything else costs a little more length in exchange for universal coverage.
3. **Other scripts**: paste `你好` or `مرحبا`. The "unknown bytes" count never moves off zero — that's the guarantee a character-level tokenizer can't make.
4. **Whitespace is bytes too**: a tab and a newline are single bytes (9 and 10), so code indentation tokenizes cleanly.
:::
::: {.callout-warning}
## Splitting Bytes Mid-Character
Because one character can span several bytes, cutting a byte sequence in the middle of a multi-byte character leaves a partial, invalid UTF-8 fragment. `ByteLevelBPETokenizer.decode()` follows GPT-2 and decodes with `errors="replace"`, turning any such fragment into the replacement character `` rather than crashing. This is why streaming decoders buffer bytes until a full character is available before showing text to the user.
:::
## Pre-tokenization: Bounding the Merges
There is a step that runs *before* BPE ever counts a pair, and it quietly decides
the quality of every merge you learn: **pre-tokenization**. BPE only merges pairs
that sit *inside* one pre-token — it never merges across a boundary. So the rule
you use to chop text into pre-tokens is the rule that says which byte sequences
are even *allowed* to become a single token.
### Intuition: Why a Naive Split Hurts
Our byte-level tokenizer above split on the simple `(\s+|\S+)` rule — runs of
whitespace or non-whitespace. That lets ugly things merge. Consider `"don't"`: the
naive split keeps it as one chunk, so BPE can learn a token that fuses the
apostrophe into the word. Numbers are worse — `"GPT2"` as one chunk invites a
`"GPT2"` token, and `"2024"` might partly merge into a neighbouring word. And
because `"the"` and `" the"` (with a leading space) are *different* chunks that
never share structure, the model wastes vocabulary learning both.
GPT-2 fixed this with a hand-crafted **regex** that pre-splits text into
linguistically clean pieces before BPE runs. Its rules, in plain English:
- **Contractions come apart**: `"don't"` → `"don"`, `"'t"`; `"I'll"` → `"I"`, `"'ll"`.
- **Letters, digits, and punctuation never mix**: `"GPT2"` → `"GPT"`, `"2"`.
- **A leading space rides with its word**: `" the"` is one piece, so the model
learns a single " the" token and reuses it everywhere a word follows a space.
- **Whitespace runs stay intact** (so code indentation survives).
### The Pattern
Here is GPT-2's regex, written for Python's standard-library `re` (no third-party
dependency). The original uses the Unicode classes `\p{L}` (letters) and `\p{N}`
(numbers); the standard library spells a letter as `[^\W\d_]` — a word character
that is neither a digit nor an underscore — and a digit as `\d`:
```text
's|'t|'re|'ve|'m|'ll|'d| ?[^\W\d_]+| ?\d+| ?[^\s\w]+| ?_+|\s+(?!\S)|\s+
└── contractions ──┘ └letters┘ └digits┘ └symbols┘ └_┘ └─ whitespace ─┘
```
The alternatives are tried left to right, and the pattern is **total** — every
character lands in exactly one piece — so joining the pieces rebuilds the input
byte-for-byte. That totality is what preserves the byte-level round-trip
guarantee: pre-tokenization changes *which merges are possible*, never *what text
comes back*.
### From Scratch: `gpt2_pretokenize`
`tokenizer.py` compiles that pattern once and applies it with a single
`findall`. `ByteLevelBPETokenizer` now uses it by default (`pretokenizer="gpt2"`);
pass `pretokenizer="simple"` to fall back to the naive split.
```{python}
from tokenizer import gpt2_pretokenize
print(gpt2_pretokenize("Don't merge GPT2 across_words!"))
print(gpt2_pretokenize(" the café costs €5.50"))
# Totality — the pieces rejoin to the original, so the round-trip is safe:
text = "café ☕ 你好\n\t42"
assert "".join(gpt2_pretokenize(text)) == text
print("round-trip-safe:", "".join(gpt2_pretokenize(text)) == text)
```
Now watch pre-tokenization change what BPE learns. Train two byte-level
tokenizers on the same text — one with each split — and compare the merges:
```{python}
from tokenizer import ByteLevelBPETokenizer
corpus = "don't stop. I'll pay $20. don't wait. I'll go. " * 40
gpt2 = ByteLevelBPETokenizer(vocab_size=300, pretokenizer="gpt2")
simple = ByteLevelBPETokenizer(vocab_size=300, pretokenizer="simple")
gpt2.train(corpus, show_progress=False)
simple.train(corpus, show_progress=False)
print("gpt2 merges: ", gpt2.training_stats["num_merges"])
print("simple merges:", simple.training_stats["num_merges"])
# Both still round-trip perfectly — only the learned vocabulary differs:
print("gpt2 round-trip: ", gpt2.decode(gpt2.encode("don't")) == "don't")
print("simple round-trip:", simple.decode(simple.encode("don't")) == "don't")
```
### Interactive: The Pre-token Splitter
Type any text and watch GPT-2's regex carve it into pre-tokens, each coloured by
category. A leading space is drawn as `·` so you can see it ride onto the next
word. Every character belongs to exactly one chip — that is the totality that
keeps the round-trip exact.
```{ojs}
//| echo: false
viewof pretokText = Inputs.text({
label: "Text to pre-tokenize",
value: "Don't merge GPT2 across_words! I'll pay €5.50.",
submit: false,
width: 480
})
```
```{ojs}
//| echo: false
// GPT-2's pre-tokenization regex — JS supports the Unicode \p{L}/\p{N} classes
// directly with the /u flag, matching the Python `re` rendering in tokenizer.py.
pretokPattern = /'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+/gu
pretokPieces = {
const contractions = new Set(["'s", "'t", "'re", "'ve", "'m", "'ll", "'d"]);
return [...pretokText.matchAll(pretokPattern)].map(m => {
const piece = m[0];
const core = piece.startsWith(" ") ? piece.slice(1) : piece;
let category;
if (contractions.has(piece)) category = "contraction";
else if (piece.trim() === "") category = "space";
else if (/\p{N}/u.test(core[0])) category = "number";
else if (/\p{L}/u.test(core[0])) category = "word";
else category = "punct";
return {piece, category, visible: piece.replace(/ /g, "·").replace(/\n/g, "\\n").replace(/\t/g, "\\t")};
});
}
```
```{ojs}
//| echo: false
pretokSplitter = {
const theme = diagramTheme;
const colors = {
word: theme.accent,
number: theme.success,
punct: theme.error,
contraction: theme.highlight,
space: theme.edgeStroke
};
const chip = p => html`<span style="
display:inline-block; margin:3px; padding:6px 10px; border-radius:7px;
font-family:var(--pg-mono); font-size:14px; font-weight:600;
color:${theme.textOnAccent ?? '#fff'}; background:${colors[p.category]};
box-shadow:0 1px 2px rgba(0,0,0,0.18);">${p.visible || "∅"}</span>`;
const legendItem = (label, cat) => html`<span style="
display:inline-flex; align-items:center; gap:5px; margin-right:14px; font-size:12px;
color:${theme.nodeText};">
<span style="width:12px;height:12px;border-radius:3px;background:${colors[cat]};display:inline-block;"></span>
${label}</span>`;
return html`<div>
<div style="margin-bottom:10px;">${pretokPieces.map(chip)}</div>
<div style="margin-bottom:6px;">
${legendItem("word", "word")}${legendItem("number", "number")}
${legendItem("punct", "punct")}${legendItem("contraction", "contraction")}
${legendItem("space (·)", "space")}
</div>
<div style="font-size:13px; color:${theme.nodeText};">
<b>${pretokPieces.length}</b> pre-tokens — BPE may merge inside each chip, never across two.
</div>
</div>`;
}
```
::: {.callout-tip}
## Try This
1. **Break a contraction**: type `wouldn't've` — it splits into `wouldn`, `'t`,
`'ve`, three units BPE keeps separate.
2. **Numbers stay pure**: type `Route66 costs $1,024` — letters, digits, and
punctuation never share a chip, so no `Route66` or `1,024` token can form.
3. **The leading space**: type `the the the` — every word after the first is
`·the` (space + word), one reusable token, distinct from a sentence-initial
`the`.
4. **Switch to `pretokenizer="simple"`** in the code above and retrain: the merges
change, but `decode(encode(x)) == x` still holds — totality guarantees it.
:::
## Special Tokens
Before examining the code, understand **special tokens** - reserved tokens with specific meanings in the LLM pipeline:
| Token | Purpose | When Used |
|-------|---------|-----------|
| `<PAD>` (ID 0) | Padding | Batch processing requires same-length sequences. Padding fills shorter sequences. |
| `<UNK>` (ID 1) | Unknown | Characters not seen during training. Production tokenizers avoid this with byte-level BPE. |
| `<BOS>` (ID 2) | Beginning of Sequence | Signals the start of text. Helps model distinguish context boundaries. |
| `<EOS>` (ID 3) | End of Sequence | Signals text completion. Model generates this to stop. Critical for generation. |
The vocabulary reserves these tokens before training begins, ensuring consistent IDs across all tokenizers.
## Chat Templates: Encoding a Conversation
When you chat with an assistant, you think in **messages** with **roles** — a system instruction, your question, the model's reply. The model sees none of that structure. It consumes exactly one flat stream of token IDs, the same as any other text. So how does it know where your turn ends and its turn begins, or which words are the (trusted) system prompt versus the (untrusted) user input?
**The answer is a chat template**: an agreed convention for flattening a structured conversation into a single stream, using reserved **control tokens** to mark who is speaking and where each turn starts and stops. The most common one is **ChatML**, introduced with the OpenAI Chat API and adopted (with variants) by Qwen, Yi, and others. It wraps every turn in two control tokens — `<|im_start|>` and `<|im_end|>` — with the role name on the first line:
```text
<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
What is 2+2?<|im_end|>
<|im_start|>assistant
4<|im_end|>
```
Those `<|im_start|>` / `<|im_end|>` markers are **not** typed characters — they are single, atomic tokens, exactly like `<BOS>`/`<EOS>`. The model learns during training that "text after `<|im_start|>user` is a user turn" and "I should generate until `<|im_end|>`." Roles are just tokens; the structure is entirely a tokenization convention.
::: {.callout-note}
## Key Insight
A chat model has no built-in concept of "messages" or "roles." A chat template is pure tokenization: it serializes a list of `{role, content}` turns into one flat ID stream, marking the boundaries with reserved control tokens the model was trained to recognize. Change the template and the model gets confused — the format is part of the contract.
:::
**From scratch.** `tokenizer.py` builds this directly. `render_chatml` flattens the messages; `encode_chat` keeps each control token atomic and runs the rest through BPE; `chatml_segments` returns the typed spans the visualizer below draws.
```{python}
from tokenizer import render_chatml, encode_chat, chatml_segments, CHAT_TOKENS, BPETokenizer
conversation = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is 2+2?"},
]
# 1. Flatten the structured conversation into one string
templated = render_chatml(conversation, add_generation_prompt=True)
print("Rendered ChatML (add_generation_prompt=True):\n")
print(templated)
print("Control tokens:", CHAT_TOKENS)
```
The trailing `<|im_start|>assistant\n` is the **generation prompt** — a dangling, *open* turn with no content and no end marker. That is what turns a transcript into a *prompt*: it primes the model to speak next, in the assistant role. Ask for a completion without it and the model has no signal that it is its turn.
```{python}
# 2. Encode to a flat ID stream — control tokens stay atomic (one ID each)
tok = BPETokenizer(vocab_size=300)
tok.train("you are a helpful assistant what is 2 + 2 system user", show_progress=False)
ids = encode_chat(tok, conversation, add_generation_prompt=True)
print(f"Flat token IDs ({len(ids)} tokens):")
print(ids)
# Added special tokens extend the vocab: they get fresh IDs at the end
from tokenizer import chat_token_ids
print("\nAppended control-token IDs:", chat_token_ids(tok))
print(f"(regular tokens occupy 0..{len(tok.vocab)-1}; the two control tokens sit just past them)")
```
Each `<|im_start|>` / `<|im_end|>` is a **single** ID in that stream — never a run of `<`, `|`, `i`, `m`… characters. A real tokenizer protects its special tokens from BPE exactly this way (our `split_on_special` does the same), and appending them to a pretrained vocabulary gives them fresh IDs at the end so they can never collide with a learned token.
### Interactive: Chat-Template Builder
Pick a conversation and toggle the generation prompt. Watch the structured turns flatten into one stream, with the control tokens and role labels highlighted — this is precisely the byte sequence the model reads. (The builder below mirrors `chatml_segments` from `tokenizer.py`, whose spans you saw printed above.)
```{ojs}
//| echo: false
viewof chatPreset = Inputs.select(
new Map([
["Q&A (system + user + reply)", "qa"],
["Just a user question", "user"],
["Multi-turn conversation", "multi"]
]),
{value: "qa", label: "Conversation"}
)
```
```{ojs}
//| echo: false
viewof chatGenPrompt = Inputs.toggle({label: "Add generation prompt", value: true})
```
```{ojs}
//| echo: false
// Mirror tokenizer.py's chatml_segments in JS so the builder is live.
chatSegments = {
const presets = {
qa: [
{role: "system", content: "You are a helpful assistant."},
{role: "user", content: "What is 2+2?"}
],
user: [
{role: "user", content: "Write a haiku about tokens."}
],
multi: [
{role: "system", content: "You are a terse assistant."},
{role: "user", content: "Capital of France?"},
{role: "assistant", content: "Paris."},
{role: "user", content: "And of Japan?"}
]
};
const msgs = presets[chatPreset];
const segs = [];
const emit = (role, content) => {
segs.push({kind: "control", text: "<|im_start|>", role});
segs.push({kind: "role", text: role + "\n", role});
if (content !== null) {
segs.push({kind: "content", text: content, role});
segs.push({kind: "control", text: "<|im_end|>", role});
segs.push({kind: "content", text: "\n", role});
}
};
for (const m of msgs) emit(m.role, m.content);
if (chatGenPrompt) emit("assistant", null);
return segs;
}
```
```{ojs}
//| echo: false
chatTemplateView = {
const theme = diagramTheme;
const roleColor = {
system: theme.accent,
user: theme.highlight,
assistant: theme.success || "#22c55e"
};
const container = html`<div style="
font-family: var(--pg-mono);
background: ${theme.bg}; border-radius: 12px; padding: 18px 20px;
line-height: 1.9; font-size: 14px; white-space: pre-wrap; word-break: break-word;
border: 1px solid ${theme.edgeStroke};"></div>`;
for (const seg of chatSegments) {
const span = document.createElement("span");
const c = roleColor[seg.role] || theme.nodeText;
if (seg.kind === "control") {
span.textContent = seg.text;
span.style.cssText = `background:${c}; color:${theme.bg}; padding:1px 6px; border-radius:5px; font-weight:700;`;
} else if (seg.kind === "role") {
span.textContent = seg.text;
span.style.cssText = `color:${c}; font-weight:700;`;
} else {
span.textContent = seg.text;
span.style.cssText = `color:${theme.nodeText};`;
}
container.appendChild(span);
}
// Legend
const legend = html`<div style="display:flex; gap:16px; margin-top:14px; font-size:12px; font-family:var(--pg-mono); flex-wrap:wrap;"></div>`;
for (const [role, color] of Object.entries(roleColor)) {
const item = html`<span style="display:inline-flex; align-items:center; gap:6px; color:${theme.nodeText};">
<span style="width:12px;height:12px;border-radius:3px;background:${color};display:inline-block;"></span>${role}</span>`;
legend.appendChild(item);
}
const controlCount = chatSegments.filter(s => s.kind === "control").length;
const note = html`<div style="margin-top:10px; font-size:12px; color:${theme.nodeText}; opacity:0.8; font-family:var(--pg-mono);">
${controlCount} atomic control tokens · ${chatGenPrompt ? "open assistant turn ready for generation" : "closed transcript"}</div>`;
return html`<div>${container}${legend}${note}</div>`;
}
```
::: {.callout-tip}
## Try This
1. **Toggle the generation prompt off.** The trailing `<|im_start|>assistant` disappears — you now have a *transcript*, not a *prompt*. Toggle it back on to prime the model to reply.
2. **Switch to the multi-turn conversation.** Notice the assistant's earlier reply is wrapped and closed just like any other turn — the model's own past outputs are fed back to it inside the same template.
3. **Count the control tokens.** Two per closed turn (`<|im_start|>` … `<|im_end|>`) plus one for the open generation turn. Every one is a single ID, not the characters you see.
:::
::: {.callout-warning}
## The template is part of the contract
A model is trained with **one specific** chat template. Feed it a conversation formatted with a different one — wrong control tokens, a missing `<|im_end|>`, the role label in the wrong place — and quality collapses, because the boundaries it learned to rely on are no longer where it expects. When you use a pretrained chat model, always apply *its* template, not a generic one.
:::
## Code Walkthrough
Explore tokenization interactively:
```{python}
# Import our BPE tokenizer
from tokenizer import BPETokenizer, SPECIAL_TOKENS
print("Special tokens:", SPECIAL_TOKENS)
print("\nThese tokens are reserved at IDs 0-3 before training begins.")
```
### Training a BPE Tokenizer
The `BPETokenizer` class has key parameters:
- `vocab_size`: Target vocabulary size (including special tokens)
- `min_frequency`: Minimum times a pair must appear to be merged (default: 2). This prevents rare pairs from being merged — if a pair only appears once, it's likely noise rather than a useful pattern. Higher values create more conservative, generalizable vocabularies.
- `verbose`: Print detailed training progress
```{python}
# Simple text to train on
simple_text = "ab cd ab cd ab cd ab cd " * 20
# Create and train tokenizer
# vocab_size includes the 4 special tokens, so effective learned tokens = vocab_size - 4
tokenizer = BPETokenizer(vocab_size=30, verbose=False)
stats = tokenizer.train(simple_text, show_progress=True)
print(f"\nVocab size: {stats['vocab_size']}")
print(f"Merges learned: {stats['num_merges']}")
print(f"Special tokens: {stats['num_special_tokens']}")
```
```{python}
# See what patterns were learned
print("Learned merges:")
for i, ((a, b), merged) in enumerate(list(tokenizer.merges.items())[:10]):
print(f" {i+1}. {repr(a)} + {repr(b)} -> {repr(merged)}")
```
### Encoding and Decoding
Encoding applies merges in their learned order. This is crucial - the merge order determines how text is split.
```{python}
# Encode some text
test_text = "ab cd"
ids = tokenizer.encode(test_text)
tokens = [tokenizer.id_to_token(i) for i in ids]
print(f"Text: '{test_text}'")
print(f"Token IDs: {ids}")
print(f"Tokens: {tokens}")
# Decode back
decoded = tokenizer.decode(ids)
print(f"Decoded: '{decoded}'")
print(f"Round-trip successful: {test_text == decoded}")
```
```{python}
# With special tokens (used during actual LLM training/inference)
ids_with_special = tokenizer.encode(test_text, add_special_tokens=True)
print(f"\nWith special tokens: {ids_with_special}")
print(f"Tokens: {[tokenizer.id_to_token(i) for i in ids_with_special]}")
# Decoding skips special tokens by default
decoded = tokenizer.decode(ids_with_special, skip_special_tokens=True)
print(f"Decoded (skip special): '{decoded}'")
```
### Training on Python Code
```{python}
python_code = '''
def fibonacci(n):
"""Calculate the nth Fibonacci number."""
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
def factorial(n):
"""Calculate n factorial."""
if n <= 1:
return 1
return n * factorial(n - 1)
class Calculator:
def __init__(self):
self.result = 0
def add(self, x):
self.result += x
return self
def subtract(self, x):
self.result -= x
return self
# Main execution
if __name__ == "__main__":
print(fibonacci(10))
print(factorial(5))
'''
print(f"Training on {len(python_code)} characters of Python code")
```
```{python}
# Train tokenizer on code
code_tokenizer = BPETokenizer(vocab_size=200, verbose=False)
stats = code_tokenizer.train(python_code * 3, show_progress=True)
print(f"\nFinal vocab size: {stats['vocab_size']}")
print(f"Merges learned: {stats['num_merges']}")
```
```{python}
# Look at what code patterns were learned
print("Interesting tokens learned (longest first):")
print("=" * 40)
interesting_patterns = []
for token, id in code_tokenizer.vocab.items():
if len(token) >= 2 and not token.startswith('<'):
interesting_patterns.append((token, id))
# Sort by length (longer = more merged)
interesting_patterns.sort(key=lambda x: len(x[0]), reverse=True)
for token, id in interesting_patterns[:15]:
print(f" {id:3d}: {repr(token)}")
```
### Visualizing Tokenization
```{python}
def visualize_tokens(tokenizer, text):
"""Show how text is split into tokens with colors."""
ids = tokenizer.encode(text)
tokens = [tokenizer.id_to_token(i) for i in ids]
print(f"Original: {repr(text)}")
print(f"Tokens ({len(tokens)}): {tokens}")
print(f"IDs: {ids}")
print(f"Compression: {len(text)/len(ids):.2f} chars/token")
print()
# Try different code patterns
patterns = [
"def fibonacci(n):",
"self.result = 0",
"return self",
" for i in range(10):",
]
for pattern in patterns:
visualize_tokens(code_tokenizer, pattern)
```
### Vocabulary Size Tradeoffs
Vocabulary size is one of the most important hyperparameters in tokenization:
**Larger vocabulary:**
- (+) Shorter sequences = faster training, more context in fixed window
- (+) Common words as single tokens = better semantic units
- (-) Larger embedding table = more parameters, more memory
- (-) Rare tokens get few training examples = poor representations
**Smaller vocabulary:**
- (+) Smaller model, faster embedding lookups
- (+) Every token well-trained on many examples
- (-) Longer sequences = slower training, less context
- (-) Words split into less meaningful pieces
```{python}
test_text = "def calculate_fibonacci(number):\n return fibonacci(number)"
vocab_sizes = [50, 100, 200, 500]
print(f"Text: {repr(test_text)}")
print(f"Text length: {len(test_text)} characters")
print()
for vocab_size in vocab_sizes:
tok = BPETokenizer(vocab_size=vocab_size, verbose=False)
tok.train(python_code * 5, show_progress=False)
ids = tok.encode(test_text)
tokens = [tok.id_to_token(i) for i in ids]
print(f"Vocab size {vocab_size}:")
print(f" Tokens: {len(ids)}")
print(f" Ratio: {len(test_text)/len(ids):.1f} chars/token")
print(f" Sample: {[tok.id_to_token(i) for i in ids[:5]]}...")
print()
```
**Real-world vocabulary sizes:**
- GPT-2: 50,257 tokens
- GPT-4: ~100,000 tokens
- Llama 2: 32,000 tokens
- Claude: ~100,000 tokens
### Saving and Loading
```{python}
import tempfile
import os
# Save tokenizer
save_path = tempfile.mktemp(suffix='.json')
code_tokenizer.save(save_path)
# Load it back
loaded = BPETokenizer.load(save_path)
# Verify it works the same
test = "def test():"
original_ids = code_tokenizer.encode(test)
loaded_ids = loaded.encode(test)
print(f"\nOriginal encoding: {original_ids}")
print(f"Loaded encoding: {loaded_ids}")
print(f"Match: {original_ids == loaded_ids}")
# Cleanup
os.unlink(save_path)
```
## Interactive Exploration
Watch BPE tokenization step by step. Type text and see how it gets broken into tokens through iterative pair merging.
::: {.callout-warning}
## Demo Uses Pre-defined Merge Rules
This interactive demo uses a **simplified, pre-defined set of common English merge rules** — not dynamically computed merges. A real tokenizer learns merges from a training corpus, but the mechanism shown here is identical. The Python implementation above (`BPETokenizer`) demonstrates actual BPE training.
:::
```{ojs}
//| echo: false
// Pre-trained BPE merges (curated for demo purposes)
// These are ordered by frequency - common pairs first
bpeMerges = [
// Common letter pairs
["t", "h", "th"],
["h", "e", "he"],
["i", "n", "in"],
["e", "r", "er"],
["a", "n", "an"],
["r", "e", "re"],
["o", "n", "on"],
["e", "s", "es"],
["o", "r", "or"],
["t", "i", "ti"],
["e", "n", "en"],
["a", "t", "at"],
["e", "d", "ed"],
["o", "u", "ou"],
["i", "s", "is"],
["i", "t", "it"],
["a", "l", "al"],
["a", "r", "ar"],
["s", "t", "st"],
["l", "l", "ll"],
["l", "e", "le"],
["n", "d", "nd"],
// Common trigrams
["th", "e", "the"],
["in", "g", "ing"],
["an", "d", "and"],
["ti", "on", "tion"],
["er", "s", "ers"],
["he", "r", "her"],
["ll", "o", "llo"],
["he", "ll", "hell"],
["hell", "o", "hello"],
["w", "or", "wor"],
["wor", "l", "worl"],
["worl", "d", "world"]
]
// Apply a single merge to token list
function applyMerge(tokens, left, right, merged) {
const result = [];
let i = 0;
while (i < tokens.length) {
if (i < tokens.length - 1 && tokens[i] === left && tokens[i + 1] === right) {
result.push(merged);
i += 2;
} else {
result.push(tokens[i]);
i += 1;
}
}
return result;
}
// Apply merges up to a certain step
function tokenizeWithSteps(text, maxStep) {
// Start with character-level tokens (preserve spaces)
let tokens = text.split('');
const steps = [{ tokens: [...tokens], mergeApplied: null }];
for (let i = 0; i < Math.min(maxStep, bpeMerges.length); i++) {
const [left, right, merged] = bpeMerges[i];
const newTokens = applyMerge(tokens, left, right, merged);
// Only record step if something changed
if (newTokens.length !== tokens.length) {
tokens = newTokens;
steps.push({
tokens: [...tokens],
mergeApplied: `"${left}" + "${right}" → "${merged}"`
});
}
}
return { finalTokens: tokens, steps };
}
// Fully tokenize (all merges)
function tokenize(text) {
let tokens = text.split('');
for (const [left, right, merged] of bpeMerges) {
tokens = applyMerge(tokens, left, right, merged);
}
return tokens;
}
```
```{ojs}
//| echo: false
// Widget theme - uses diagramTheme from _diagram-lib.qmd which already handles dark mode
theme = {
const t = diagramTheme;
return {
textPrimary: t.nodeText,
textMuted: t.edgeStroke,
// Was an ad-hoc flat hex (#e5e7eb, Tailwind gray-200 — off the diagram's
// warm-stone palette) in light mode only, paired with an unrelated
// translucent tint in dark. Both branches now derive from the diagram's
// own tokenized fill-alt (--diagram-hover-fill via diagramTheme.nodeFillHover)
// so "space" highlights read as the same material in both themes.
spaceBg: t.nodeFillHover,
spaceBorder: t.edgeStroke,
tokenBorder: 50,
tokenLightness: t.isDark ? 25 : 85,
historyBg: t.bgSecondary,
stepBg: t.bg === 'transparent' ? t.bgSecondary : t.bg,
stepBorderInitial: t.edgeStroke,
stepBorderMerge: t.accent,
stepTextMuted: t.edgeStroke,
tokenStepBg: t.isDark ? 'rgba(56, 189, 248, 0.15)' : 'rgba(14, 165, 233, 0.15)',
spaceStepBg: t.nodeFillHover,
isDark: t.isDark
};
}
```
```{ojs}
//| echo: false
viewof inputText = Inputs.text({
label: "Enter text",
value: "hello world",
placeholder: "Type something...",
width: 400
})
viewof showSteps = Inputs.toggle({
label: "Show step-by-step",
value: true
})
viewof maxMergeStep = Inputs.range([0, bpeMerges.length], {
value: bpeMerges.length,
step: 1,
label: "Merge steps to apply",
disabled: !showSteps
})
```
```{ojs}
//| echo: false
// Tokenization results
result = tokenizeWithSteps(inputText.toLowerCase(), showSteps ? maxMergeStep : bpeMerges.length)
finalTokens = result.finalTokens
tokenizationSteps = result.steps
// Stats
charCount = inputText.length
tokenCount = finalTokens.length
compressionRatio = charCount > 0 ? (charCount / tokenCount).toFixed(2) : 0
```
```{ojs}
//| echo: false
// Token visualization as colored boxes
tokenVisualization = html`
<div style="margin: 20px 0; color: ${theme.textPrimary};">
<strong>Tokens (${tokenCount}):</strong>
<div style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px;">
${finalTokens.map((token, i) => {
// Color based on token length (longer = more merged)
const hue = Math.min(token.length * 30, 200);
const color = `hsl(${hue}, 70%, ${theme.tokenLightness}%)`;
const isSpace = token === ' ';
return html`<span style="
background: ${isSpace ? theme.spaceBg : color};
padding: 4px 8px;
border-radius: 4px;
font-family: var(--pg-mono);
font-size: 14px;
color: ${theme.textPrimary};
border: 1px solid ${isSpace ? theme.spaceBorder : `hsl(${hue}, ${theme.tokenBorder}%, ${theme.isDark ? 50 : 60}%)`};
">${isSpace ? '␣' : token}</span>`;
})}
</div>
</div>
`
```
```{ojs}
//| echo: false
md`**Stats:** ${charCount} characters → ${tokenCount} tokens | **Compression:** ${compressionRatio} chars/token`
```
```{ojs}
//| echo: false
// Step-by-step view (when enabled)
mergeHistory = showSteps && maxMergeStep > 0 ? html`
<div style="margin-top: 20px; padding: 15px; background: ${theme.historyBg}; border-radius: 8px; color: ${theme.textPrimary};">
<strong>Merge History:</strong>
<div style="font-family: var(--pg-mono); font-size: 13px; margin-top: 10px;">
${tokenizationSteps.map((step, i) => html`
<div style="margin: 8px 0; padding: 8px; background: ${theme.stepBg}; border-radius: 4px; border-left: 3px solid ${i === 0 ? theme.stepBorderInitial : theme.stepBorderMerge};">
<div style="color: ${theme.textMuted}; font-size: 11px; margin-bottom: 4px;">
${i === 0 ? 'Initial (characters)' : `Step ${i}: ${step.mergeApplied}`}
</div>
<div style="display: flex; flex-wrap: wrap; gap: 2px;">
${step.tokens.map(t => html`<span style="background: ${t === ' ' ? theme.spaceStepBg : theme.tokenStepBg}; padding: 2px 6px; border-radius: 3px; color: ${theme.textPrimary};">${t === ' ' ? '␣' : t}</span>`)}
</div>
<div style="color: ${theme.stepTextMuted}; font-size: 11px; margin-top: 4px;">${step.tokens.length} tokens</div>
</div>
`)}
</div>
</div>
` : html``
```
::: {.callout-tip}
## Try This
1. **Common words merge well**: Type "the" or "and" - they become single tokens quickly due to high-frequency merges.
2. **Step through merges**: Enable "Show step-by-step" and slide the merge steps from 0 to max. Watch how character pairs combine into larger tokens.
3. **Rare words stay split**: Type "xyz" or uncommon words - they remain as characters because those patterns weren't in the training data.
4. **Compression varies**: Compare "the the the" (high compression) vs "qxz qxz qxz" (low compression). Common patterns compress better.
5. **Spaces are preserved**: Notice that spaces remain as separate tokens (shown as ␣). This is typical BPE behavior.
:::
## Exercises
### Exercise 1: Compression Efficiency
BPE achieves better compression on repetitive text. This matters because better compression = shorter sequences = more context in the model's window.
```{python}
# Train on repetitive vs varied text and compare compression
texts = {
"repetitive": "the the the " * 100,
"varied": " ".join([f"word{i}" for i in range(100)]),
"code": python_code,
}
print("Compression comparison:")
print("=" * 40)
for name, text in texts.items():
tok = BPETokenizer(vocab_size=200, verbose=False)
tok.train(text, show_progress=False)
ids = tok.encode(text)
ratio = len(text) / len(ids)
print(f"{name:12s}: {ratio:.2f} chars/token")
print("\nNote: Repetitive text compresses best because BPE learns")
print("common patterns. Code has structure but more variety.")
```
### Exercise 2: Analyze the First Merges
The first merges reveal the most frequent patterns in your data. For English text, you'll often see common letter pairs like 'th', 'he', 'in'.
```{python}
# What patterns are learned first?
sample_text = "hello world hello world hello world " * 10
tok = BPETokenizer(vocab_size=50, verbose=False)
tok.train(sample_text, show_progress=False)
print("First 10 merges (most frequent patterns):")
for i, ((a, b), merged) in enumerate(list(tok.merges.items())[:10]):
print(f" {i+1}. '{a}' + '{b}' = '{merged}'")
print("\nNotice: Common substrings merge first, eventually")
print("forming complete words like 'hello' and 'world'.")
```
### Exercise 3: Observe Unknown Character Behavior
Our simple tokenizer can only encode characters it saw during training. Characters not in the vocabulary become `<UNK>` tokens. This exercise demonstrates the problem — and why production tokenizers use byte-level BPE to solve it.
```{python}
# What happens with characters not in training?
tokenizer = BPETokenizer(vocab_size=50, verbose=False)
tokenizer.train("hello world", show_progress=False)
# Try encoding text with emoji
test = "hello world" # Safe text
try:
ids = tokenizer.encode(test)
print(f"'{test}' -> {ids}")
print(f"Decoded: '{tokenizer.decode(ids)}'")
except Exception as e:
print(f"Error: {e}")
# Now try with a character not in training
test2 = "hello 123"
ids = tokenizer.encode(test2)
tokens = [tokenizer.id_to_token(i) for i in ids]
print(f"\n'{test2}' -> {ids}")
print(f"Tokens: {tokens}")
print("\nNotice: '1', '2', '3' become <UNK> (ID 1) because they")
print("weren't in the training data.")
print("\nThe byte-level tokenizer we built above has no such failure mode.")
```
```{python}
# The same unseen text, encoded with the byte-level tokenizer we built
from tokenizer import ByteLevelBPETokenizer
byte_tokenizer = ByteLevelBPETokenizer(vocab_size=400, verbose=False)
byte_tokenizer.train("hello world", show_progress=False)
for probe in ["hello 123", "hello ☕", "你好"]:
ids = byte_tokenizer.encode(probe)
unk = sum(1 for i in ids if i == SPECIAL_TOKENS["<UNK>"])
print(f" {probe!r:12} → {len(ids)} tokens, {unk} <UNK>, "
f"round-trip={byte_tokenizer.decode(ids) == probe}")
print("\nOperating on UTF-8 bytes (0-255) means any input is encodable —")
print("this is exactly how tiktoken and SentencePiece avoid <UNK>.")
```
### Exercise 4: Whitespace Handling
Whitespace is tricky in tokenization. Our tokenizer preserves it, but notice how spaces can be part of tokens.
```{python}
# Whitespace is significant in tokenization
code_tok = BPETokenizer(vocab_size=100, verbose=False)
code_tok.train("def foo():\n return 1\ndef bar():\n return 2", show_progress=False)
# See how indentation is tokenized
samples = [
"def foo():",
" return", # 4 spaces
" x", # 8 spaces
]
for sample in samples:
ids = code_tok.encode(sample)
tokens = [code_tok.id_to_token(i) for i in ids]
print(f"{repr(sample):20s} -> {tokens}")
print("\nIn production tokenizers, leading spaces often attach to")
print("the following word: ' hello' is one token, not ' ' + 'hello'")
```
## Tokenization in the LLM Pipeline
Tokenization occupies the first stage of the LLM pipeline:
```{ojs}
//| echo: false
// Interactive step-through of tokenization in LLM pipeline
viewof pipelineStep = stepControl({min: 0, max: 5, value: 0, label: "Pipeline Step"})
```
```{ojs}
//| echo: false
llmPipelineDiagram = {
const width = 720;
const height = 200;
// Pipeline stages
const stages = [
{ id: 'input', x: 60, label: 'Raw Text', sublabel: "'def hello():'", group: 'Input' },
{ id: 'split', x: 180, label: 'Split', sublabel: "['def',' ','hello',...]", group: 'Tokenization' },
{ id: 'lookup', x: 300, label: 'Look up IDs', sublabel: '[42, 5, 128, ...]', group: 'Tokenization' },
{ id: 'embed', x: 430, label: 'Embeddings', sublabel: 'Module 04', group: 'Model' },
{ id: 'transform', x: 550, label: 'Transformer', sublabel: 'Module 06', group: 'Model' },
{ id: 'decode', x: 670, label: 'Decode', sublabel: 'Back to text', group: 'Output' }
];
// Stage descriptions
const descriptions = [
"Start: Raw source code text as input",
"Tokenization: Split text into subword tokens using BPE",
"Tokenization: Convert tokens to integer IDs via vocabulary lookup",
"Model: Map token IDs to dense vector embeddings",
"Model: Process embeddings through transformer layers",
"Output: Decode predicted token IDs back to readable text"
];
const svg = d3.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`);
// Background
svg.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', diagramTheme.bg)
.attr('rx', 8);
// Group backgrounds
const groups = [
{ name: 'Input', x1: 20, x2: 120, color: diagramTheme.nodeStroke },
{ name: 'Tokenization', x1: 130, x2: 360, color: diagramTheme.highlight },
{ name: 'Model', x1: 370, x2: 610, color: diagramTheme.accent },
{ name: 'Output', x1: 620, x2: 710, color: diagramTheme.nodeStroke }
];
groups.forEach(group => {
const isActive = stages.filter(s => s.group === group.name)
.some((s, i) => stages.indexOf(s) === pipelineStep);
svg.append('rect')
.attr('x', group.x1)
.attr('y', 25)
.attr('width', group.x2 - group.x1)
.attr('height', 95)
.attr('rx', 6)
.attr('fill', 'transparent')
.attr('stroke', isActive ? group.color : diagramTheme.nodeStroke)
.attr('stroke-width', isActive ? 2 : 1)
.attr('stroke-dasharray', isActive ? 'none' : '4,2')
.attr('opacity', isActive ? 1 : 0.4);
svg.append('text')
.attr('x', (group.x1 + group.x2) / 2)
.attr('y', 40)
.attr('text-anchor', 'middle')
.attr('fill', isActive ? group.color : diagramTheme.nodeText)
.attr('font-size', '10px')
.attr('font-weight', isActive ? '600' : '400')
.attr('opacity', isActive ? 1 : 0.5)
.text(group.name);
});
// Arrow marker
const defs = svg.append('defs');
defs.append('marker')
.attr('id', 'pipeline-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 5)
.attr('markerHeight', 5)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', diagramTheme.edgeStroke);
defs.append('marker')
.attr('id', 'pipeline-arrow-active')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 8)
.attr('refY', 0)
.attr('markerWidth', 5)
.attr('markerHeight', 5)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', diagramTheme.highlight);
// Draw nodes
const nodeY = 85;
const nodeW = 90;
const nodeH = 48;
stages.forEach((stage, i) => {
const isActive = i === pipelineStep;
const isPast = i < pipelineStep;
const g = svg.append('g')
.attr('transform', `translate(${stage.x}, ${nodeY})`);
const rect = g.append('rect')
.attr('x', -nodeW / 2)
.attr('y', -nodeH / 2)
.attr('width', nodeW)
.attr('height', nodeH)
.attr('rx', 6)
.attr('fill', isActive ? diagramTheme.highlight :
isPast ? diagramTheme.bgSecondary : diagramTheme.nodeFill)
.attr('stroke', isActive ? diagramTheme.highlight :
isPast ? diagramTheme.accent : diagramTheme.nodeStroke)
.attr('stroke-width', isActive ? 2.5 : 1.5);
if (isActive) {
rect.attr('filter', `drop-shadow(0 0 10px ${diagramTheme.highlightGlow})`);
}
// Main label
g.append('text')
.attr('x', 0)
.attr('y', -6)
.attr('text-anchor', 'middle')
.attr('fill', isActive ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr('font-size', '11px')
.attr('font-weight', '600')
.text(stage.label);
// Sublabel
g.append('text')
.attr('x', 0)
.attr('y', 10)
.attr('text-anchor', 'middle')
.attr('fill', isActive ? diagramTheme.textOnHighlight : diagramTheme.nodeText)
.attr('font-size', '9px')
.style('font-family', 'var(--pg-mono)')
.attr('opacity', isActive ? 0.9 : 0.6)
.text(stage.sublabel);
// Draw arrow to next stage
if (i < stages.length - 1) {
const nextStage = stages[i + 1];
const arrowActive = i === pipelineStep - 1;
svg.append('path')
.attr('d', `M${stage.x + nodeW/2 + 5},${nodeY} L${nextStage.x - nodeW/2 - 10},${nodeY}`)
.attr('stroke', arrowActive ? diagramTheme.highlight : diagramTheme.edgeStroke)
.attr('stroke-width', arrowActive ? 2 : 1.5)
.attr('marker-end', `url(#${arrowActive ? 'pipeline-arrow-active' : 'pipeline-arrow'})`);
}
});
// Description
svg.append('rect')
.attr('x', 20)
.attr('y', height - 50)
.attr('width', width - 40)
.attr('height', 35)
.attr('rx', 6)
.attr('fill', diagramTheme.bgSecondary);
svg.append('text')
.attr('x', width / 2)
.attr('y', height - 28)
.attr('text-anchor', 'middle')
.attr('fill', diagramTheme.nodeText)
.attr('font-size', '12px')
.text(descriptions[pipelineStep]);
return svg.node();
}
```
## Beyond Tokens: Byte-Latent Patching
Everything so far accepts one premise: **learn a fixed vocabulary of subwords,
then look every piece of text up in it.** BPE is a brilliant way to build that
vocabulary — but the vocabulary itself is a frozen table, chosen by *compression*
frequency, not by how *hard to predict* each piece of text actually is. The
frontier is now questioning that premise directly.
The **Byte Latent Transformer** (BLT, Meta 2024) throws the tokenizer away. It
runs straight on raw UTF-8 **bytes** — no vocabulary, no `<UNK>`, no merge rules —
and groups those bytes into **patches** whose boundaries it chooses *dynamically*,
per input, by how surprising each next byte is. A patch is BLT's unit of compute,
the way a token is BPE's.
### Intuition: spend compute where the bytes are surprising
Read the bytes of `predicts` one at a time. After `predic`, the next byte is
almost certainly `t` — you barely need a model to guess it. But the very first
byte of a *new word* could be almost anything. Predictable stretches carry little
information; the surprising bytes are where the real decisions happen.
BLT measures that surprise with a small, **separate** byte-level language model and
uses it as a ruler: wherever the next-byte surprise **spikes**, start a new patch;
wherever bytes are predictable, let them ride together in one big patch. The
result is that the expensive model runs **once per patch** — so compute flows to
the hard-to-predict regions and skims over the easy ones. A fixed tokenizer can't
do this: its boundaries are baked in before it ever sees your sentence.
::: {.callout-note}
## Key Insight
A BPE token is a *static* unit chosen once, at training time, by frequency. A BLT
patch is a *dynamic* unit chosen at inference time, per sequence, by predictive
difficulty. Same goal — cut the byte stream into chunks the big model processes —
but the cut is made by **entropy**, not by a lookup table.
:::
### The Math: next-byte entropy
The "surprise" of the byte at position $t$ is the **Shannon entropy** of the
model's prediction for it, over all 256 possible byte values:
$$
H(x_t) = -\sum_{v=0}^{255} p_e(x_t = v \mid x_{<t}) \, \log_2 p_e(x_t = v \mid x_{<t})
$$
where $p_e$ is the small entropy model. With base-2 logs, $H$ is in **bits** and
ranges from $0$ (the model is certain which byte comes next) to $\log_2 256 = 8$
(a uniform guess). BLT turns that signal into patch boundaries with one of two
rules:
- **Global threshold.** Start a new patch wherever the surprise crosses an
absolute level: $\;H(x_t) > \theta$.
- **Approximate-monotonic (relative) threshold.** Inside a patch, entropy tends to
*fall* as context accumulates; start a new patch where that trend **breaks** —
where entropy jumps: $\;H(x_t) - H(x_{t-1}) > \theta_r$.
The threshold is the single knob that sets the average patch size: raise it and
patches grow (fewer, cheaper global steps); lower it and they shrink (more,
finer-grained steps).
### The three stages of a Byte Latent Transformer
Patching is the idea; the model wraps it in three pieces. Step through them:
```{ojs}
//| echo: false
viewof bltArchStep = stepControl({min: 0, max: 4, value: 0, label: "BLT Stage"})
```
```{ojs}
//| echo: false
bltArchSteps = [
{label: "Raw bytes", sub: "no tokenizer", caption: "The input is the raw UTF-8 byte stream — every byte 0–255 is a valid, known input. There is no vocabulary and no <UNK>."},
{label: "Entropy patcher", sub: "small byte LM", caption: "A small, separate byte-level model scores next-byte entropy H(xₜ). Boundaries land where the surprise spikes — a cheap preprocessing pass."},
{label: "Local encoder", sub: "bytes → patch", caption: "A lightweight transformer pools the bytes of each patch into one patch vector. Small model, runs on every byte, but only locally."},
{label: "Latent transformer",sub: "the compute lives here", caption: "The big, expensive transformer runs autoregressively over PATCH vectors — one step per patch, not per byte. Fewer patches ⇒ less compute."},
{label: "Local decoder", sub: "patch → bytes", caption: "A lightweight transformer expands each predicted patch vector back into raw output bytes. Byte-level in, byte-level out."}
]
```
```{ojs}
//| echo: false
bltArchDiagram = {
const width = 760, height = 220;
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%")
.attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect")
.attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const n = bltArchSteps.length;
const boxW = 118, boxH = 62, gap = (width - 40 - n * boxW) / (n - 1);
const y = 46;
const defs = svg.append("defs");
defs.append("marker")
.attr("id", "blt-arch-arrow").attr("viewBox", "0 -5 10 10")
.attr("refX", 8).attr("refY", 0).attr("markerWidth", 5).attr("markerHeight", 5)
.attr("orient", "auto").append("path").attr("d", "M0,-5L10,0L0,5")
.attr("fill", theme.edgeStroke);
bltArchSteps.forEach((s, i) => {
const x = 20 + i * (boxW + gap);
const active = i === bltArchStep;
// The latent transformer (stage 3) is the compute-heavy stage — draw it taller.
const heavy = i === 3;
const h = heavy ? boxH + 14 : boxH;
const yy = heavy ? y - 7 : y;
const g = svg.append("g");
const rect = g.append("rect")
.attr("x", x).attr("y", yy).attr("width", boxW).attr("height", h).attr("rx", 8)
.attr("fill", active ? theme.highlight : theme.nodeFill)
.attr("stroke", active ? theme.highlight : (heavy ? theme.accent : theme.nodeStroke))
.attr("stroke-width", active ? 2.5 : (heavy ? 2 : 1.5));
if (active) rect.attr("filter", `drop-shadow(0 0 10px ${theme.highlightGlow})`);
g.append("text")
.attr("x", x + boxW / 2).attr("y", yy + h / 2 - 4).attr("text-anchor", "middle")
.attr("fill", active ? theme.textOnHighlight : theme.nodeText)
.attr("font-size", "12px").attr("font-weight", "600").text(s.label);
g.append("text")
.attr("x", x + boxW / 2).attr("y", yy + h / 2 + 13).attr("text-anchor", "middle")
.attr("fill", active ? theme.textOnHighlight : theme.edgeStroke)
.attr("font-size", "9.5px").attr("opacity", active ? 0.9 : 0.7).text(s.sub);
if (i < n - 1) {
svg.append("path")
.attr("d", `M${x + boxW + 3},${y + boxH / 2} L${x + boxW + gap - 4},${y + boxH / 2}`)
.attr("stroke", theme.edgeStroke).attr("stroke-width", 1.5)
.attr("marker-end", "url(#blt-arch-arrow)");
}
});
svg.append("rect")
.attr("x", 20).attr("y", height - 58).attr("width", width - 40).attr("height", 42)
.attr("rx", 6).attr("fill", theme.bgSecondary);
// Word-wrap the caption into up to two lines.
const words = bltArchSteps[bltArchStep].caption.split(" ");
const lines = ["", ""];
let li = 0;
words.forEach(w => {
if ((lines[li] + " " + w).trim().length > 82 && li === 0) li = 1;
lines[li] = (lines[li] + " " + w).trim();
});
svg.append("text")
.attr("x", width / 2).attr("y", height - 38).attr("text-anchor", "middle")
.attr("fill", theme.nodeText).attr("font-size", "11px").text(lines[0]);
svg.append("text")
.attr("x", width / 2).attr("y", height - 24).attr("text-anchor", "middle")
.attr("fill", theme.nodeText).attr("font-size", "11px").text(lines[1]);
return svg.node();
}
```
The tokenizer chapter's whole job — cutting text into the units a big model
processes — is here done by the **entropy patcher**, and the "vocabulary" is just
the 256 byte values. Everything you built with BPE still teaches the ideas; BLT is
one answer to *what if the cut were learned end-to-end from bytes instead*.
### From scratch: a byte entropy model and a patcher
We can build the whole patcher from scratch. The entropy model is the one part BLT
trains as a real (small) transformer; here we stand it in with a **count-based
n-gram byte model** — it conditions the next byte on the previous few, backs off to
shorter context when it hasn't seen a longer one, and gives us a genuine entropy
signal to threshold. All of this lives in `blt.py`.
```{python}
from blt import ByteEntropyModel, byte_entropy
# Shannon entropy of a next-byte distribution, in bits.
print("uniform over 256 bytes:", round(byte_entropy([1/256]*256), 2), "bits") # max
print("certain (one-hot): ", byte_entropy([1.0, 0.0, 0.0]), "bits") # min
```
Train the tiny entropy model on a little coherent corpus, then read off the
per-byte surprise of a sentence built from words it has seen:
```{python}
corpus = (
"the model learns to predict the next token from the previous tokens. "
"a language model predicts the next token. the model reads the tokens and "
"learns the patterns in the tokens. attention lets the model read every "
"token. the transformer predicts tokens one token at a time. "
) * 6
entropy_model = ByteEntropyModel(order=4).train(corpus)
demo = "the model predicts the next token"
ents = entropy_model.entropies(demo)
# Show the surprise of each byte — spikes at word starts, dips inside words.
for ch, h in list(zip(demo, ents))[:14]:
bar = "█" * int(h * 3)
print(f"{ch!r:5} {h:4.1f} bits {bar}")
```
Now turn that signal into patches with the global rule, and measure the payoff —
the big transformer runs once per patch, so the average patch size is (roughly) the
factor by which its work shrinks versus running on every byte:
```{python}
from blt import (
patch_boundaries_global, patches_from_boundaries,
average_patch_size, relative_global_compute,
)
theta = 2.0 # bits
boundaries = patch_boundaries_global(ents, theta)
patches = patches_from_boundaries(list(demo.encode()), boundaries)
avg = average_patch_size(boundaries, len(demo.encode()))
print(f"θ = {theta} bits → {len(patches)} patches, avg {avg:.2f} bytes/patch")
print(f"global transformer compute vs per-byte: {relative_global_compute(avg):.2f}×")
print("patches:", [bytes(p).decode() for p in patches])
```
Raise `theta` and the patches merge; lower it and they split — one knob trading
model resolution against compute.
### Interactive: drive the threshold
Below is the same demo sentence with each byte tinted by its entropy (calm → hot).
Drag the threshold and watch the **patch boundaries** move: bytes stay glued
together in the predictable, cool stretches and split apart where the surprise runs
hot. The live stats show how the average patch size — and the big model's
compute — respond.
```{python}
#| echo: false
#| output: false
# Bridge the real per-byte entropies (this demo is pure ASCII, so 1 char = 1 byte).
ojs_define(
bltChars = list(demo),
bltEntropies = [round(h, 4) for h in ents],
bltEntropyMax = round(max(ents), 4),
)
```
```{ojs}
//| echo: false
viewof bltRule = Inputs.radio(
new Map([["Global: H(xₜ) > θ", "global"], ["Monotonic: H(xₜ) − H(xₜ₋₁) > θ", "monotonic"]]),
{value: "global", label: "Boundary rule"}
)
```
```{ojs}
//| echo: false
viewof bltTheta = Inputs.range([0, Math.ceil(bltEntropyMax)], {
value: 2.0, step: 0.05, label: "Threshold θ (bits)"
})
```
```{ojs}
//| echo: false
// Compute patch start indices from the entropy strip + threshold, in JS, so the
// widget stays live without re-running Python. Mirrors patch_boundaries_* in blt.py.
bltBoundaries = {
const H = bltEntropies, n = H.length, starts = [0];
for (let t = 1; t < n; t++) {
const cut = bltRule === "global" ? H[t] > bltTheta : (H[t] - H[t - 1]) > bltTheta;
if (cut) starts.push(t);
}
return starts;
}
```
```{ojs}
//| echo: false
bltPatchStrip = {
const theme = diagramTheme;
const chars = bltChars, H = bltEntropies, n = chars.length;
const starts = new Set(bltBoundaries);
// calm (low entropy) → hot (high entropy)
const heat = d3.scaleLinear().domain([0, bltEntropyMax]).range([0, 1]).clamp(true);
const color = h => d3.interpolateRgb(theme.bgSecondary, theme.highlight)(heat(h));
const container = html`<div style="margin: 12px 0;"></div>`;
// Byte cells, boxed into patches by a left border wherever a new patch starts.
const strip = html`<div style="display:flex; flex-wrap:wrap; gap:2px; align-items:flex-end;"></div>`;
chars.forEach((ch, i) => {
const isStart = starts.has(i);
const cell = html`<div title="H = ${H[i].toFixed(2)} bits" style="
min-width: 20px; text-align:center; padding:6px 4px 4px;
background:${color(H[i])};
border-radius:3px;
border-left:${isStart ? `3px solid ${theme.accent}` : "3px solid transparent"};
margin-left:${isStart && i > 0 ? "8px" : "0"};">
<div style="font-family:var(--pg-mono); font-size:14px; color:${theme.nodeText};">${ch === " " ? "␣" : ch}</div>
<div style="font-family:var(--pg-mono); font-size:8px; color:${theme.nodeText}; opacity:0.55;">${H[i].toFixed(1)}</div>
</div>`;
strip.appendChild(cell);
});
container.appendChild(strip);
// Live stats.
const numPatches = starts.size;
const avg = n / numPatches;
const rel = 1 / avg;
const stats = html`<div style="font-family:var(--pg-mono); font-size:13px; margin-top:14px; color:${theme.nodeText};">
<strong>${numPatches}</strong> patches
· avg <strong>${avg.toFixed(2)}</strong> bytes/patch
· global compute
<span style="color:${theme.accent}; font-weight:600;">${rel.toFixed(2)}×</span>
<span style="opacity:0.65;">of per-byte</span>
</div>`;
container.appendChild(stats);
// Legend.
const legend = html`<div style="display:flex; align-items:center; gap:8px; font-family:var(--pg-mono); font-size:11px; margin-top:8px; color:${theme.nodeText}; opacity:0.75;">
<span>calm</span>
<span style="display:inline-block; width:120px; height:10px; border-radius:5px;
background:linear-gradient(90deg, ${color(0)}, ${color(bltEntropyMax)});"></span>
<span>surprising</span>
<span style="margin-left:12px; border-left:3px solid ${theme.accent}; padding-left:6px;">= new patch</span>
</div>`;
container.appendChild(legend);
return container;
}
```
::: {.callout-tip}
## Try This
1. **Slide θ to the extremes.** Near `0`, almost every byte starts its own patch
(byte-level, maximum compute). Near the top, the whole sentence collapses into
one or two patches (cheap, but the big model sees very coarse units).
2. **Find the word starts.** Notice the boundaries snap to the *beginnings* of
words — `m` of `model`, `p` of `predicts` — because the first byte of a word is
the surprising one. The predictable tails (`odel`, `oken`) stay glued.
3. **Switch to the monotonic rule.** It fires on entropy *jumps* rather than an
absolute level, so it reacts to local rises and is steadier when the overall
entropy drifts over a long sequence.
:::
::: {.callout-warning}
## The entropy model is a separate model — and entropy ≠ correctness
The patcher's ruler is its own small byte LM, trained *before* the main model and
frozen during patching (a cheap preprocessing pass over the data). Two things to
keep straight: high entropy means the *next byte* is hard to predict, **not** that
the content is important or that any answer is "wrong"; and our count-based n-gram
stand-in falls back toward uniform (≈8 bits) on byte contexts it never saw, so on
truly novel text a real trained entropy transformer gives a smoother, better
signal than this toy. The *mechanism* — threshold the surprise to place boundaries
— is identical.
:::
BLT is the first byte-level architecture to **match a strong BPE-tokenized model
(Llama 3) at scale** in a compute-controlled study up to 8B parameters, while using
**up to ~50% fewer inference FLOPs** by spending them only where bytes are hard.
Dropping the fixed vocabulary also buys robustness to noisy or unusual text and
strong character-level manipulation — with no `<UNK>` and no tokenizer to train.
### Going Deeper
**Core Papers:**
- [Byte Latent Transformer: Patches Scale Better Than Tokens](https://arxiv.org/abs/2412.09871) — Pagnoni et al., 2024. The entropy-patching architecture built above; matches Llama 3 at scale with fewer inference FLOPs.
- [MEGABYTE: Predicting Million-byte Sequences with Multiscale Transformers](https://arxiv.org/abs/2305.07185) — Yu et al., 2023. The predecessor: fixed-size byte patches with a global-then-local transformer stack.
- [MambaByte: Token-free Selective State Space Model](https://arxiv.org/abs/2401.13660) — Wang et al., 2024. Token-free byte modeling with a state-space backbone (see Module 19).
## Summary
Key takeaways:
1. **BPE learns subword units** by iteratively merging the most frequent adjacent token pairs
2. **Vocabulary size** is a tradeoff: larger = shorter sequences but more parameters and sparse token usage
3. **Special tokens** (BOS, EOS, PAD, UNK) serve critical roles in the LLM pipeline
4. **Code patterns** emerge naturally (def, self., return, indentation) when trained on code
5. **Round-trip guarantee**: encode -> decode should perfectly reconstruct the original text
6. **Byte-level BPE** starts from the 256 UTF-8 bytes instead of characters, so it encodes *any* text — emoji, accents, CJK — with no `<UNK>` and an exact round-trip. This is the layout GPT-2, tiktoken, and SentencePiece all use.
7. **Pre-tokenization** runs *before* BPE and bounds every merge to one pre-token. GPT-2's regex splits off contractions, keeps letters/digits/punctuation apart, and lets a leading space ride with its word — all while staying *total*, so the round-trip holds. Cleaner boundaries mean a cleaner learned vocabulary.
8. **Chat templates** (ChatML) serialize a role-tagged conversation into one flat ID stream, using reserved `<|im_start|>`/`<|im_end|>` control tokens to mark turns. Roles are just tokens; the `add_generation_prompt` open turn is what primes the model to reply. The template is part of the model's contract.
9. **Byte-latent patching** (BLT) drops the tokenizer entirely: it runs on raw bytes and cuts them into *dynamic* patches wherever a small entropy model's next-byte surprise `H(xₜ)` spikes, so the big transformer runs once per patch and compute flows to the hard-to-predict bytes. A patch is BLT's dynamic answer to BPE's static token.
### What We Simplified
Even the byte-level tokenizer we built still differs from production tokenizers in several ways:
| Our Tokenizer | Production Tokenizers |
|---------------|----------------------|
| Byte-level BPE (built above) — plus a simpler character-level version | Byte-level BPE (same core idea) |
| GPT-2 regex pre-tokenization (built above) | GPT-2 regex pre-tokenization (same) |
| Python dict lookups | Optimized Rust/C++ (tiktoken is 10x+ faster) |
| Trained on tiny corpora | Trained on trillions of tokens |
**Pre-tokenization** — which we built above with `gpt2_pretokenize` — is the step
that keeps merges linguistically clean by bounding BPE to one pre-token at a time.
The main gap that remains is *speed*: production tokenizers run the same regex and
BPE in optimized Rust/C++ (tiktoken is 10×+ faster than our teaching Python) and
cache merges aggressively, but the algorithm is exactly the one you just built.
### Practical Implications
- **Context length**: A 4096-token context window holds varying amounts of text depending on tokenization efficiency
- **Cost**: API pricing is per-token, so tokenization directly affects cost
- **Multilingual**: Tokenizers trained on English use more tokens for other languages (2-3x for some)
- **Code vs prose**: Code often tokenizes inefficiently (many single-character tokens for syntax)
## What's Next
[Module 04: Embeddings](../m04_embeddings/lesson.qmd) converts token IDs into dense vectors that capture meaning. Each token becomes a learnable vector in high-dimensional space.