Skip to content

English · Español

03 — BPE byte-level y escollos de Unicode

Pruébalo — el texto como bytes crudos

🇪🇸 Operar sobre bytes (no caracteres Unicode) hace al tokenizer inmune a normalización, BOM, mojibake y entradas adversariales malformadas. El alfabeto base de 256 bytes nunca produce "out of vocab"; los emojis se tokenizan como sus bytes UTF-8 (cuatro bytes por carácter en el peor caso) y pueden o no fusionarse según el corpus. Para nuestro corpus bilingüe (inglés + español, §A13), los caracteres acentuados como á o ñ son 2 bytes cada uno antes de cualquier fusión.


Byte vs carácter: la distinción sorprendentemente profunda

Un "carácter" es un code point Unicode — una abstracción. Un "byte" es un número 0–255 — concreto. La traducción entre ellos es una codificación (UTF-8, UTF-16, Latin-1, …).

UTF-8 mapea los code points Unicode a secuencias de bytes de longitud 1 a 4:

  • ASCII (az, AZ, 09, puntuación común): 1 byte cada uno. Igual que el valor de byte ASCII.
  • Suplemento Latin-1 (á, ñ, é, ó, ú, ¿, ¡, …): 2 bytes cada uno.
  • Latin Extended (ě, ą, …): 2 bytes cada uno.
  • CJK (chino, japonés, coreano): 3 bytes cada uno.
  • Emoji, planos suplementarios: 4 bytes cada uno.

Así que "mañana" son 6 caracteres pero 7 bytes: b'm', b'a', b'\xc3', b'\xb1', b'a', b'n', b'a' (la ñ es 0xC3 0xB1). Y "🦊" es 1 carácter pero 4 bytes: b'\xf0\x9f\xa6\x8a'.

BPE byte-level: opera sobre los bytes codificados

En lugar de empezar con un "vocabulario de caracteres Unicode" (que es teóricamente infinito — Unicode tiene ~150.000 code points asignados y creciendo), BPE byte-level empieza con un vocabulario fijo de 256 bytes. Cada entrada se codifica primero a UTF-8 en bytes, luego BPE se ejecuta sobre la secuencia de bytes.

Consecuencias:

  1. Nunca out of vocab. Cada byte 0–255 está en el vocab base. Cualquier entrada es alguna secuencia de bytes; ergo, cualquier entrada es tokenizable.
  2. Invariante a normalización Unicode. Dos cadenas que se renderizan idénticas pero tienen secuencias de code points diferentes (NFC vs NFD) se tokenizan como secuencias de bytes diferentes. No tratamos de "arreglar" esto; tokenizamos los bytes reales que el usuario nos dio.
  3. Robusto frente a entrada malformada. Bytes basura (p. ej. 0xFF, que no puede aparecer en UTF-8 válido) se tokenizan perfectamente — son valores de byte como cualquier otro.

El coste: los acentos españoles son 2 tokens base antes de cualquier merge; los emojis son 4. Con el entrenamiento BPE sobre nuestro corpus de verbos, las secuencias multibyte españolas frecuentes (la ñ de mañana, la á de está) se fusionan en tokens únicos. La primera iteración que captura 0xC3 + 0xB1 es la que da nacimiento al token ñ. Observable en lab 02.

La trampa de normalización Unicode

Cadenas con la misma apariencia pueden ser secuencias de bytes diferentes:

"mañana"   # NFC: 7 bytes (precomposed ñ): 6d 61 c3 b1 61 6e 61
"mañana"   # NFD: 8 bytes (n + combining tilde): 6d 61 6e cc 83 61 6e 61

(Los glifos visibles son idénticos; las secuencias de bytes difieren.) Un tokenizer a nivel de carácter tendría que decidir cuál es "canónico". Un tokenizer byte-level simplemente tokeniza los bytes que recibe.

Por qué esto importa:

  • Reproducibilidad: dos corpora que son "el mismo texto" pueden tener secuencias de bytes diferentes si uno se guardó con la normalización HFS+ NFD de macOS y el otro con el típico NFC de Linux. BPE byte-level entrenará merges diferentes sobre ellos.
  • Consistencia del pipeline de entrada: cuando el agente tutor de gramática de la Fase 32 recibe la frase de un aprendiz pegada de una fuente desconocida, la ambigüedad NFC/NFD podría causar que mañana se tokenice de una forma durante el entrenamiento y de otra durante inferencia.

