Trust, but Trace
EN ES

Fundamentos de ML

Un modelo no lee texto: lo que el tokenizador decide por ti

El tokenizador parece ir delante del modelo, pero va dentro. Leo en el BPE de tiktoken qué fija antes del primer peso: vocabulario, coste y límite de contexto.

Un modelo de lenguaje no ha visto nunca una letra.

Solo ve enteros. En algún punto entre tu cadena y la primera multiplicación de matrices hay una función que convierte el texto en una lista de ids, y esa función quedó congelada el día en que se entrenó el modelo. Aunque parezca preprocesamiento, forma parte del artefacto. Cámbiala y los pesos seguirán ahí, pero el modelo que tenías habrá desaparecido.

Vengo de los compiladores, donde el parser es tuyo y puedes cambiarlo un martes cualquiera. Aquí solo es tuyo una vez, antes del entrenamiento, y ya nunca más.

Con este post empieza una serie para ingenieros que dan el salto a la ingeniería de ML desde el software o desde la ciencia de datos. Cada parte se ocupa de una cosa que no se comporta como en el software. En esta le toca al parser, que va dentro del modelo.

Un lexer con otro objetivo

Si has escrito un compilador, ya has escrito un tokenizador. El lexer recibe caracteres y emite un flujo de símbolos (identificadores, palabras clave, literales). Su objetivo es reconocer la estructura para que el parser pueda construir un árbol.

El tokenizador de un modelo es igual por fuera, pero persigue otra cosa. Aquí no hay estructura que reconocer. Se trata de comprimir, de convertir una cadena en el menor número posible de ids de un vocabulario fijo, para que el modelo vea menos posiciones y el vocabulario siga siendo lo bastante pequeño como para dar a cada id su fila en la matriz de embeddings.

Ninguno de los dos extremos funciona. Con caracteres, el vocabulario es minúsculo y las secuencias salen demasiado largas; cada posición se paga en atención, cuyo coste crece con el cuadrado de la longitud. Con palabras enteras, las secuencias son cortas, pero el vocabulario queda sin acotar y, encima, deja un agujero por cada palabra que no esté en él. A ese agujero el científico de datos lo llama «fuera de vocabulario» y el ingeniero de software, «caso no contemplado».

El término medio son las subpalabras. Sennrich, Haddow y Birch (2016) cogieron el byte-pair encoding, un algoritmo de compresión de 1994, y lo aplicaron a este otro problema para aprender las unidades de subpalabra a partir del corpus, sin tener que declararlas de antemano.

BPE es un solo bucle

He leído el _educational.py de tiktoken. Su codificador es una versión legible del núcleo en Rust, cosa que he comprobado ejecutando los dos sobre todas las muestras de más abajo y comparando los ids. El entrenador, en cambio, no imita nada. El núcleo en Rust no trae entrenador, así que bpe_train (_educational.py:119-185) es la única versión del bucle que hay en el paquete. Todo el entrenamiento es un solo bucle, y este es el cuerpo de bpe_train:

while len(ranks) < vocab_size:
stats = collections.Counter()
for piece in words:
for pair in zip(piece[:-1], piece[1:]):
stats[pair] += 1
most_common_pair = max(stats, key=lambda x: stats[x])
token_bytes = most_common_pair[0] + most_common_pair[1]
token = len(ranks)
# Add the new token!
ranks[token_bytes] = token
# ... then replace that pair through the corpus, and loop

Empiezas con 256 tokens, uno por byte, y cuentas todos los pares adyacentes del corpus. Coges el más frecuente, le das el siguiente id y lo sustituyes en todo el corpus, de izquierda a derecha, y así hasta que el vocabulario tenga el tamaño que has pedido.

El vocabulario es la lista ordenada de fusiones, y en ese orden está todo el modelo del idioma. Codificar consiste en reproducir ese orden: partes la entrada en bytes, buscas el par adyacente con el rango de fusión más bajo, lo fusionas y repites hasta que no quede nada que fusionar. bpe_encode (_educational.py:83-116) ocupa treinta y cuatro líneas y no recorre nunca la lista de fusiones. El entrenamiento se queda con el par más frecuente y la codificación con el de rango más bajo, que viene a ser lo mismo.

