Phase 10 - Lesson 02

Construyendo un Tokenizador desde Cero

This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.

La Lección 01 te dio un juguete. Esta lección te da un arma.

Tipo: Construcción Idiomas: Python Prerrequisitos: Fase 10, Lección 01 (Tokenizadores: BPE, WordPiece, SentencePiece) Tiempo: ~90 minutos

Objetivos de Aprendizaje

  • Construir un tokenizador BPE de grado de producción que maneje Unicode, normalización de espacios en blanco y tokens especiales
  • Implementar un fallback a nivel de bytes para que el tokenizador pueda codificar cualquier entrada (incluyendo emoji, CJK y código) sin tokens desconocidos
  • Añadir patrones regex de pre-tokenización que dividan el texto en los límites de las palabras antes de aplicar las fusiones de BPE
  • Entrenar un tokenizador personalizado en un corpus y evaluar su tasa de compresión frente a tiktoken en texto multilingüe

El Problema

Tu tokenizador BPE de la Lección 01 funciona en texto en inglés. Ahora pruébalo con japonés. O con un emoji. O con código Python que tenga una mezcla de tabulaciones y espacios.

Se rompe.

No porque BPE sea incorrecto, sino porque la implementación está incompleta. Un tokenizador de producción maneja bytes crudos en cualquier codificación, normaliza Unicode antes de la división, gestiona tokens especiales que nunca se fusionan, encadena la pre-tokenización con la división de subpalabras y hace todo esto lo suficientemente rápido como para no saturar un flujo de entrenamiento que procesa 15 billones de tokens.

El tokenizador de GPT-2 tiene 50,257 tokens. Llama 3 tiene 128,256. GPT-4 tiene aproximadamente 100,000. Estos no son números de juguete. Las tablas de fusión detrás de esos vocabularios se entrenaron con cientos de gigabytes de texto, y la maquinaria circundante —normalización, pre-tokenización, inyección de tokens especiales, formato de plantillas de chat— es lo que separa a un tokenizador que maneja "hello world" de uno que maneja todo el internet.

Vas a construir esa maquinaria.

El Concepto

El Pipeline Completo

Un tokenizador de producción no es un solo algoritmo. Es un pipeline de Silicon de cinco etapas, cada una resolviendo un problema diferente.

graph LR
    A[Texto Crudo] --> B[Normalizar]
    B --> C[Pre-Tokenizar]
    C --> D[Fusión BPE]
    D --> E[Tokens Especiales]
    E --> F[IDs de Tokens]

    style A fill:#1a1a2e,stroke:#e94560,color:#fff
    style B fill:#1a1a2e,stroke:#e94560,color:#fff
    style C fill:#1a1a2e,stroke:#e94560,color:#fff
    style D fill:#1a1a2e,stroke:#e94560,color:#fff
    style E fill:#1a1a2e,stroke:#e94560,color:#fff
    style F fill:#1a1a2e,stroke:#e94560,color:#fff

Cada etapa tiene una función específica:

Etapa Qué Hace Por Qué Importa
Normalizar Unicode NFKC, minúsculas opcional, eliminar acentos opcional La ligadura "fi" (U+FB01) se convierte en "fi" (dos caracteres). Sin esto, la misma palabra obtiene diferentes tokens.
Pre-Tokenizar Divide el texto en fragmentos antes de BPE Evita que BPE se fusione a través de los límites de las palabras. "the cat" nunca debería producir un token "e c".
Fusión BPE Aplica reglas de fusión aprendidas a secuencias de bytes La compresión central. Convierte bytes crudos en tokens de subpalabras.
Tokens Especiales Inyecta marcadores [BOS], [EOS], [PAD] y marcadores de plantilla de chat Estos tokens tienen IDs fijos. Nunca participan en fusiones BPE. El modelo los necesita para la estructura.
Mapeo de IDs Convierte cadenas de tokens en IDs enteros El modelo ve enteros, no cadenas.

BPE a Nivel de Bytes

El tokenizador de la Lección 01 funcionaba con bytes UTF-8. Esa fue la decisión correcta. Pero nos saltamos algo importante: ¿qué pasa cuando esos bytes no son UTF-8 válidos?

