Comparé mi tokenizador BPE con tiktoken y BERT: los números no fueron amables
Reentrené el BPE de transformer-from-scratch a un vocabulario de 8.000 y lo enfrenté a cl100k_base, o200k_base, GPT-2 y BERT multilingüe sobre los mismos corpus de inglés, español y código. La brecha en español fue peor de lo que esperaba, y el motivo real no fue el que había supuesto.

Cuando escribí mi propio tokenizador BPE para el transformer desde cero, aprendí cómo funciona por dentro: contar pares, fusionar el más frecuente, repetir. Lo que no sabía era cuánto peor se comporta ese algoritmo — el mío, sin cambiar una línea — fuera del idioma en el que lo entrené. En vez de suponerlo, monté un benchmark reproducible que lo mide con números reales, contra cuatro tokenizadores de producción, sobre los mismos textos.
Los cinco contendientes. Mi BPE, más cl100k_base y o200k_base de tiktoken (los tokenizadores de GPT-3.5/4 y GPT-4o), más GPT-2 y bert-base-multilingual-cased vía la librería tokenizers de Hugging Face. El checkpoint que entrené en transformer-from-scratch tiene un vocabulario de solo 512 tokens, y compararlo contra vocabularios de cien mil o más solo habría medido que 512 es mucho más pequeño que cien mil, que no es información. Así que reentrené el mismo algoritmo, sin tocar una línea, con la misma tiny-shakespeare, hasta un vocabulario de 8.000 tokens (7.744 fusiones aprendidas): sigue siendo un orden de magnitud más pequeño que el resto, pero ya lo bastante grande para que la comparación hable del idioma en que se entrenó y no solo de su tamaño.
Los corpus, con hueso de verdad. Tres textos de dominio público, cada uno recortado a un tamaño fijo y documentado: Pride and Prejudice para inglés, El Quijote para español (ambos de Project Gutenberg, con las cabeceras de licencia retiradas), y un corpus de código armado concatenando módulos ordenados de la biblioteca estándar de Python. Cada descarga queda fijada con su checksum sha256 en el propio script, así que el benchmark se reproduce exactamente igual dentro de un año. Los tres corpus terminan pesando casi lo mismo, unos 700 KB cada uno, para que el tamaño del texto no sea la variable que decide nada.
Lo que no estaba en el plan: 56 segundos por una sola pasada. La primera vez que lancé el benchmark completo lo dejé correr con un límite de cinco minutos, y se agotaron los cinco minutos sin que imprimiera ni una línea. Medí directamente el problema: mi encode() reescanea toda la secuencia de tokens en cada fusión que aplica, así que su coste crece más rápido que el tamaño del texto. Una sola pasada sobre 150.000 bytes de inglés — su idioma más favorable — tardó 56 segundos. Repetir eso seis veces (una de calentamiento más cinco medidas, la metodología de velocidad) sobre los tres corpus completos de 700 KB me habría costado, solo para ese tokenizador, bastante más de media hora. La solución fue medir los cuatro indicadores sobre una muestra fija de 100.000 bytes por corpus, la misma para los cinco tokenizadores, documentada en el propio README en vez de escondida. El benchmark entero pasó de no terminar nunca a tardar siete minutos, de principio a fin.
La fertilidad, con los números reales. Mi BPE necesita 2,12 tokens por palabra en inglés, 2,83 en español y 5,97 en código: subir un 33,6 % en español y un 181,4 % en código respecto a su propio inglés, la peor degradación de los cinco. cl100k_base pasa de 1,40 a 1,73 (+23,6 %) y a 2,29 en código (+63,4 %). o200k_base, el más reciente, pasa de 1,39 a 1,54 (+11,4 %) y a 2,30 en código (+66,3 %). Los números confirman lo que se suele repetir sobre los tokenizadores modernos y multilingües — que sufren mucho menos fuera del inglés — pero también enseñan que "mucho menos" no es "nada": ni siquiera o200k_base es neutral entre idiomas.
El tamaño del vocabulario no cuenta toda la historia. bert-base-multilingual-cased tiene 119.547 tokens, menos del 60 % de los 200.019 de o200k_base, y aun así su brecha inglés-español es la más pequeña de los cinco: +6,9 % frente al +11,4 % de o200k_base. Lo que lo explica no es el tamaño, es el origen de los datos: Wikipedia en 104 idiomas, con el español presente en una proporción parecida a la del inglés, no en una fracción residual. Un vocabulario más grande compra más tokens distintos; en qué idioma se gastan esos tokens lo decide el corpus de entrenamiento, no el número de entradas.
Una sorpresa que casi publico sin comprobar. bert-base-multilingual-cased —el único de los cinco con un token [UNK] real— tiene una tasa de desconocidos más alta en inglés (3,585 %) que en español (0,456 %) o en código (0,015 %), justo al revés de lo esperable. Antes de escribir "BERT falla más en inglés", extraje los fragmentos exactos que generaban el [UNK]: las 842 apariciones en inglés son comillas tipográficas (“ ” ‘ ’), porque esa edición de Gutenberg de Pride and Prejudice las usa y un vocabulario WordPiece de 2018 no las incluye. Las 124 en español son rayas (—), que la edición de El Quijote usa para marcar los diálogos. No es un problema de cobertura de idioma: es tipografía de la edición concreta, y de no comprobarlo habría publicado una conclusión falsa con aspecto de dato.
Otra grieta entre dos métricas que deberían contar la misma historia. Por fertilidad, el código es el peor caso para los cinco tokenizadores, pero eso mide en parte un defecto de la métrica: separar por espacios en blanco convierte self.assertEqual(x, y) en una sola "palabra" que luego explota en una docena de tokens. Por bytes por token, la imagen cambia: cl100k_base y o200k_base — los dos únicos entrenados con código de verdad en la mezcla — comprimen el corpus de Python más que la prosa en inglés (4,55 frente a 4,37 bytes/token, y 4,52 frente a 4,42). Los otros tres, el mío incluido, comprimen peor el código que el inglés. La fertilidad y los bytes por token no siempre están de acuerdo, y cuando no lo están, suele ser porque uno de los dos está midiendo otra cosa.
Todo esto — el código, los corpus con checksum fijado, el resultado completo de las quince combinaciones y los scripts para reproducirlo desde cero — está en el repositorio. No hice este benchmark para demostrar que mi tokenizador es peor; eso ya lo sabía en abstracto. Lo hice para tener números concretos de cuánto peor, y para entender que la respuesta correcta casi nunca es "el vocabulario es pequeño": es el idioma en el que aprendiste a fusionar bytes.

