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 . 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.
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 , cortada en pre-tokens, y empecemos con : cada token es un byte, y es la longitud del corpus en bytes, que llamaremos . La frecuencia de un par de tokens es cuántas veces aparece seguido de dentro de un mismo pre-token:
La fusión elige el par más frecuente,
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, , cuyos bytes son los de seguidos de los de , y sustituye cada aparición del par por ella, de izquierda a derecha y sin solaparse. El vocabulario crece de uno en uno:
(En el código, es el primer número libre, : el código cuenta las fusiones desde
cero y lo escribe 256 + i. Y no la escribimos porque dos símbolos juntos se leen como un
producto.) Lo que queda tras
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
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 salidaLas 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 : en el peor caso, cada byte es su propio token.
Lo que compra cada fusión, y a qué precio
Llamemos a la longitud del corpus en tokens después de fusiones (el subíndice cuenta fusiones, no posiciones), con . Cada aparición del par elegido son dos tokens que pasan a ser uno, así que, si ,
Con las apariciones pueden solaparse (en ... hay dos pares (., .) y sólo cabe una fusión) y el ahorro se queda por debajo de . 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 , ningún par superaba a : por eso lo eligió. Después, un par que no contiene sólo puede haber perdido apariciones, porque fusionar borra vecindades entre tokens viejos y no crea ninguna. Un par que sí contiene aparece como mucho tantas veces como , que son como mucho . En los dos casos, el máximo no sube:
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, se elige, y lo que se paga por elegirlo pequeño es longitud. La de aquel curso vuelve, medida en bytes:
donde son los bytes del token. Sin fusiones, ; 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,
casi la cuarta parte de los 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 tokens de una vez (el máximo que admite, no la de un texto concreto), y cuánto texto son esos 64 tokens lo decide .
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 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 , y la última línea compara tus 64 con las 64 primeras del
mini-GPT.
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. no sube ni una vez, de a , como pide la desigualdad de
arriba, y la celda lo comprueba en las 64. La 20, (., .), es la única de las 64 con , y por
tanto la única en la que el corpus puede encoger menos que . 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.
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 tokens; las 192 siguientes, el triple de fusiones, sólo . 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 tokens a la vez, y sobre su corpus un token lleva de media bytes. ¿Cuántos bytes de texto caben, de media, en esos 64 tokens? Redondea al entero.
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 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.
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
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
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
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.