El bucle de entrenamiento avanza un paso por clic: cuenta los pares adyacentes, fusiona el más frecuente y repite. El vocabulario es la lista de fusiones en orden. El símbolo ␣ es un espacio que va pegado a la palabra siguiente.
siguiente: l + o aparece 6×
low ␣lower ␣lowest ␣low ␣newer ␣newest ␣new ␣wider ␣widest ␣low ␣lower ␣new
fusiones, en orden
ninguna todavía
vocabulario256 + 0
tokens en el corpus64

La demo ejecuta ese bucle sobre un corpus de juguete, una fusión por clic. Como doce de sus dieciocho pasos son empates, la regla de desempate (gana el primer par que aparece) pesa mucho.

Al ingeniero le importan dos detalles. El primero es que BPE trabaja con bytes y no con caracteres, de ahí que cualquier entrada se pueda codificar y que no exista el token desconocido. Un carácter que las fusiones no han visto nunca cuesta hasta un token por cada byte UTF-8, pero no da error. Con GPT-2, ß es un token porque sus dos bytes se fusionaron durante el entrenamiento, 漢 son tres, uno por byte, y una letra del alfabeto gótico son cuatro. Los vocabularios más recientes han recortado parte de ese sobrecoste (o200k_base ya tiene 漢 entero), aunque la letra del alfabeto gótico sigue costando cuatro tokens en todos ellos. El segundo detalle es que, antes de que entre en juego BPE, una expresión regular parte el texto en fragmentos, y las fusiones nunca pasan de un fragmento a otro. El patrón de GPT-2 deja el espacio inicial pegado a la palabra que lo sigue, de modo que " token" y "token" son tokens distintos. Por eso el modelo no trata igual el principio de una línea que el medio, y por eso un espacio de más en una plantilla de prompt cambia la salida.

El vocabulario es el corpus de entrenamiento

El vocabulario de GPT-2 tiene 50 257 entradas. Si la cifra no es redonda es porque se trata de una suma: 256 tokens de byte, 50 000 fusiones aprendidas y un token especial, <|endoftext|>, con el id 50256. Radford et al. (2019) describen el diseño basado en bytes del que salen esas cifras. Las dos codificaciones más recientes de OpenAI que he comprobado, cl100k_base y o200k_base, tienen 100 261 y 200 000 tokens repartidos en rangos de 100 277 y 200 019 ids (el n_vocab de tiktoken), y los huecos son ids sin usar.

En las fusiones queda codificado el corpus de entrenamiento. Las palabras inglesas le salen baratas a GPT-2, que se entrenó sobre todo con texto web en inglés. " tokenizer" son dos tokens, mientras que el mismo concepto en español, " tokenizador", son tres, y " desvanecimiento", seis. Una palabra alemana con diéresis, " Wörter", ocupa tres, y en uno de ellos los dos bytes de la ö van soldados a la r que la sigue. El tokenizador no tiene noción de carácter, solo de bytes y de frecuencias.

Los números son el caso que pilla por sorpresa a los ingenieros. GPT-2 tokeniza 1234567 como 123, 45, 67 porque es lo que sale, sin más, de los rangos de fusión. cl100k_base cambió la expresión regular para cortar las secuencias de dígitos en tramos de tres como máximo. Eso acota el daño sin arreglarlo. Los tramos se forman de izquierda a derecha, así que 1000 acaba partido en 100 y 0. Es una decisión del tokenizador con un efecto directo en lo que el modelo puede calcular, y se tomó en una expresión regular.

Para ser justos con GPT-2, sus fusiones se aprendieron solo a partir de la frecuencia, y en el texto web 1000 es tan común que 00, luego 000 y luego 1000 fueron ganando por turnos el recuento de pares. Una tabla de fusiones que respete la frecuencia se lo tragará entero. Nadie decidió romper la aritmética. El coste solo salió a la luz cuando la gente empezó a pedir a estos modelos que sumaran.

El mismo párrafo cuesta más en español

He escrito un párrafo de 184 bytes en inglés y en español, y he montado un payload JSON de más o menos el mismo tamaño, 182 bytes. Luego he codificado los tres con cada uno de los tres tokenizadores.

Con GPT-2, el español cuesta en torno a 1,7× lo que el inglés, y con la codificación más reciente sigue en 1,2×. El JSON cuesta alrededor de 2,3× con las tres codificaciones, porque las llaves, las comillas y las claves no se comprimen. Ninguna de las 26 comillas de ese payload aparece sola como token. Cada una va soldada a la llave, el corchete, los dos puntos, la coma o el espacio que tiene al lado, en tokens como ", ":, ",, {" y "},. Son veintiséis comillas repartidas en veintiséis tokens, una en cada uno.

