BPE de verdad: fusionar, no solo partir

BPE de verdad: fusionar, no solo partir

30 min de lectura

Cuántos tokens tiene una frase, y qué es cada uno, no lo decide la red: lo decide el tokenizador, antes de que la red vea nada. El curso anterior explicó en su lección sobre tokenización por qué los modelos de verdad cortan por debajo de la palabra, y la lección sobre el modelo de lenguaje causal escribió la probabilidad de un texto como un producto con un factor por token, contado con palabras enteras porque cabían en una hoja de papel. Ninguna de las dos construyó el tokenizador.

Esta lección lo construye, y es el que usa el mini-GPT de este bloque. Tiene que elegir unos cientos de trozos entre todas las cadenas posibles del español sin que nadie le diga qué es una sílaba o un sufijo, y el procedimiento que lo consigue cabe en una línea: cuenta los pares de vecinos, fusiona el más frecuente, repite. Se llama byte-pair encoding (BPE), y de esa línea salen tres cosas que la lección deja demostradas. Ningún texto se queda sin tokens, ni siquiera uno escrito en otro alfabeto. Cada fusión cambia una entrada más del vocabulario por menos tokens en cada texto, y cada vez a peor precio. Y lo que el algoritmo encuentra no son las unidades de la lengua, sino las del corpus.

Empieza por abajo del todo. Un texto guardado en un fichero no está hecho de letras sino de bytes, números entre 0 y 255, y la codificación que usa casi todo el mundo, UTF-8 (Unicode Transformation Format, 8 bits), escribe cada carácter con entre uno y cuatro. Una letra sin tilde ocupa uno; la ñ, dos, el 195 y el 177; un emoji, cuatro. Tomar los 256 bytes como vocabulario de partida resuelve de golpe lo que el curso anterior arreglaba con <UNK>: cualquier texto, en cualquier idioma, es una secuencia de bytes, así que ninguno se queda fuera. El precio es la longitud. Con un token por byte, El señor dijo que la canción era pequeña. son 44 tokens, y dos de ellos son una ñ partida por la mitad.

BPE compra longitud con vocabulario. Recorre el corpus y cuenta cuántas veces aparece cada par de tokens vecinos; convierte el más frecuente en un token nuevo, sustituye por él todas sus apariciones y el vocabulario gana una entrada. Después vuelve a contar, porque los pares han cambiado, y repite tantas veces como entradas quieras. Antes de nada corta el texto en pre-tokens: una palabra con el espacio que la precede, un número, un tramo de signos. Ninguna fusión cruza de un pre-token al siguiente, y por eso los tokens del mini-GPT empiezan a menudo por espacio ( de, que) y ninguno junta el final de una palabra con el principio de otra.

El explorable lo hace delante de ti sobre un corpus de tres frases, lo bastante corto para leerlo entero y comprobar las cuentas: el recuadro del centro enseña, antes de cada paso, los tres pares más frecuentes, con su ff. Avanza fusión a fusión y mira tres cosas. La primera fusión junta los dos bytes de la ñ, que sale diez veces y es el par más frecuente de todos. En la 17 el corpus ya tiene ción, y nadie se lo ha enseñado. Y la frase de abajo, que el corpus no contiene, baja de 42 bytes a 22 tokens, con pequeña y enseñó cortadas en trozos que el corpus sí tenía. Después escribe una frase tuya.

Tres frases y 31 fusiones. Cada paso fusiona el par más frecuente del corpus; la frase de abajo se codifica con las fusiones aprendidas hasta ese paso.

Fíjate en lo que el algoritmo no sabe. No sabe qué es una letra: la ñ sale entera porque sus dos bytes siempre van juntos, no porque formen un carácter, y en la lista del mini-GPT hay tres tokens que cortan un carácter por la mitad. Tampoco sabe qué es un sufijo: ción aparece porque esas letras vienen juntas a menudo, y en ese orden. Todo lo que encuentra lo encuentra contando.

Contar pares y fusionar el más frecuente