BPE a nivel de bytes resuelve esto tratando cada valor de byte posible (0-255) como un token válido. Tu vocabulario base tiene exactamente 256 entradas. Cualquier archivo —texto, binario, corrompido— se puede tokenizar sin producir un token desconocido.

GPT-2 añadió un truque: mapear cada byte a un carácter Unicode imprimible para que el vocabulario siga siendo legible por humanos. El byte 0x20 (espacio) se convierte en el carácter "G" en su mapeo. Esto es puramente cosmético. Al algoritmo no le importa.

El verdadero poder: BPE a nivel de bytes maneja todos los idiomas de la tierra. Los caracteres chinos tienen 3 bytes UTF-8 cada uno. El japonés puede tener entre 3 y 4 bytes. El árabe, devanagari, emoji: todos son solo secuencias de bytes. El algoritmo BPE encuentra patrones en estas secuencias de bytes exactamente de la misma manera que encuentra patrones en bytes ASCII de inglés.

Pre-Tokenización

Antes de que BPE toque tu texto, necesitas dividirlo en fragmentos. Esto evita que el algoritmo de fusión cree tokens que abarquen los límites de las palabras.

GPT-2 utiliza un patrón regex para dividir el texto:

'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+

Este patrón divide en contracciones ("don't" se convierte en "don" + "'t"), palabras con espacios iniciales opcionales, números, puntuación y espacios en blanco. El espacio inicial se mantiene unido a la palabra, por lo que "the cat" se convierte en [" the", " cat"], no en ["the", " ", "cat"].

Llama utiliza SentencePiece, que omite la expresión regular por completo. Trata el flujo de bytes crudos como una sola secuencia larga y deja que el algoritmo BPE descubra los límites. Esto es más simple, pero le da a BPE más libertad para crear tokens que cruzan palabras.

La elección importa. La expresión regular de GPT-2 evita que el tokenizador aprenda que el "the" al final de una palabra y el "the" al principio de la siguiente deben fusionarse. SentencePiece lo permite, lo que a veces produce una compresión más eficiente pero tokens menos interpretables.

Tokens Especiales

Cada tokenizador de producción reserva IDs de tokens para marcadores estructurales:

Token Propósito Usado Por
[BOS] / <s> Inicio de secuencia Llama 3, GPT
[EOS] / </s> Fin de secuencia Todos los modelos
[PAD] Relleno para alineación de lotes BERT, T5
[UNK] Token desconocido (BPE a nivel de bytes elimina esto) BERT, WordPiece
<|im_start|> Inicio del límite del mensaje de chat ChatGPT, Qwen
<|im_end|> Fin del límite del mensaje de chat ChatGPT, Qwen
<|user|> Marcador de turno del usuario Llama 3
<|assistant|> Marcador de turno del asistente Llama 3

Los tokens especiales necesitan coincidencia exacta e IDs fijos. Evitan BPE por completo.

Plantillas de Chat

Aquí es donde la mayoría de la gente se confunde y la mayoría de las implementaciones fallan.

Cuando envías mensajes a un modelo de chat, la API acepta una lista de mensajes:

[
  {"role": "system", "content": "You are helpful."},
  {"role": "user", "content": "Hello"},
  {"role": "assistant", "content": "Hi there!"}
]

El modelo no ve JSON. Ve una secuencia de tokens plana. La plantilla de chat convierte los mensajes en esa secuencia plana utilizando tokens especiales. Cada modelo lo hace de manera diferente:

Llama 3:
<|begin_of_text|><|start_header_id|>system<|end_header_id|>

You are helpful.<|eot_id|><|start_header_id|>user<|end_header_id|>

Hello<|eot_id|><|start_header_id|>assistant<|end_header_id|>

Hi there!<|eot_id|>

ChatGPT:
<|im_start|>system
You are helpful.<|im_end|>
<|im_start|>user
Hello<|im_end|>
<|im_start|>assistant
Hi there!<|im_end|>

