Lección 1 de 6 · Del texto a los vectores · 20 min
Tokenización: de texto a enteros con BPE
Implementas Byte Pair Encoding desde cero y entiendes por qué casi todos los LLM modernos tokenizan en subpalabras.
Un Transformer no ve letras ni palabras: ve secuencias de enteros. El paso que convierte texto en enteros se llama tokenización (tokenization), y aunque parece un detalle de preprocesamiento, condiciona el tamaño del vocabulario, el largo de las secuencias, el costo de inferencia y hasta en qué idiomas el modelo es "más barato". En esta lección vas a construir el tokenizador que usará tu mini-GPT durante todo el curso.
Tres formas de partir el texto
| Estrategia | Vocabulario | Largo de secuencia | Problema principal |
|---|---|---|---|
| Caracteres | Muy chico (~100–300) | Muy largo | El modelo gasta capacidad y contexto en "deletrear" |
| Palabras | Enorme (cientos de miles) | Corto | Palabras desconocidas (out-of-vocabulary, OOV) y morfología: "correr", "corrió", "corriendo" son tokens independientes |
| Subpalabras (BPE) | Ajustable (p. ej. 32k–100k) | Intermedio | Hay que entrenar el tokenizador y sus artefactos se filtran al modelo |
La tokenización por subpalabras es el punto medio: las palabras frecuentes quedan como un único token y las raras se descomponen en piezas reutilizables. Ninguna cadena es "desconocida", porque en el peor caso se cae a unidades mínimas.
“We introduce a simpler and more effective approach, making the NMT model capable of open-vocabulary translation by encoding rare and unknown words as sequences of subword units.”
El algoritmo BPE
Byte Pair Encoding (codificación por pares de bytes) nació como algoritmo de compresión y Sennrich et al. lo adaptaron a traducción automática. La idea de entrenamiento es greedy (voraz):
- Empieza con un vocabulario de símbolos mínimos (caracteres o bytes).
- Cuenta todos los pares de símbolos adyacentes en el corpus.
- Fusiona el par más frecuente en un símbolo nuevo y agrégalo al vocabulario.
- Repite hasta alcanzar el tamaño de vocabulario deseado.
El resultado es una lista ordenada de fusiones (merges). Ese orden es el tokenizador.
Un ejemplo a mano
Supón este corpus, con la frecuencia de cada palabra:
low ×5 lower ×2 newest ×6 widest ×3
Contamos pares adyacentes ponderando por frecuencia:
| Par | Cuenta |
|---|---|
e s | 6 + 3 = 9 |
s t | 6 + 3 = 9 |
w e | 2 + 6 = 8 |
l o | 5 + 2 = 7 |
o w | 5 + 2 = 7 |
n e, e w | 6 |
Hay empate entre e s y s t; desempatamos por el primero encontrado y fusionamos e s → es. Ahora newest es n e w es t y widest es w i d es t. Al recontar, es t aparece 9 veces: fusionamos es t → est. Luego l o → lo (7) y lo w → low (7). Tras cuatro fusiones, lowest —una palabra que nunca vimos— se tokeniza como low + est. Eso es generalización morfológica gratis.
Comprueba tu comprensión
Después de las fusiones del ejemplo (es, est, lo, low), ¿cómo se tokeniza la palabra nueva 'slow'?
BPE a nivel de bytes
Si partes de caracteres Unicode, el vocabulario base puede ser enorme (hay más de cien mil caracteres asignados). GPT-2 popularizó la variante byte-level: se codifica el texto en UTF-8 y se parte de los 256 bytes posibles. Así cualquier string —emojis, chino, código— es representable sin token "desconocido". El costo: un carácter como ñ ocupa 2 bytes en UTF-8, así que antes de fusionar vale dos símbolos.
Implementación mínima
Vamos con una implementación byte-level en Python puro. Primero, dos utilidades: contar pares y reemplazar un par por un id nuevo.
def get_stats(ids):
"""Cuenta pares adyacentes en una lista de enteros."""
counts = {}
for pair in zip(ids, ids[1:]):
counts[pair] = counts.get(pair, 0) + 1
return counts
def merge(ids, pair, idx):
"""Reemplaza cada aparición de `pair` en `ids` por el token `idx`."""
out, i = [], 0
while i < len(ids):
if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]:
out.append(idx)
i += 2
else:
out.append(ids[i])
i += 1
return out
Ahora el tokenizador. train aprende las fusiones; encode las aplica en el orden en que se aprendieron; decode concatena bytes.
class BPETokenizer:
def train(self, text, vocab_size):
assert vocab_size >= 256
ids = list(text.encode("utf-8"))
self.merges = {} # (a, b) -> nuevo id
self.vocab = {i: bytes([i]) for i in range(256)} # id -> bytes
for idx in range(256, vocab_size):
stats = get_stats(ids)
if not stats:
break
pair = max(stats, key=stats.get)
ids = merge(ids, pair, idx)
self.merges[pair] = idx
self.vocab[idx] = self.vocab[pair[0]] + self.vocab[pair[1]]
def encode(self, text):
ids = list(text.encode("utf-8"))
while len(ids) >= 2:
stats = get_stats(ids)
# el par con la fusión aprendida MÁS TEMPRANO (id más bajo)
pair = min(stats, key=lambda p: self.merges.get(p, float("inf")))
if pair not in self.merges:
break # no queda nada por fusionar
ids = merge(ids, pair, self.merges[pair])
return ids
def decode(self, ids):
raw = b"".join(self.vocab[i] for i in ids)
return raw.decode("utf-8", errors="replace")
Pruébalo:
text = open("input.txt", encoding="utf-8").read() # cualquier texto en español
tok = BPETokenizer()
tok.train(text, vocab_size=512)
ids = tok.encode("La atención es todo lo que necesitas")
print(len(ids), ids)
assert tok.decode(ids) == "La atención es todo lo que necesitas"
¿Por qué errors="replace" en decode? Porque el modelo puede generar una secuencia de ids cuyos bytes no formen UTF-8 válido (por ejemplo, el primer byte de una ñ sin el segundo). En vez de explotar, mostramos el carácter de reemplazo.
Comprueba tu comprensión
En `encode`, ¿por qué se elige el par con el id de fusión MÁS BAJO en lugar del par más frecuente del texto de entrada?
Complejidad y atajos de producción
Nuestra versión recalcula todas las estadísticas en cada fusión: el entrenamiento es aproximadamente , con fusiones y bytes del corpus. Para un corpus de juguete es perfecto; en producción se usan estructuras incrementales y se entrena sobre palabras únicas con su frecuencia, como en el ejemplo a mano.
La compresión que logra el tokenizador se puede resumir como
cuántos bytes de texto entran, en promedio, en cada token
Cuanto mayor el ratio, más texto entra en la misma ventana de contexto. Hay una tensión clara: más fusiones (vocabulario más grande) dan mejor compresión, pero agrandan la matriz de embeddings y la capa de salida, y dejan tokens raros con pocos ejemplos de entrenamiento.
Los tokenizadores reales agregan dos cosas que aquí omitimos:
- Pre-tokenización con regex: GPT-2 parte el texto en fragmentos (palabras, números, puntuación, espacios) antes de aplicar BPE, para que no se fusionen cosas como
"perro."en un único token que mezcla palabra y puntuación. - Tokens especiales: marcadores como
<|endoftext|>que delimitan documentos y nunca deben aparecer por fusión accidental.
Comprueba tu comprensión
Aumentas el tamaño del vocabulario de 512 a 50.000 tokens manteniendo el resto del modelo igual. ¿Qué efecto NO esperarías?
Lo que te llevas
- El modelo solo ve enteros; el tokenizador decide qué es un "átomo" de texto.
- BPE aprende una lista ordenada de fusiones a partir de frecuencias de pares.
- La variante byte-level elimina los tokens desconocidos a costa de secuencias algo más largas para texto no ASCII.
- En la próxima lección convertimos esos enteros en vectores: embeddings.
¿Qué tal esta lección?
Sé el primero en calificarla
¿Terminaste la lección?
Márcala para sumar +50 XP y mantener tu racha.
Tutor IA
Contexto: Tokenización: de texto a enteros con BPE
Conectando con el tutor…