Fijemos el corpus como una secuencia de tokens x1,…,xTx_1, \dots, x_T, cortada en pre-tokens, y empecemos con V0={0,…,255}V_0 = \{0, \dots, 255\}: cada token es un byte, y TT es la longitud del corpus en bytes, que llamaremos nn. La frecuencia de un par de tokens (a,b)(a, b) es cuántas veces aparece aa seguido de bb dentro de un mismo pre-token:

f(a,b)=∣{ t  :  xt=a,  xt+1=b,  ambos en el mismo pre-token }∣.f(a, b) = \left\lvert \{\, t \;:\; x_t = a,\; x_{t+1} = b,\; \text{ambos en el mismo pre-token} \,\} \right\rvert.

La fusión ii elige el par más frecuente,

(ai,bi)=arg⁡max⁡(a, b)f(a,b),(a_i, b_i) = \arg\max_{(a,\, b)} f(a, b),

con los empates resueltos a favor del par de ids menores: una regla arbitraria, pero fija, que hace que dos ejecuciones den el mismo vocabulario. Crea una entrada nueva, uiu_i, cuyos bytes son los de aia_i seguidos de los de bib_i, y sustituye cada aparición del par por ella, de izquierda a derecha y sin solaparse. El vocabulario crece de uno en uno:

Vi=Vi−1∪{ui},∣Vm∣=256+m.V_i = V_{i-1} \cup \{u_i\}, \qquad \lvert V_m \rvert = 256 + m.

(En el código, uiu_i es el primer número libre, 255+i255 + i: el código cuenta las fusiones desde cero y lo escribe 256 + i. Y no la escribimos aibia_i b_i porque dos símbolos juntos se leen como un producto.) Lo que queda tras mm fusiones es la lista ordenada de las fusiones, y esa lista es el tokenizador entero: el del mini-GPT son 256 fusiones, guardadas en bpe-merges.json, así que su vocabulario tiene 256+256=512256 + 256 = 512 entradas.

Codificar un texto nuevo es aplicar esa lista, no volver a contar:

codificar(texto, fusiones):
    para cada pre-token w de texto:
        ids ← los bytes de w
        mientras algún par de vecinos de ids esté en fusiones:
            fusiona, en todo ids, el par que aparezca antes en la lista
        añade ids a la salida

Las frecuencias del texto nuevo no intervienen. Si lo hicieran, la misma palabra se cortaría de una manera en un documento y de otra en el siguiente, y el modelo, que aprendió a leer los cortes del corpus, recibiría otros. Decodificar es lo contrario: concatenar los bytes de cada token y leerlos como UTF-8.

Con eso el tokenizador cumple las dos exigencias que el curso anterior le hacía (determinismo y reconstrucción) y la totalidad que allí costaba un <UNK>. Es determinista, porque la lista y la regla de desempate fijan cada corte. Es reversible, porque los pre-tokens cubren el texto sin dejarse ningún carácter y una fusión sólo concatena bytes: decodificar lo codificado devuelve el texto byte a byte. Y es total, porque todo texto es una secuencia de V0⊆VmV_0 \subseteq V_m: en el peor caso, cada byte es su propio token.

Lo que compra cada fusión, y a qué precio

Llamemos TiT_i a la longitud del corpus en tokens después de ii fusiones (el subíndice cuenta fusiones, no posiciones), con T0=nT_0 = n. Cada aparición del par elegido son dos tokens que pasan a ser uno, así que, si ai≠bia_i \neq b_i,

Ti=Ti−1−f(ai,bi).T_i = T_{i-1} - f(a_i, b_i).

Con ai=bia_i = b_i las apariciones pueden solaparse (en ... hay dos pares (., .) y sólo cabe una fusión) y el ahorro se queda por debajo de ff. Salvo en ese caso, BPE es un compresor voraz: en cada paso elige el token nuevo que más acorta el corpus en ese momento.

Y cada fusión compra menos que la anterior. Antes de la fusión ii, ningún par superaba a (ai,bi)(a_i, b_i): por eso lo eligió. Después, un par que no contiene uiu_i sólo puede haber perdido apariciones, porque fusionar borra vecindades entre tokens viejos y no crea ninguna. Un par que sí contiene uiu_i aparece como mucho tantas veces como uiu_i, que son como mucho f(ai,bi)f(a_i, b_i). En los dos casos, el máximo no sube:

f(ai+1,bi+1)≤f(ai,bi).f(a_{i+1}, b_{i+1}) \le f(a_i, b_i).

Es la forma exacta del compromiso entre vocabulario y longitud que planteaba el curso anterior. Con palabras, el vocabulario crecía sin techo a medida que crecía el corpus, como mostraba la ley de Heaps en la lección sobre el vocabulario y las palabras fuera de él. Con BPE, ∣V∣\lvert V \rvert se elige, y lo que se paga por elegirlo pequeño es longitud. La ℓˉ\bar{\ell} de aquel curso vuelve, medida en bytes:

ℓˉ=1T∑t=1T∣xt∣=nT,\bar{\ell} = \frac{1}{T}\sum_{t=1}^{T} \lvert x_t \rvert = \frac{n}{T},

donde ∣xt∣\lvert x_t \rvert son los bytes del token. Sin fusiones, ℓˉ=1\bar{\ell} = 1; cada fusión la sube, y cada vez menos.

Lo que cuesta cada entrada se paga dentro de la red. La tabla que convierte un token en vector tiene una fila por entrada,

∣V∣⋅dmodel=512⋅64=32 768nuˊmeros,\lvert V \rvert \cdot d_{\text{model}} = 512 \cdot 64 = 32\,768 \quad \text{números},

casi la cuarta parte de los 136 448136\,448 parámetros del mini-GPT, y la misma tabla, leída al revés, es la que da sus logits. Lo que compra es alcance. El mini-GPT lee como mucho Tctx=64T_{\text{ctx}} = 64 tokens de una vez (el máximo que admite, no la TT de un texto concreto), y cuánto texto son esos 64 tokens lo decide ℓˉ\bar{\ell}.

Tu BPE, contra el del mini-GPT

Esta lección no llama todavía al mini-GPT: trabaja con su tokenizador, el bpe.py que las celdas del bloque cargan, y la primera celda lo escribe. Su entrenar hace lo de arriba con un atajo: cuenta los pares una sola vez y, tras cada fusión, recuenta sólo los pre-tokens donde estaba el par (los guarda donde), porque en los demás no ha cambiado nada. Salen las mismas fusiones, varias veces más deprisa, y eso es lo que permite entrenar 64 sobre los 302 566302\,566 bytes de Marianela, la novela de Galdós que hace de corpus del bloque, en lo que dura una celda. Ejecútala: imprime algunas fusiones con su ff, y la última línea compara tus 64 con las 64 primeras del mini-GPT.

import re, json, time
from collections import Counter, defaultdict
from pyodide.http import open_url

# Pre-tokens: una palabra con su espacio delante, un número, un tramo de signos, blancos.
PATRON = re.compile(r" ?[^\W\d_]+| ?\d+| ?(?:[^\w\s]|_)+|\s+(?!\S)|\s+")

def pares(ids):
return zip(ids, ids[1:])

def fusionar(ids, par, nuevo):
salida, i = [], 0
while i < len(ids):
if i + 1 < len(ids) and (ids[i], ids[i + 1]) == par:
salida.append(nuevo)
i += 2
else:
salida.append(ids[i])
i += 1
return salida

def entrenar(texto, m, veces=None):
palabras = Counter(PATRON.findall(texto)) # pre-token -> cuántas veces sale
trozos = {w: list(w.encode("utf-8")) for w in palabras}
f, donde = Counter(), defaultdict(set) # f(a, b), y en qué pre-tokens está
for w, n in palabras.items():
for par in pares(trozos[w]):
f[par] += n
donde[par].add(w)
fusiones = []
for i in range(m):
mejor = max(f, key=lambda p: (f[p], -p[0], -p[1]), default=None) # empate: el menor
if mejor is None or f[mejor] <= 0:
break
fusiones.append(mejor)
if veces is not None:
veces.append(f[mejor])
for w in donde.pop(mejor): # sólo cambia donde estaba el par
n, viejo = palabras[w], trozos[w]
trozos[w] = fusionar(viejo, mejor, 256 + i)
for par in pares(viejo):
f[par] -= n
for par in pares(trozos[w]):
f[par] += n
donde[par].add(w)
del f[mejor]
return fusiones