Un párrafo escrito en dos idiomas y un payload JSON de igual tamaño, codificados con tres tokenizadores. Cambia la muestra y la codificación: los mismos bytes salen a otro precio. Cada bloque de color es un id que ve el modelo, y el espacio inicial forma parte del token.
muestra
codificación
The tokenizer is the first design decision in the system and the one nobody reviews. It fixes the vocabulary, the cost per language and the context limit before a single weight exists.
tokens36
bytes / token5,1
frente al inglés×1,00
Inglés 36
Español 60
JSON 82

Si cambias la muestra y la codificación, verás que los vocabularios más recientes eliminan la mayor parte de la diferencia del español, mientras que la del JSON no se mueve.

De ahí salen tres consecuencias, todas de facturación o de capacidad y ninguna de modelado. Una ventana de contexto expresada en tokens admite menos español que inglés, y mucho menos JSON. Un precio por token es un precio por idioma. Y una plantilla de prompt que envuelve cada petición en metadatos estructurados paga la tarifa del JSON en cada llamada.

El sistema de tipos no avisa

En el software, el parser es código y el modelo son datos. Puedes cambiar el parser, recompilar y leer los mismos datos. Aquí, en cambio, el parser está acoplado a los pesos. La matriz de embeddings tiene una fila por id, y la fila 11 241 significa " token" solo porque eso es lo que dijeron las fusiones de GPT-2 cuando se entrenó el modelo; con cl100k_base, esa misma fila es Seg. Si codificas el mismo texto con otro tokenizador, obtienes ids que indexan la misma matriz y significan otra cosa. Que el fallo se note o pase en silencio es cuestión de suerte. Con o200k_base, el mismo párrafo en inglés llega al id 99 665, que una matriz de 50 257 filas rechaza. En sentido contrario, si metes ids de GPT-2 en un modelo de vocabulario más grande, todos caen dentro y no salta ninguna excepción. Y ni siquiera el rechazo es tan firme como parece. Los modelos en producción rellenan la matriz de embeddings hasta un múltiplo de 64 o de 128 para alinearla con los tensor cores, con lo que hay más filas en la matriz que ids en el vocabulario. Además, en una GPU un índice fuera de rango acaba apareciendo como un assert asíncrono en alguna línea que no tiene nada que ver con el sitio donde ocurrió.

Por eso el tokenizador forma parte del artefacto. En GPT-2 esa misma matriz hace también de cabeza de salida, así que la consulta a la entrada y la distribución de muestreo a la salida dependen del mismo array de pesos y un cambio de tokenizador corrompe las dos. El fichero del modelo y el del tokenizador son dos mitades de una misma cosa, y cuando se desalinean, el fallo es silencioso. Un cambio completo cuyos ids siguen dentro de rango produce basura que ningún control detecta, y una deriva parcial (un token especial renumerado, un BOS que falta) produce texto fluido condicionado por una entrada equivocada.

Qué vigila el ingeniero de ML

Hay cuatro cosas que conviene comprobar antes de que nada de esto llegue a producción.

Tokenizador y modelo, versionados juntos. Guarda el hash del tokenizador junto a los pesos y compruébalo al cargar. No hay desajuste peor que el que no falla al cargar.

Coste por idioma y por dominio. Mídelo con tu tráfico real antes de estimar una factura o de prometer una longitud de contexto. La ventana de 8k son 8k tokens de inglés, pero con el payload JSON de arriba se acerca más a 3,5k del mismo contenido.

Tokens especiales. Por defecto, el Encoding.encode de tiktoken lanza una excepción si el texto contiene algo parecido a <|endoftext|>, precisamente porque un usuario podría escribirlo. Ese comportamiento por defecto es una decisión de seguridad. Comprueba qué hace con ello la capa que sirve tu modelo y en qué punto mete tu plantilla de chat sus propios tokens especiales.

Longitud en tokens, no en caracteres. Todos los límites del modelo van en tokens. Para truncar, repartir el presupuesto de contexto o aplicar límites de consumo hay que pasar cada petición por el tokenizador, y contar caracteres no sirve.

Nada de esto es trabajo de modelado. Es la parte del modelo que se comporta más como el software corriente, y precisamente por eso llega a producción sin que nadie la revise: una función que convierte cadenas en enteros parece preprocesamiento hasta el momento exacto en que deja de serlo.

Fundamentos de MLFrom code to weights