Para nuestro corpus, el generador de snippets (Fase 12 scripts/gen_corpus.py) emite todo el texto usando str.encode('utf-8') por defecto de Python. Las cadenas de Python se almacenan como Unicode y por defecto no se normalizan. El generador del corpus debe llamar explícitamente a unicodedata.normalize('NFC', s) sobre cada línea emitida, y codificamos esa decisión en el BLUEPRINT del corpus. Las entradas de inferencia (Fase 32) pasan por el mismo normalizador NFC antes de tokenización.

Documéntalo; la consistencia importa más que la elección específica.

Por qué GPT-2 fue byte-level (el detalle histórico)

GPT-1 usaba un vocabulario de caracteres Unicode. Para manejar cada carácter Unicode, necesitarías un vocab de millones. GPT-1 esquivó esto entrenando solo sobre inglés filtrado; todo lo demás era <unk>.

GPT-2 quería manejar texto multilingüe de forma robusta. El equipo notó: si BPE corre sobre bytes, no caracteres, el vocab base es siempre exactamente 256 — sin importar cuántos idiomas o cuán exótico el texto. Los merges pueden entonces absorber patrones multibyte comunes. BPE byte-level fue el truco que hizo a GPT-2 multilingüe.

Heredamos este diseño. Nuestro corpus es bilingüe (inglés + español según §A13), así que byte-level no es preparación para el futuro — es la elección correcta hoy. Los acentos españoles (á, é, í, ó, ú, ñ, ¿, ¡) aparecen a menudo a través de la matriz de conjugación que BPE los fusionará en tokens únicos, y luego fusionará esos en fragmentos de raíz comunes (trab, habl, ).

Visualización: un token no es siempre sus bytes

La forma de visualización de un token (lo que escribimos en gráficos y salida de depuración) no siempre es lo mismo que su representación en bytes. Ejemplos:

  • El byte 0x20 (espacio) se muestra como ' '.
  • Los bytes b'\xc3\xb1' (la ñ de mañana) se muestran como el carácter 'ñ' si el terminal puede renderizar UTF-8.
  • El byte b'\xff' (que nunca aparece en UTF-8 válido) se muestra como '\\xff' (escapado).

El tokenizer de GPT-2 tiene una peculiaridad notoria: remapea cada byte a un carácter Unicode imprimible antes de visualizarlo, usando una tabla fija. Así el byte 0x20 se muestra como un carácter especial 'Ġ' (marcador visible de espacio inicial). Esto hace los gráficos de merges principales más legibles. No nos molestamos con este remapeo; nuestro corpus es pequeño y podemos mostrar los bytes literales (con escapes \\xNN para no-ASCII) más una columna de glifo renderizado lado a lado en los gráficos.

El dataclass Vocab (según src/minitoken/BLUEPRINT.md) tiene campos separados para id_to_bytes (canónico) e id_to_display (legible por humanos). Los gráficos usan id_to_display; codificación/decodificación usa id_to_bytes.

Lo que BPE byte-level maneja y lo que no

Lo que BPE byte-level maneja:

  • Bytes basura. b'\xff\xfe\xfd' se tokeniza como tres byte-tokens.
  • Codificaciones mezcladas. Si un usuario pega bytes Latin-1 que no son UTF-8 válido, byte-level los trata como bytes — sin crash.
  • Espacios en blanco adversariales. Tabs, espacios no separables, zero-width joiners, todos se vuelven sus bytes.
  • Marcadores BOM (\xef\xbb\xbf al inicio de un fichero). Solo bytes.
  • Acentos españoles y emojis. Tokenizados como sus secuencias de bytes UTF-8, posiblemente fusionados tras el entrenamiento.