Si configuras mal la plantilla, el modelo producirá basura. Fue entrenado en un formato exacto. Cualquier desviación —una nueva línea faltante, un token cambiado, un espacio adicional— coloca la entrada fuera de la distribución de entrenamiento.

Velocidad

Python es demasiado lento para la tokenización en producción.

tiktoken (OpenAI) está escrito en Rust con enlaces de Python. HuggingFace tokenizers también es Rust. SentencePiece es C++. Estos logran aceleraciones de 10 a 100 veces sobre Python puro.

Para ponerlo en perspectiva: tokenizar 15 billones de tokens para el pre-entrenamiento de Llama 3 a 1 millón de tokens por segundo (Python rápido) tomaría 174 días. A 100 millones de tokens por segundo (Rust), toma 1.7 días.

Estás construyendo en Python para entender el algoritmo. En producción, usarías una implementación compilada y solo tocarías el wrapper de Python.

Constrúyelo

Paso 1: Codificación a Nivel de Bytes

La base. Convierte cualquier cadena en una secuencia de bytes, mapea cada byte a un carácter imprimible para su visualización y revierte el proceso.

def bytes_to_tokens(text):
    return list(text.encode("utf-8"))

def tokens_to_text(token_bytes):
    return bytes(token_bytes).decode("utf-8", errors="replace")

Pruébalo con texto multilingüe para ver los conteos de bytes:

texts = [
    ("English", "hello"),
    ("Chinese", "你好"),
    ("Emoji", "🔥"),
    ("Mixed", "hello你好🔥"),
]

for label, text in texts:
    b = bytes_to_tokens(text)
    print(f"{label}: {len(text)} chars -> {len(b)} bytes -> {b}")

"hello" tiene 5 bytes. "你好" tiene 6 bytes (3 por carácter). El emoji de fuego tiene 4 bytes. Al tokenizador a nivel de bytes no le importa qué idioma sea. Los bytes son bytes.

Paso 2: Pre-Tokenizador con Regex

Divide el texto en fragmentos usando el patrón regex de GPT-2. Cada fragmento se tokeniza de forma independiente mediante BPE.

import re

try:
    import regex
    GPT2_PATTERN = regex.compile(
        r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
    )
except ImportError:
    GPT2_PATTERN = re.compile(
        r"""'(?:[sdmt]|ll|ve|re)| ?[a-zA-Z]+| ?[0-9]+| ?[^\s\w]+|\s+(?!\S)|\s+"""
    )

def pre_tokenize(text):
    return [match.group() for match in GPT2_PATTERN.finditer(text)]

El módulo regex admite escapes de propiedades Unicode (\p{L} para letras, \p{N} para números). El módulo re de la biblioteca estándar no lo hace, por lo que recurrimos a clases de caracteres ASCII. Para tokenizadores multilingües de producción, instala regex.

Pruébalo:

print(pre_tokenize("Hello, world! Don't stop."))
# [' Hello', ',', ' world', '!', " Don", "'t", ' stop', '.']

El espacio inicial permanece unido a la palabra. Las contracciones se dividen en el apóstrofo. La puntuación se convierte en su propio fragmento. BPE nunca fusionará tokens a través de estos límites.

Paso 3: BPE en Secuencias de Bytes

El algoritmo central de la Lección 01, pero ahora operando en fragmentos pre-tokenizados de forma independiente.

from collections import Counter

def get_byte_pairs(chunks):
    pairs = Counter()
    for chunk in chunks:
        byte_seq = list(chunk.encode("utf-8"))
        for i in range(len(byte_seq) - 1):
            pairs[(byte_seq[i], byte_seq[i + 1])] += 1
    return pairs

def apply_merge(byte_seq, pair, new_id):
    merged = []
    i = 0
    while i < len(byte_seq):
        if i < len(byte_seq) - 1 and byte_seq[i] == pair[0] and byte_seq[i + 1] == pair[1]:
            merged.append(new_id)
            i += 2
        else:
            merged.append(byte_seq[i])
            i += 1
    return merged

Paso 4: Manejo de Tokens Especiales

Los tokens especiales necesitan coincidencia exacta e IDs fijos. Evitan BPE por completo.

