Crea un tokenizador BPE: por qué tu modelo no sabe contar las r
Entrena un codificador BPE en 60 líneas, mira cómo descubre «the» solo y mide por qué un párrafo cuesta un 39 % más en español.
En esta página
Pídele a un modelo capaz de aprobar un examen de acceso a la abogacía que cuente cuántas letras r hay en strawberry, y hay bastantes posibilidades de que diga dos.
La explicación habitual es que los modelos de lenguaje son «malos contando» o «no entienden de verdad». Ambas son imposibles de refutar y ninguna es la razón. La razón es mecánica, ocurre antes de que el modelo se ejecute, y puedes verla en una sola línea:
'strawberry' -> 3 tokens [496, 675, 15717] ['str', 'aw', 'berry']El modelo no está mirando diez letras. Está mirando tres números. Para contar las r, tendría que saber, solo a partir de la identidad del token 496, cuántas r hay dentro de una cadena que no puede ver; y después hacer lo mismo con 675 y 15717 y sumarlas. Se le está haciendo una pregunta sobre una representación a la que no tiene acceso.
Este capítulo construye lo que produce esos tres números. Lleva unas sesenta líneas, es el mismo algoritmo que usa cualquier gran modelo, y cuando lo hayas escrito, una docena de rarezas que parecen no tener relación se reducen a una sola causa.
Por qué no letras, y por qué no palabras
Enlace a la sección: Por qué no letras, y por qué no palabrasHay dos formas obvias de introducir texto en una red, y ambas fallan por motivos que merece la pena entender, porque ese fallo define la forma de la solución.
Palabras. Dividir por espacios y asignar un número a cada palabra. El inglés tiene cientos de miles de formas de palabras y el modelo necesita una fila de embedding para cada una, así que el vocabulario —y la capa de salida, que debe producir una puntuación para cada entrada— se vuelve enorme. Peor aún es lo que ocurre en inferencia: una palabra que el modelo nunca vio durante el entrenamiento no tiene número. Ese es el problema de fuera de vocabulario, y el parche habitual es mapear todo lo desconocido a un único token <UNK>, lo que tira la información a la basura. Además, «palabra» no es un concepto bien definido: el chino y el japonés no ponen espacios entre palabras, y el alemán compone sustantivos unos sobre otros indefinidamente.
Caracteres. No hay problema de fuera de vocabulario, y el vocabulario tiene apenas un centenar de símbolos. Pero las secuencias se vuelven muy largas, y el capítulo 9 mostrará que el coste de attention crece cuadráticamente con la longitud de la secuencia. Un documento de 1000 palabras ronda los 5000 caracteres: una secuencia cuatro o cinco veces más larga de lo necesario, con un precio cuadrático. Y cada carácter apenas lleva significado por sí solo, así que las primeras capas se gastan en recomponer palabras que el tokenizador podría haber entregado intactas.
La respuesta está entre medias: subpalabras. Las palabras comunes se convierten en un token, las raras se dividen en piezas, y nada es jamás desconocido porque las piezas acaban reduciéndose a bytes individuales. Lo interesante es que nadie diseña la división. El tokenizador se entrena, con el mismo tipo de datos que el modelo, y aprende qué secuencias de bytes merecen su propio número contando con qué frecuencia aparecen juntas.
Codificación por pares de bytes
Enlace a la sección: Codificación por pares de bytesEl algoritmo es de 1994, y era un algoritmo de compresión. Philip Gage lo publicó en el C Users Journal como una forma de reducir archivos sustituyendo repetidamente el par más frecuente de bytes adyacentes por un byte que no apareciera en los datos.1 Se quedó ahí durante veintidós años, hasta que Sennrich, Haddow y Birch lo reutilizaron para traducción automática en 2016 con el fin de resolver el problema de fuera de vocabulario.2 Ahora es, en esencia, la forma en que leen todos los grandes modelos de lenguaje.
El bucle de entrenamiento repite cuatro pasos:
Empezar desde bytes
Enlace a la sección: Empezar desde bytesCodifica el texto de entrenamiento como UTF-8. Cada valor de byte 0–255 es un token. Tamaño del vocabulario: 256.
Contar pares adyacentes
Enlace a la sección: Contar pares adyacentesRecorre la secuencia y cuenta con qué frecuencia aparece cada par de tokens vecinos.
Fusionar el par más frecuente
Enlace a la sección: Fusionar el par más frecuenteToma el ganador, acuña un nuevo id de token para él y sustituye cada aparición en la secuencia. El vocabulario crece en uno; la secuencia se acorta.
Registrar la fusión y repetir
Enlace a la sección: Registrar la fusión y repetirGuarda el par y el id en el que se convirtió, en orden. Esa lista ordenada es el tokenizador: es todo lo necesario para codificar texto nuevo más adelante.
Aquí tienes el entrenador completo:
def get_stats(ids):
counts = {}
for a, b in zip(ids, ids[1:]):
counts[(a, b)] = counts.get((a, b), 0) + 1
return counts
def merge(ids, pair, 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
class BPE:
def __init__(self):
self.merges = {}
self.vocab = {i: bytes([i]) for i in range(256)}
def train(self, text, vocab_size):
ids = list(text.encode("utf-8"))
for i in range(vocab_size - 256):
stats = get_stats(ids)
if not stats:
break
pair = max(stats, key=stats.get)
idx = 256 + i
ids = merge(ids, pair, idx)
self.merges[pair] = idx
self.vocab[idx] = self.vocab[pair[0]] + self.vocab[pair[1]]
return idsVer nacer las fusiones
Enlace a la sección: Ver nacer las fusionesEjecútalo sobre 151.191 bytes de prosa inglesa e imprime las doce primeras fusiones a medida que ocurren. Esta es la parte que conviene leer despacio, porque nadie le dijo nada sobre inglés al algoritmo:
merge 1: b'e' + b' ' -> b'e ' (occurred 4433 times)
merge 2: b' ' + b't' -> b' t' (occurred 3302 times)
merge 3: b'\xe2' + b'\x80' -> b'\xe2\x80' (occurred 3247 times)
merge 4: b' ' + b'a' -> b' a' (occurred 2335 times)
merge 5: b' t' + b'h' -> b' th' (occurred 2253 times)
merge 6: b'i' + b'n' -> b'in' (occurred 2011 times)
merge 7: b't' + b' ' -> b't ' (occurred 1904 times)
merge 8: b'e' + b'r' -> b'er' (occurred 1813 times)
merge 9: b'd' + b' ' -> b'd ' (occurred 1703 times)
merge 10: b'o' + b'u' -> b'ou' (occurred 1554 times)
merge 11: b' ' + b's' -> b' s' (occurred 1467 times)
merge 12: b' th' + b'e '-> b' the ' (occurred 1270 times)Hay tres cosas en esa lista que merece la pena señalar.
La fusión 12 es la palabra «the»: con el espacio anterior y el espacio posterior, como una sola unidad, descubierta en la duodécima iteración de un bucle que cuenta pares. Nadie proporcionó un diccionario. Está ahí porque esos cinco bytes coaparecen más que otros cinco cualesquiera en inglés.
La fusión 3 ni siquiera es texto. \xe2\x80 son los dos primeros bytes de la codificación UTF-8 de la puntuación tipográfica: la raya, las comillas curvas. El algoritmo no tiene ni idea de que UTF-8 existe, y acaba de redescubrir una parte de su estructura, porque las codificaciones multibyte son, por construcción, secuencias de bytes que siempre aparecen juntas.
La mayoría de las primeras fusiones incluyen un espacio, y normalmente el espacio está a la izquierda. Ese es el origen de uno de los comportamientos más confusos en la práctica, al que volveremos enseguida.
La compensación del tamaño de vocabulario
Enlace a la sección: La compensación del tamaño de vocabularioCada fusión hace la secuencia más corta y el vocabulario más grande. Hasta dónde llevarlo es una decisión real, y se puede medir; aquí, sobre los mismos 151.191 bytes:
| tamaño de vocabulario | tokens resultantes | compresión (bytes por token) |
|---|---|---|
| 300 | 101.065 | 1,50 |
| 512 | 68.249 | 2,22 |
| 1024 | 50.369 | 3,00 |
| 2048 | 39.306 | 3,85 |
| 4096 | 30.757 | 4,92 |
Rendimientos decrecientes, de forma visible. Duplicar de 512 a 1024 compra 0,78 bytes por token; duplicar de 2048 a 4096 compra 1,07 —mejor aquí solo porque este corpus es lo bastante pequeño como para que las fusiones largas sigan compensando. En un corpus real, la curva se aplana de golpe.
Y el coste de un vocabulario mayor no es solo memoria. Cada token necesita una fila de embedding y —más caro aún— la capa de salida del modelo tiene que producir una puntuación para cada entrada del vocabulario en cada paso, así que la multiplicación matricial final escala con el tamaño del vocabulario. Los modelos reales se sitúan entre 32.000 y 200.000: GPT-2 usaba 50.257, cl100k de GPT-4 usa 100.277, o200k de GPT-4o aproximadamente duplica esa cifra. La tendencia es al alza, y el motivo está en la siguiente sección.
La factura, por idioma
Enlace a la sección: La factura, por idiomaAquí está el mismo párrafo, traducido, medido con los tokenizadores reales que distribuye OpenAI:
| idioma | caracteres | tokens (cl100k) | tokens (o200k) | tokens/car. | sobrecoste frente al inglés |
|---|---|---|---|---|---|
| Inglés | 164 | 31 | 31 | 0,189 | — |
| Español | 169 | 43 | 36 | 0,254 | +39 % |
| Ruso | 178 | 78 | 43 | 0,438 | +152 % |
| Japonés | 72 | 79 | 58 | 1,097 | +155 % |
El mismo contenido, el mismo significado, y con cl100k la versión rusa consume dos veces y media los tokens. Como las API facturan por token y las context window se miden en tokens, eso no es una curiosidad lingüística: es una línea en un presupuesto, una context window efectiva más corta y una respuesta más lenta, las tres cosas a la vez, para cualquiera que no trabaje en inglés.
El mecanismo son los datos de entrenamiento. Un tokenizador entrenado mayoritariamente con inglés gasta su presupuesto de fusiones en secuencias de bytes inglesas. El español comparte el alfabeto latino, así que aún obtiene parte del beneficio; el ruso casi ninguno, porque los caracteres cirílicos ocupan dos bytes en UTF-8 y pocos de esos pares fueron lo bastante comunes en el corpus de entrenamiento como para ganarse una fusión. El japonés es aún peor: tres bytes por carácter, y 72 caracteres se convierten en 79 tokens: más tokens que caracteres.
La columna o200k muestra que esto es un problema resoluble y que se está resolviendo. Duplicar el vocabulario y reequilibrar los datos de entrenamiento reduce el sobrecoste del español de +39 % a +16 %, y el del ruso de +152 % a +39 %. Esa es la verdadera razón por la que los vocabularios siguen creciendo: no compresión por la compresión en sí, sino el hecho de que la generación anterior estaba cobrando de tapadillo un extra a una gran parte del mundo.
Codificación, y por qué importa el orden de las fusiones
Enlace a la sección: Codificación, y por qué importa el orden de las fusionesEl entrenamiento produjo una lista ordenada de fusiones. Codificar texto nuevo la reproduce, y debe reproducirla en el mismo orden, porque la fusión 12 combina los resultados de las fusiones 5 y 1. Aplícalas en otro orden y obtendrás una tokenización distinta e incorrecta, que no coincidirá con nada de lo que el modelo vio durante el entrenamiento.
def encode(self, text):
ids = list(text.encode("utf-8"))
while len(ids) >= 2:
stats = get_stats(ids)
# the pair whose merge came FIRST during training wins
pair = min(stats, key=lambda p: self.merges.get(p, float("inf")))
if pair not in self.merges:
break
ids = merge(ids, pair, self.merges[pair])
return ids
def decode(self, ids):
return b"".join(self.vocab[i] for i in ids).decode("utf-8", errors="replace")Decodificar es trivial en comparación: busca los bytes de cada id, concatena y decodifica como UTF-8. Fíjate en el errors="replace": un modelo puede emitir una secuencia de tokens que termina a mitad de carácter, y no es una hipótesis; es lo que ocurre cuando una respuesta en streaming se corta en mitad de un emoji, por eso las API de streaming almacenan en búfer los bytes parciales en lugar de decodificar token por token.
El viaje de ida y vuelta funciona con cualquier cosa, que es la promesa del BPE a nivel de byte:
'strawberry' -> 6 tokens, decode == original: True
'Alice was beginning to get very tired' -> 14 tokens, decode == original: True
'café — naïve — 日本語' -> 23 tokens, decode == original: TrueTodo lo demás que en realidad es esto
Enlace a la sección: Todo lo demás que en realidad es estoUna vez que el mecanismo está claro, un conjunto de quejas que parecen no tener relación resultan ser la misma queja.
Aritmética. Los números no se dividen de forma coherente:
1234 -> 2 tokens ['123', '4']
12345 -> 2 tokens ['123', '45']
1000000 -> 3 tokens ['100', '000', '0']
3.14159 -> 4 tokens ['3', '.', '141', '59']
2024 -> 2 tokens ['202', '4']Para sumar 1234 y 12345, el modelo primero debe averiguar que ['123','4'] y ['123','45'] son números cuyos dígitos se alinean de una forma concreta, y la alineación cambia para cada par de números. Los dígitos de un número no están en los mismos lugares de un número al siguiente. Algunos tokenizadores más recientes fuerzan que los dígitos se dividan en grupos coherentes de tres precisamente para eliminar este obstáculo, y los modelos entrenados con ellos son mediblemente mejores en aritmética.
Indentación en Python.
' x = 1' -> 5 tokens [' ', ' x', ' =', ' ', '1']
' x = 1' -> 5 tokens [' ', ' x', ' =', ' ', '1']
'\tx = 1' -> 4 tokens ['\tx', ' =', ' ', '1']Cuatro espacios y ocho espacios son tokens únicos distintos, y una tabulación se fusiona con el carácter posterior. La indentación, que en Python es sintaxis, se representa de forma incoherente; eso explica en gran parte por qué los modelos solían producir Python con indentaciones sutilmente incorrectas, y por qué los tokenizadores centrados en código añaden tokens explícitos para secuencias de indentación comunes.
Ortografía e inversión. La misma causa que al contar las r: pedirle a un modelo que invierta strawberry es pedirle que reordene letras dentro de tres ids opacos. Los modelos lo hacen porque han memorizado grafías durante el entrenamiento, no porque estén mirando, y por eso lo hacen bien con palabras comunes y mal con palabras raras.
Glitch tokens. El caso más llamativo es SolidGoldMagikarp y un conjunto de cadenas similares que hacían que GPT-2 y GPT-3 se comportaran de forma extraña: se negaban a repetirlas, producían salidas sin relación, a veces insultaban al usuario. La explicación es mundana y se deduce directamente de que el tokenizador se entrena por separado del modelo: esas cadenas eran frecuentes en el corpus de entrenamiento del tokenizador (eran nombres de usuario de Reddit), así que consiguieron su propio token, pero eran raras o no aparecían en el corpus de entrenamiento del modelo. El resultado es una fila de embedding que se inicializó aleatoriamente y casi nunca se actualizó. El modelo tiene un símbolo que, en la práctica, casi nunca ha visto, y su comportamiento ahí es el que dictara la inicialización aleatoria.
WordPiece, usado por BERT, difiere de BPE en la regla de selección: en lugar de fusionar el par más frecuente, fusiona el par que más aumenta la verosimilitud de los datos de entrenamiento, lo que normaliza según lo comunes que sean ya las partes; así, un par de dos piezas raras puede ganar a un par de dos piezas comunes.
Unigram, de Kudo, funciona al revés: empieza con un gran vocabulario candidato y elimina iterativamente las piezas cuya supresión perjudica menos la verosimilitud del corpus. También asigna una probabilidad a cada segmentación, lo que permite muestrear distintas tokenizaciones de la misma cadena como regularizador.
SentencePiece es la implementación que usan la mayoría de los modelos no ingleses. Su aportación consiste en tratar la entrada como un flujo crudo sin pretokenización alguna, codificando el espacio como un carácter visible, lo que significa que funciona igual para idiomas que no separan palabras con espacios. Por debajo puede ejecutar BPE o Unigram.
Lo que cuesta y lo que compra
Enlace a la sección: Lo que cuesta y lo que compraUn tokenizador es una interfaz con pérdida entre texto y números, y cada comportamiento extraño de este capítulo es la interfaz asomando. Conviene dejar claro que el intercambio es deliberado: el BPE a nivel de byte significa que ninguna entrada queda nunca sin representar, las secuencias son cuatro o cinco veces más cortas de lo que serían con caracteres, y las palabras comunes llegan intactas.
El precio es que los átomos del modelo no son nuestros átomos. Razona sobre texto que no puede deletrear, en unidades elegidas por un recuento de frecuencias sobre un corpus que él no vio, con un coste por idioma que nadie negoció.
Adónde va esto ahora
Enlace a la sección: Adónde va esto ahoraAhora tienes una secuencia de enteros. Ese es el formato de entrada para todo lo que queda en la Parte II.
Lo que no tienes es ningún motivo para que un entero siga a otro. El siguiente capítulo introduce el objetivo con el que se entrena todo modelo de lenguaje, y es sorprendentemente simple: dados los tokens hasta ahora, predecir el siguiente. Ese único objetivo —sin etiquetas, sin anotación, solo texto con su propio futuro como objetivo— es lo que convierte internet entero en datos de entrenamiento, y de ahí salen las primeras representaciones genuinas del modelo.
También exige que la regla de la cadena de la probabilidad del capítulo 2 sea exactamente correcta, porque afirmar que predecir un token cada vez es lo mismo que modelar documentos completos es una factorización, no una metáfora.
El capítulo 8 trata del objetivo autorregresivo, los embedding y el primer lugar donde un modelo aprende algo que nadie puso ahí.
Fuentes y método
Enlace a la sección: Fuentes y métodoKudo, T. Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates (arXiv:1804.10959) introduce el modelo Unigram; Kudo and Richardson, SentencePiece: A simple and language independent subword tokenizer and detokenizer for Neural Text Processing (arXiv:1808.06226) es la implementación que usan la mayoría de los modelos multilingües; Schuster and Nakajima, Japanese and Korean Voice Search (ICASSP 2012) es el origen de WordPiece. Let's build the GPT Tokenizer, de Andrej Karpathy, y el repositorio karpathy/minbpe que lo acompaña son los antepasados directos del código de este capítulo y van bastante más allá, incluida la regex de GPT-4 y el manejo de special tokens. El capítulo 6 del Hugging Face LLM Course cubre los tres algoritmos uno al lado del otro con ejemplos desarrollados.
Referencias
Enlace a la sección: Referencias-
Gage, P. A New Algorithm for Data Compression. The C Users Journal 12(2), pp. 23–38 (1994). Codificación por pares de bytes como esquema de compresión, veintidós años antes de que nadie la usara para modelos de lenguaje. ↩
-
Sennrich, R., Haddow, B. and Birch, A. Neural Machine Translation of Rare Words with Subword Units. arXiv:1508.07909 (2015; ACL 2016). El artículo que llevó BPE al NLP, motivado por las palabras fuera de vocabulario en traducción. ↩
-
Radford, A., Wu, J., Child, R., Luan, D., Amodei, D. and Sutskever, I. Language Models are Unsupervised Multitask Learners (2019). La sección 2.2 introduce BPE a nivel de byte con la regex de pretokenización comentada arriba. ↩