texto = open_url("/courses/llm-agents/corpus.txt").read().split("\n", 1)[1].lstrip()
veces, t0 = [], time.time()
fusiones = entrenar(texto, 64, veces)
print("64 fusiones sobre %d bytes en %.1f s" % (len(texto.encode("utf-8")), time.time() - t0))
vocab = {b: bytes([b]) for b in range(256)}
for i, (a, b) in enumerate(fusiones):
vocab[256 + i] = vocab[a] + vocab[b]
for i in [0, 1, 2, 3, 4, 15, 17, 19, 31, 63]:
a, b = fusiones[i]
print("%2d %r + %r -> %r f = %d" % (i + 1, vocab[a], vocab[b], vocab[256 + i], veces[i]))
print("¿f baja o se queda igual en las 64?", all(x >= y for x, y in zip(veces, veces[1:])))
mini = [tuple(p) for p in json.load(open_url("/courses/llm-agents/bpe-merges.json"))]
print("¿las 64 primeras del mini-GPT, en el mismo orden?", fusiones == mini[:64])

La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador y se reutiliza en todas las lecciones.

Las primeras fusiones son un espacio con la letra que lo sigue ( d, e, l), porque el espacio es el carácter más frecuente del corpus y va pegado al principio de cada palabra; la quinta ya es de, la palabra que más se repite. La 18 es la í, que la celda imprime como sus dos bytes, b'\xc3\xad', y la 32 es la raya de los diálogos, que ocupa tres. ff no sube ni una vez, de 4 6524\,652 a 628628, como pide la desigualdad de arriba, y la celda lo comprueba en las 64. La 20, (., .), es la única de las 64 con ai=bia_i = b_i, y por tanto la única en la que el corpus puede encoger menos que ff. Y la última línea dice que tu código produce las fusiones del modelo: las mismas, en el mismo orden.

La segunda celda carga bpe.py tal cual, con las 256 fusiones del mini-GPT, codifica tres frases y comprueba en cada una que decodificar devuelve el texto exacto. Después mide el corpus entero con 0, 64 y las 256 fusiones.

import json
from pyodide.http import open_url

exec(open_url("/courses/llm-agents/bpe.py").read()) # tu entrenar, y codificar / decodificar
F = [tuple(p) for p in json.load(open_url("/courses/llm-agents/bpe-merges.json"))]
V = vocabulario(F)

def ver(frase):
ids = codificar(frase, F)
assert decodificar(ids, F) == frase # vuelve entera, byte a byte
print(len(frase.encode("utf-8")), "bytes ->", len(ids), "tokens:",
[V[i].decode("utf-8", "replace") for i in ids])

ver("El señor dijo que la canción era pequeña.")
ver(" criptomoneda")
ver("¿Qué? 🙂")
print("ñ =", list("ñ".encode("utf-8")), "-> fusión", F.index((195, 177)) + 1)

texto = quitar_cabecera(open_url("/courses/llm-agents/corpus.txt").read())
n = len(texto.encode("utf-8"))
for m in [0, 64, 256]:
T = len(codificar(texto, F[:m]))
print("m = %3d |V| = %d T = %6d l = %.2f bytes por token" % (m, 256 + m, T, n / T))

La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador y se reutiliza en todas las lecciones.

En la primera frase, señor es un solo token; la ñ de pequeña es otro (la fusión 71, que la celda localiza) y ción va entera. criptomoneda, que en el curso anterior acababa en <UNK>, sale en ocho trozos. La tercera frase enseña qué hace BPE con lo que no tiene fusionado: el emoji son sus cuatro bytes, cuatro tokens que por separado no son texto y se imprimen como �, y a la ¿ del principio le pasa lo mismo, porque el mini-GPT sólo aprendió a fusionarla con un espacio o una raya delante. Aun así, ver no ha protestado: decodificar lo devolvió todo.