class SpecialTokenHandler:
    def __init__(self):
        self.special_tokens = {}
        self.pattern = None

    def add_token(self, token_str, token_id):
        self.special_tokens[token_str] = token_id
        escaped = [re.escape(t) for t in sorted(self.special_tokens.keys(), key=len, reverse=True)]
        self.pattern = re.compile("|".join(escaped))

    def split_with_specials(self, text):
        if not self.pattern:
            return [(text, False)]
        parts = []
        last_end = 0
        for match in self.pattern.finditer(text):
            if match.start() > last_end:
                parts.append((text[last_end:match.start()], False))
            parts.append((match.group(), True))
            last_end = match.end()
        if last_end < len(text):
            parts.append((text[last_end:], False))
        return parts

Paso 5: Clase de Tokenizador Completo

Encadena todo junto: normaliza, divide en tokens especiales, pre-tokeniza, fusiona BPE, mapea a IDs.

import unicodedata

class ProductionTokenizer:
    def __init__(self):
        self.merges = {}
        self.vocab = {i: bytes([i]) for i in range(256)}
        self.special_handler = SpecialTokenHandler()
        self.next_id = 256

    def normalize(self, text):
        return unicodedata.normalize("NFKC", text)

    def train(self, text, num_merges):
        text = self.normalize(text)
        chunks = pre_tokenize(text)
        chunk_bytes = [list(chunk.encode("utf-8")) for chunk in chunks]

        for i in range(num_merges):
            pairs = Counter()
            for seq in chunk_bytes:
                for j in range(len(seq) - 1):
                    pairs[(seq[j], seq[j + 1])] += 1
            if not pairs:
                break
            best = max(pairs, key=pairs.get)
            new_id = self.next_id
            self.next_id += 1
            self.merges[best] = new_id
            self.vocab[new_id] = self.vocab[best[0]] + self.vocab[best[1]]
            chunk_bytes = [apply_merge(seq, best, new_id) for seq in chunk_bytes]

    def add_special_token(self, token_str):
        token_id = self.next_id
        self.next_id += 1
        self.special_handler.add_token(token_str, token_id)
        self.vocab[token_id] = token_str.encode("utf-8")
        return token_id

    def encode(self, text):
        text = self.normalize(text)
        parts = self.special_handler.split_with_specials(text)
        all_ids = []
        for part_text, is_special in parts:
            if is_special:
                all_ids.append(self.special_handler.special_tokens[part_text])
            else:
                for chunk in pre_tokenize(part_text):
                    byte_seq = list(chunk.encode("utf-8"))
                    for pair, new_id in self.merges.items():
                        byte_seq = apply_merge(byte_seq, pair, new_id)
                    all_ids.extend(byte_seq)
        return all_ids

    def decode(self, ids):
        byte_parts = []
        for token_id in ids:
            if token_id in self.vocab:
                byte_parts.append(self.vocab[token_id])
        return b"".join(byte_parts).decode("utf-8", errors="replace")

    def vocab_size(self):
        return len(self.vocab)

Paso 6: Prueba Multilingüe

La prueba real. Pruébalo con inglés, chino, emoji y código.

corpus = (
    "The quick brown fox jumps over the lazy dog. "
    "The quick brown fox runs through the forest. "
    "Machine learning models process natural language. "
    "Deep learning transforms how we build software. "
    "def train(model, data): return model.fit(data) "
    "def predict(model, x): return model(x) "
)

tok = ProductionTokenizer()
tok.train(corpus, num_merges=50)

bos = tok.add_special_token("<|begin|>")
eos = tok.add_special_token("<|end|>")

test_texts = [
    "The quick brown fox.",
    "你好世界",
    "Hello 🌍 World",
    "def foo(x): return x + 1",
    f"<|begin|>Hello<|end|>",
]

for text in test_texts:
    ids = tok.encode(text)
    decoded = tok.decode(ids)
    print(f"Input:   {text}")
    print(f"Tokens:  {len(ids)} ids")
    print(f"Decoded: {decoded}")
    print()