Lo que BPE byte-level NO maneja (estas son preocupaciones a nivel de modelo, no de tokenizer):

  • Confusables visuales. O (O latina) vs Ο (Omicron griega) vs 0 (dígito cero). Bytes diferentes, tokens diferentes, renderizado idéntico o casi idéntico. El modelo no puede distinguir solo desde el token. La mitigación requiere normalización de clase de carácter en el pipeline de entrada, no en el tokenizer.
  • Codificación esteganográfica. Datos ocultos en longitudes de espacio o en caracteres invisibles (zero-width joiners). Todos se tokenizan; el modelo los ve como tokens. La mitigación es un sanitizador de entrada del modelo.
  • Aliasing de tokenización. Construir entradas tales que dos frases visualmente diferentes se tokenicen idénticamente. Improbable en práctica para BPE codicioso sobre nuestro pequeño corpus, pero los property tests del lab deben hacer comprobación de cordura.

Estos los exponemos en la Fase 32, no en la Fase 11. Aquí, la conclusión es: BPE byte-level hace que el tokenizer sea un no-tema.

Decisión de normalización NFC/NFD para nuestro proyecto

Decisión (registrada en BLUEPRINT):

  • Corpus de entrenamiento: scripts/gen_corpus.py (Fase 12) llama a unicodedata.normalize('NFC', line) sobre cada línea emitida.
  • Entradas de inferencia: el wrapper del agente de la Fase 32 aplica la misma normalización NFC antes de la tokenización.

Documéntalo; la consistencia importa más que la elección específica.

Nota de implementación: modos de I/O de ficheros

  • Siempre lee los ficheros de entrenamiento en modo binario (open(path, 'rb')). El modo texto (open(path, 'r')) hace conversión de codificación en Windows y también puede normalizar finales de línea. Binario preserva los bytes exactamente.
  • Siempre escribe los ficheros de guardado del tokenizer en binario o UTF-8 explícito. Un vocab.json guardado en una máquina Windows y cargado en Linux debe hacer round-trip byte-idéntico.

La implementación de BPE del Lab 01 debe hacer I/O binario. Fuente común de bugs.

Problemas de práctica

Soluciones en solutions/03-byte-level-and-unicode-ref.md (apertura de fase).

  1. ¿Cuántos bytes ocupa "¿cómo estás?" (una pregunta típica en español) en UTF-8? Cuenta a mano los caracteres multibyte; verifica con len("¿cómo estás?".encode('utf-8')).
  2. Argumenta por qué BPE byte-level hace que el comportamiento del tokenizer sea independiente de la codificación del fichero fuente (asumiendo que lees en binario). ¿Por qué esto importa para reproducibilidad entre sistemas operativos?
  3. Una entrada maliciosa es una secuencia de 100 bytes que contiene solo b'\xff'. El tokenizer entrenado en el corpus de verbos nunca ha visto este byte. ¿Qué produce encode? (Pista: piensa en el vocab base y los merges.)
  4. Un aprendiz pega cafe (sin acento) y café (con acento). Ambas son palabras prestadas inglés/español válidas. Muestra cómo se tokenizan de forma diferente y por qué esa diferencia es información que el modelo puede usar, no un bug.

Lo que esta página de teoría NO cubre

  • Plegado de mayúsculas consciente del locale. Fuera de alcance; tokenizamos bytes tal cual.
  • Análisis de ratio de compresión de byte-level vs char-level sobre nuestro corpus. Un ejercicio del lab 02, no una página de teoría.
  • Corpora multi-script más allá de inglés+español. Solo mención de encuesta.
  • Implementaciones de regex de pre-tokenización (el pat de GPT-2). Theory 01 menciona; no implementado aquí.

Recapitulación en un párrafo

BPE byte-level opera sobre bytes UTF-8, con un vocab base fijo de 256 bytes. Esto elimina out-of-vocab (cada byte está en el vocab), dolores de cabeza de normalización Unicode (tokenizamos los bytes que el usuario nos dio), y crashes por entrada adversarial. El coste es que los acentos españoles ocupan 2 tokens base antes de los merges, y los emojis 4 — pero los merges los absorben en tokens únicos cuando son frecuentes. Para nuestro corpus bilingüe inglés-español de verbos, byte-level es la elección correcta (no solo a prueba de futuro). La robustez a nivel de tokenizer es sólida; los confusables visuales y el aliasing siguen siendo preocupaciones a nivel de modelo para la Fase 32.


Siguiente: lab/00-toy-bpe-by-hand.md.