Saltar al contenido
← Transformers desde cero

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

EstrategiaVocabularioLargo de secuenciaProblema principal
CaracteresMuy chico (~100–300)Muy largoEl modelo gasta capacidad y contexto en "deletrear"
PalabrasEnorme (cientos de miles)CortoPalabras desconocidas (out-of-vocabulary, OOV) y morfología: "correr", "corrió", "corriendo" son tokens independientes
Subpalabras (BPE)Ajustable (p. ej. 32k–100k)IntermedioHay 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.”
— Sennrich et al., 2016

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):

  1. Empieza con un vocabulario de símbolos mínimos (caracteres o bytes).
  2. Cuenta todos los pares de símbolos adyacentes en el corpus.
  3. Fusiona el par más frecuente en un símbolo nuevo y agrégalo al vocabulario.
  4. 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:

ParCuenta
e s6 + 3 = 9
s t6 + 3 = 9
w e2 + 6 = 8
l o5 + 2 = 7
o w5 + 2 = 7
n e, e w6

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.

python
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.

python
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:

python
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 O(M⋅N)O(M \cdot N), con MM fusiones y NN 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

ratio=nuˊmero de bytes UTF-8nuˊmero de tokens\text{ratio} = \frac{\text{número de bytes UTF-8}}{\text{número de tokens}}

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

Entra para calificar

¿Terminaste la lección?

Márcala para sumar +50 XP y mantener tu racha.