Los caracteres chinos producen 3 bytes cada uno. El emoji produce 4 bytes. Ninguno de estos hace fallar al tokenizador. Ninguno produce tokens desconocidos. Ese es el poder de BPE a nivel de bytes.

Úsalo

Comparando Tokenizadores Reales

Carga los tokenizadores reales de Llama 3, GPT-4 y Mistral. Mira cómo maneja cada uno el mismo párrafo multilingüe.

import tiktoken

gpt4_enc = tiktoken.get_encoding("cl100k_base")

test_paragraph = "Machine learning is powerful. 机器学习很强大。 L'apprentissage automatique est puissant. 🤖💪"

tokens = gpt4_enc.encode(test_paragraph)
pieces = [gpt4_enc.decode([t]) for t in tokens]
print(f"GPT-4 ({len(tokens)} tokens): {pieces}")
from transformers import AutoTokenizer

llama_tok = AutoTokenizer.from_pretrained("meta-llama/Meta-Llama-3-8B")
mistral_tok = AutoTokenizer.from_pretrained("mistralai/Mistral-7B-v0.1")

for name, tok in [("Llama 3", llama_tok), ("Mistral", mistral_tok)]:
    tokens = tok.encode(test_paragraph)
    pieces = tok.convert_ids_to_tokens(tokens)
    print(f"{name} ({len(tokens)} tokens): {pieces[:20]}...")

Verás diferentes conteos de tokens para el mismo texto. Llama 3 con un vocabulario de 128K es más agresivo al fusionar patrones comunes. GPT-4 con 100K se sitúa en el medio. Mistral con 32K produce más tokens pero tiene una capa de embedding más pequeña.

El compromiso es siempre el mismo: un vocabulario más grande significa secuencias más cortas pero más parámetros.

Envíalo a Producción

Esta lección produce un prompt para construir y depurar tokenizadores de producción. Consulta outputs/prompt-tokenizer-builder.md.

Ejercicios

  1. Fácil: Añade un método get_token_bytes(id) que muestre los bytes crudos para cualquier ID de token. Úsalo para inspeccionar qué representan realmente los tokens fusionados más comunes.
  2. Medio: Implementa el pre-tokenizador al estilo Llama que divide en espacios en blanco y dígitos pero mantiene los espacios iniciales. Compara su vocabulario con el enfoque regex de GPT-2 en el mismo corpus.
  3. Difícil: Añade un método de plantilla de chat que tome una lista de mensajes {"role": ..., "content": ...} y produzca la secuencia de tokens correcta para el formato de chat de Llama 3. Pruébalo contra la implementación de HuggingFace.

Términos Clave

Término Lo que dice la gente Lo que realmente significa
BPE a nivel de bytes "Tokenizador que funciona en bytes" BPE con un vocabulario base de 256 valores de bytes: maneja cualquier entrada sin tokens desconocidos
Pre-tokenización "División antes de BPE" División basada en expresiones regulares o reglas que evita que BPE se fusione a través de los límites de las palabras
Normalización NFKC "Limpieza de Unicode" Descomposición canónica seguida de composición de compatibilidad: la ligadura "fi" se convierte en "fi", la "A" de ancho completo (fullwidth) se convierte en "A"
Plantilla de chat "Cómo los mensajes se convierten en tokens" El formato exacto para convertir una lista de mensajes de rol/contenido en una secuencia de tokens plana: específico del modelo y debe coincidir con el formato de entrenamiento
Tokens especiales "Tokens de control" IDs de tokens reservados que omiten BPE —[BOS], [EOS], [PAD], marcadores de chat— que coinciden exactamente antes de la fusión
Fertilidad "Tokens por palabra" Proporción de tokens de salida respecto a palabras de entrada: 1.3 para inglés en GPT-4, 2-3 para coreano; un valor más alto significa contexto desperdiciado
tiktoken "Tokenizador de OpenAI" Implementación de BPE en Rust con enlaces de Python: 10-100 veces más rápida que Python puro
Tabla de fusiones "El vocabulario" Lista ordenada de fusiones de pares de bytes aprendidas durante el entrenamiento: este ES el conocimiento aprendido del tokenizador

Leituras Adicionales

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).