Y la tabla es el compromiso, medido. Las 64 primeras fusiones le quitan al corpus 112 726112\,726 tokens; las 192 siguientes, el triple de fusiones, sólo 50 07550\,075. ℓˉ\bar{\ell} pasa de 1.00 a 1.59 con las primeras y, a duras penas, a 2.16 con el resto.

Comprueba tu intuición

Cuatro preguntas y un reto: los bytes de la ñ, por qué que llega antes que qu, qué garantiza el algoritmo y cuánto texto le cabe al mini-GPT de una vez. El reto te pide escribir codificar.

Un tokenizador que parte de bytes ve esto antes de la primera fusión. ¿Qué imprime?

palabra = "año"
print(len(palabra), len(palabra.encode("utf-8")), list("ñ".encode("utf-8")))
 

En la lista del mini-GPT, que es la fusión 16 y qu la 65, aunque en español la q siempre va seguida de u. ¿Por qué llega antes el trozo más largo?

Marca lo que es cierto de BPE a nivel de byte tal como lo define esta lección.

Marca todas las opciones correctas. Se corrige todo o nada: no hay puntuación parcial.

El mini-GPT lee como mucho Tctx=64T_{\text{ctx}} = 64 tokens a la vez, y sobre su corpus un token lleva de media ℓˉ=2.16\bar{\ell} = 2.16 bytes. ¿Cuántos bytes de texto caben, de media, en esos 64 tokens? Redondea al entero.

bytes

Se acepta un margen de ±2.

Escribe codificar(texto, fusiones), la mitad del tokenizador que la segunda celda cargó hecha. Corta texto en pre-tokens con PATRON; convierte cada uno en sus bytes UTF-8; y, dentro de cada pre-token, mientras algún par de vecinos esté en fusiones, fusiona el que aparezca antes en la lista (la fusión i, contando desde cero, crea el token 256 + i). Devuelve los ids de todos los pre-tokens, uno detrás de otro.

PATRON, pares y fusionar ya están escritos: son los de la primera celda.

La primera comprobación descarga el intérprete de Python (~15 MB); después queda en la caché del navegador. Este desafío se resuelve mejor con un teclado físico: en el móvil puedes leerlo y volver luego.


El tokenizador está terminado y no volverá a cambiar en todo el curso: cada texto que le llegue al mini-GPT pasará antes por estas 256 fusiones. El corpus del bloque es ya una secuencia de 139 765139\,765 ids entre 0 y 511, y la pérdida de la lección sobre el modelo de lenguaje causal tiene por fin sus números de verdad. Le falta lo principal: la red que apuesta, y unos pesos que apuesten bien.

Esa red es la lección siguiente, sobre entrenar un mini-GPT en NumPy: cortar esa secuencia en ventanas, agruparlas en batches, cargar los pesos del mini-GPT y seguir entrenándolo delante de ti, con la pérdida bajando paso a paso sobre texto que el modelo no ha visto.

¿Te ha sido útil?
Para profundizar3 fuentes · 2 papers, 1 vídeo

De dónde sale lo de esta lección, y dónde seguir si quieres más. Nada de aquí hace falta para continuar el curso.

  • Neural Machine Translation of Rare Words with Subword Units
    paperSennrich, Haddow y Birch, 2016ACL 2016EN

    El artículo que convirtió BPE, un algoritmo de compresión de 1994, en un tokenizador. Su algoritmo 1 es el entrenamiento de esta lección en unas líneas de Python, sobre caracteres en lugar de bytes.

  • Language Models are Unsupervised Multitask Learners
    paperRadford, Wu, Child, Luan, Amodei y Sutskever, 2019Informe técnico de OpenAI (GPT-2)EN

    Su sección 2.2 explica por qué BPE sobre bytes y no sobre caracteres Unicode, y por qué impedir fusiones que mezclen letras con signos: el origen de los pre-tokens del mini-GPT.

  • Let's build the GPT Tokenizer
    vídeoAndrej Karpathy, 2024YouTubeEN

    Dos horas escribiendo un BPE a nivel de byte desde cero, con la expresión regular de GPT-2 y un repaso de lo que sale mal alrededor de los tokens. El recorrido de esta lección, más largo.