1Qué es tokenizar: del texto a los ids
Tokenizar es partir una cadena en un número finito de unidades y asignar a cada una un entero. Formalmente es un mapa determinista de texto a una secuencia de ids, cada uno dentro del vocabulario de tamaño $V$:
Los modelos modernos (Qwen, Llama, GPT) usan byte-level BPE. La clave está en byte-level: antes de mirar caracteres, el texto se codifica en UTF-8, es decir en una secuencia de bytes, y el alfabeto base del tokenizador son los 256 bytes posibles. Como cualquier texto imaginable — un emoji, un ideograma, un símbolo matemático, un idioma que el modelo nunca vio — se reduce a esos 256 bytes, nunca hay OOV (out-of-vocabulary): en el peor caso una palabra rara se rompe en muchos bytes sueltos, pero siempre es representable. No existe el token "desconocido" que descarta información.
Los tokenizadores antiguos basados en palabras tenían un token especial <unk> para lo que no estaba en el vocabulario, y ahí se perdía información irreversiblemente. Byte-level BPE elimina ese problema de raíz: el vocabulario está cerrado bajo cualquier entrada. El costo es que lo raro consume más tokens (lo veremos en §3).
2BPE: tokenizar como segmentar
BPE (Byte-Pair Encoding) aprende un vocabulario por frecuencia. Se parte del alfabeto de 256 bytes y, sobre un corpus grande, se busca el par de símbolos adyacentes más frecuente y se fusiona en un nuevo símbolo. La regla de merge es simplemente:
Ese par ganador se añade al vocabulario como un token nuevo y se registra la fusión. El
proceso se repite — recontando frecuencias tras cada fusión — hasta alcanzar el tamaño
de vocabulario objetivo (por ejemplo $V \approx 150\,000$ en Qwen3). El resultado es una
tabla de merges ordenada: fragmentos frecuentes como ▁the o
ción acaban siendo un solo token, mientras lo raro queda en piezas pequeñas.
En inferencia el tokenizador no reaprende nada: aplica esa tabla de merges de forma determinista, pero no fusiona de izquierda a derecha, sino por prioridad (rango de merge): en cada paso busca, entre todos los pares adyacentes presentes, el que tiene el menor rango en la tabla (el que se aprendió antes, de mayor prioridad) y lo fusiona sin importar su posición en la cadena; repite hasta que no quede ningún par aplicable. Por eso tokenizar es, en la práctica, un problema de segmentación: dado el texto, ¿cuál es la partición en unidades del vocabulario que la tabla de merges produce? La segmentación es única y reproducible, lo que garantiza que el mismo string siempre dé los mismos ids.
"tokenización" → bytes UTF-8 → [ merges BPE ] → ▁token | ización (2 tokens) "漢字" → bytes UTF-8 → [ merges BPE ] → 漢 | 字 (o varios bytes: nunca OOV)
3Fertilidad y presupuesto de contexto
La fertilidad mide cuántos tokens consume el tokenizador por palabra de texto:
Un tokenizador entrenado sobre todo en inglés suele tener fertilidad cercana a $1.3$ en inglés, pero puede dispararse a $2$–$3$ en otros idiomas, en código o en texto con muchos símbolos, porque las secuencias de bytes menos frecuentes no se fusionaron en tokens grandes. Esto tiene consecuencias directas y medibles:
- Ventana de contexto: la longitud se cuenta en tokens, no en palabras. Con fertilidad $2.5$, un documento de 4000 palabras ocupa 10 000 tokens y puede no caber donde un texto en inglés equivalente sí cabría.
- Costo y latencia: más tokens por el mismo contenido significa más cómputo por secuencia, tanto al entrenar como al servir en producción.
- Presupuesto del dataset: antes de fine-tunear conviene medir la fertilidad real de tu corpus objetivo para estimar cuánto contexto vas a necesitar y cuántos ejemplos caben por batch.
Comparar "número de ejemplos" entre dos idiomas sin mirar la fertilidad engaña. Mide siempre tokens, no filas ni palabras: es la unidad que consume memoria, contexto y presupuesto de entrenamiento.
4Tokens especiales: la estructura invisible
Además de los tokens de texto, el vocabulario reserva unos pocos tokens especiales que no representan contenido sino estructura. Son parte del vocabulario (tienen su id) pero el usuario nunca los escribe: los inserta el tokenizador o la plantilla.
| Token | Qué marca | Dónde aparece |
|---|---|---|
| BOS (begin of sequence) | Inicio de la secuencia | Al principio, si el modelo lo usa |
| EOS (end of sequence) | Fin de la generación | Señala al muestreo que pare |
| PAD (padding) | Relleno para igualar longitudes | En batches, ignorado por la máscara |
<|im_start|> / <|im_end|> | Apertura y cierre de un turno de chat | Envuelven cada mensaje (formato ChatML) |
Los marcadores de turno como <|im_start|> y <|im_end|>
(convención ChatML) son los que permiten al modelo distinguir quién habla:
system, user o assistant. El rol se escribe como texto justo después del
<|im_start|>. Estos tokens se aprendieron durante el post-entrenamiento del
modelo; usar otros distintos, o ninguno, es pedirle que interprete una estructura que no
reconoce.
5El chat template: una función determinista
Un modelo de chat no recibe una lista de mensajes: recibe una única cadena de texto. El chat template es la función determinista que convierte los mensajes en esa cadena, antes de tokenizar:
En la práctica esa función render está escrita como una plantilla Jinja
empaquetada junto al tokenizador (campo chat_template). Recorre la lista de
mensajes e inserta los marcadores de rol y turno correspondientes. Como es determinista, el
mismo messages siempre produce exactamente el mismo string — condición
indispensable para que entrenamiento e inferencia coincidan.
messages = [
{"role": "system", "content": "Eres un asistente conciso."},
{"role": "user", "content": "¿Qué es la fertilidad de un tokenizador?"},
{"role": "assistant", "content": "Es el número de tokens por palabra."}
]
ids = tokenizer.apply_chat_template(messages, add_generation_prompt=True)
# apply_chat_template = render(messages) → string → tokenizar
# produce UNA secuencia de ids con marcadores especiales, p. ej.:
# <|im_start|>system\nEres un asistente conciso.<|im_end|>
# <|im_start|>user\n¿Qué es...?<|im_end|>
# <|im_start|>assistant\n ← header abierto por add_generation_prompt
El parámetro add_generation_prompt=True añade al final el header del turno del
assistant (<|im_start|>assistant\n) pero sin contenido ni
<|im_end|>: deja la cadena "abierta" justo donde el modelo debe empezar a
escribir. Es lo que quieres en inferencia, para que el modelo continúe en el rol
correcto. En cambio, para construir un ejemplo de entrenamiento completo (donde la
respuesta del assistant ya existe) normalmente lo pones en False, porque el turno
ya está cerrado.
Qwen usa ChatML: <|im_start|>role\n…<|im_end|> y no antepone BOS. Gemma usa otra convención: <start_of_turn>user\n…<end_of_turn>, roles distintos (model en vez de assistant) y sí antepone un token BOS. Los marcadores, los nombres de rol y el uso de BOS cambian entre familias: por eso jamás se escribe la plantilla a mano, se usa la que viene con el tokenizador.
6attention_mask, plantillas mal aplicadas y cómo inspeccionar
Al armar batches, las secuencias se rellenan con PAD hasta igualar longitudes. El
attention_mask es un vector binario que marca qué posiciones son reales y cuáles
relleno:
Las posiciones con padding ($a_i=0$) se ponen a $-\infty$ en los logits pre-softmax de la atención — de modo que ninguna posición real "atiende" al relleno. Es exactamente la misma idea de máscara que la causal del Módulo 0, aplicada aquí a las posiciones de relleno.
No las confundas: son dos tensores diferentes. El attention_mask (valores
0/1) gobierna la atención — le dice al modelo que no atienda al padding. El
-100 (el ignore_index de la loss) va en las labels, no en el
attention_mask, y gobierna la loss: marca qué posiciones no cuentan
en el gradiente. Una vive en la atención; la otra, en el cálculo de la pérdida (lo verás en el
Módulo 3). Juntas evitan que el padding contamine ni las activaciones ni el gradiente.
Por qué importa la plantilla correcta. Durante el fine-tuning el modelo no solo aprende qué responder, sino el formato en el que las respuestas aparecen: los marcadores de turno, los saltos de línea, dónde empieza el assistant. Si entrenas con una plantilla distinta de la que usarás en inferencia — o peor, concatenando los mensajes a mano sin marcadores — el modelo aprende una estructura que luego no se le presenta. El síntoma clásico: en producción el modelo alucina turnos, no para de generar (no emite EOS donde debía) o ignora el rol system. La causa casi nunca son los datos: es el desajuste de formato.
Un error frecuente es tokenizar dos veces los tokens especiales: si tu string ya contiene <|im_start|> como texto y además pides que el tokenizador añada especiales, terminas con marcadores duplicados o partidos. Regla práctica: deja que apply_chat_template haga todo el trabajo y no vuelvas a insertar especiales por tu cuenta.
Cómo inspeccionar lo que realmente recibe el modelo. Nunca confíes en tu modelo mental:
imprime el texto final. Llama a apply_chat_template(..., tokenize=False) para ver
la cadena renderizada carácter por carácter, y luego decodifica los ids con
tokenizer.decode (o token a token con convert_ids_to_tokens) para
confirmar dónde caen exactamente los marcadores. Verifica el round-trip: si
decode(encode(x)) reproduce tu texto (salvo los tokens especiales añadidos), la
tubería está sana. Esta inspección de cinco minutos evita horas de fine-tuning tirado.
El tokenizador no vive solo en el entrenamiento: corre en el dispositivo justo antes de cada inferencia, porque el modelo on-device sigue viendo únicamente enteros. En apps on-device ese trabajo suele hacerlo la librería Rust tokenizers compilada para ARM, la misma que usas desde Python. El punto delicado es el chat template: si en el teléfono se aplica mal — otros marcadores, otro orden, un salto de línea de más — el modelo recibe un formato distinto al de entrenamiento y responde peor. Es un bug silencioso frecuente: nada falla en pantalla, solo baja la calidad.
- Instala el entorno:
pip install transformers. - Carga el tokenizer de Qwen3-0.6B:
AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B"). - Renderiza la plantilla: aplica
apply_chat_templatea una conversación con y sinadd_generation_prompte imprime el string renderizado (tokenize=False) para verlos lado a lado. - Verifica el round-trip: confirma que
decode(encode(x)) == xsalvo los tokens especiales añadidos. - Mide la fertilidad (tokens/palabra) de tu texto objetivo frente a un baseline en inglés y compáralas.
La librería tokenizers de Hugging Face está escrita en Rust: puedes cargar el tokenizer de Qwen y tokenizar sin tocar Python. Para el chat template en Rust se resuelve la misma plantilla Jinja con el crate minijinja, aplicándola a los mensajes antes de tokenizar. Esto es exactamente lo que corre dentro de una app: tokenización y render de plantilla nativos, sin intérprete de Python.
// tokenizers (Rust): cargar el tokenizer de Qwen y tokenizar let tokenizer = Tokenizer::from_pretrained("Qwen/Qwen3-0.6B", None)?; let enc = tokenizer.encode("Hola, mundo", true)?; // ids del texto let ids = enc.get_ids(); // &[u32] // el chat template se renderiza aparte con minijinja (plantilla Jinja)
Lecturas y recursos
✦Ejercicios
De menor a mayor complejidad. El último es el que hace un practicante de verdad.
Round-trip básico
Carga el tokenizer de Qwen3 y tokeniza tres frases (una con emoji o acentos). Decodifica los ids de vuelta e imprime el original y el reconstruido lado a lado.
Entrega: tabla frase → ids → texto decodificado. Pista: tokenizer.encode(x) y tokenizer.decode(ids).
No hay OOV
Tokeniza texto "imposible": un idioma exótico, símbolos matemáticos y bytes raros. Confirma que ninguna entrada produce un token desconocido y que todo se reconstruye tras decodificar.
Entrega: assert de que decode(encode(x)) == x para cada caso. Pista: byte-level BPE parte de 256 bytes; observa cuántos tokens usa lo raro.
Mide la fertilidad
Toma un párrafo en español y su traducción al inglés. Cuenta tokens y palabras en cada uno y calcula la fertilidad ($\text{tokens}/\text{palabra}$) de ambos.
Entrega: tabla idioma → palabras, tokens, fertilidad. Pista: palabras por split(), tokens por len(encode(x)).
Renderiza la plantilla
Con una conversación system/user/assistant, llama a apply_chat_template(messages, tokenize=False) con add_generation_prompt en True y en False. Imprime ambas cadenas y señala la diferencia exacta al final.
Entrega: las dos cadenas renderizadas con la diferencia marcada. Pista: mira si termina en <|im_end|> o en <|im_start|>assistant\n.
Volcado token-por-token y presupuesto de contexto
Toma una conversación multi-turno cruda, ejecuta tokenizer.apply_chat_template
para Qwen3 con y sin add_generation_prompt, y produce un volcado
token-por-token coloreado que muestre exactamente dónde caen <|im_start|>,
los strings de rol, <|im_end|> y cualquier BOS/EOS; luego verifica el
round-trip (decode(encode(x)) == x salvo tokens especiales) y calcula la
fertilidad de tu dataset objetivo frente a un baseline en inglés para presupuestar la
longitud de contexto.
Entrega: tabla de tokens anotada probando los límites de la plantilla + un test de round-trip por aserciones. Pista: usa convert_ids_to_tokens para el volcado y tokenizer.all_special_tokens para filtrar especiales antes de comparar el round-trip.
- Byte-level BPE parte de 256 bytes: nunca hay OOV, lo raro solo cuesta más tokens.
- Tokenizar es segmentar: el par más frecuente se fusiona ($\arg\max\,\mathrm{count}(a,b)$) hasta llenar el vocabulario.
- La fertilidad ($\text{tokens}/\text{palabra}$) decide tu presupuesto de contexto — mide tokens, no palabras.
- El chat template es
render(messages)→string, determinista y en Jinja, aplicado antes de tokenizar. add_generation_promptabre el turno del assistant para inferencia; elattention_mask($a_i\in\{0,1\}$) descarta el padding en atención y loss.- Una plantilla incorrecta enseña el formato equivocado: inspecciona siempre el texto final antes de entrenar.