Poda de vocabulario + Continual Pre-training: adapta cualquier LLM al español
En los artículos previos vimos que cada idioma necesita su propio tokenizador. Entrenamos uno con 128.000 tokens específico para español que reduce los tokens en un 59 % frente al de GPT-2.
Pero hay un problema: los modelos modernos como Llama 3.2 3B ya no dejan cambiar el tokenizador. El tokenizador está incrustado en el modelo: sus matrices de embedding (input) y LM head (output) tienen una fila por cada token del vocabulario. Si el tokenizador tiene 128.000 tokens, esas matrices ocupan ~1 GB de VRAM solo almacenando tokens de idiomas que nunca vas a usar.
La solución es la poda de vocabulario (Vocabulary Pruning). Consiste en:
- Escanear qué tokens del vocabulario se usan realmente en español
- Podar el resto — eliminar cirílico, kanjis, árabe, etc.
- Re-mapear los IDs para que sean contiguos
- Reducir las matrices del modelo al nuevo tamaño
- Hacer continual pre-training con corpus español para que el modelo se adapte a su nuevo vocabulario reducido
En este artículo lo hacemos paso a paso con TinyLlama 1.1B (modelo abierto, sin autenticación, cabe en cualquier GPU). Los mismos pasos aplican a Llama 3.2 3B, Mistral, o cualquier modelo de la familia Llama.
El problema: vocabularios multilingües desaprovechados
Los modelos actuales se entrenan con vocabularios enormes para cubrir todos los idiomas del mundo:
| Modelo | Tokens en vocabulario |
|---|---|
| GPT-2 (solo inglés) | 50.257 |
| Llama 3 / 3.1 | 128.000 |
| Llama 3.2 | 128.000 |
| Mistral | 32.768 |
| Gemma 2 | 256.000 |
Cada token en el vocabulario necesita una fila en la matriz de input embeddings (que convierte IDs en vectores) y otra en la de output embeddings / LM head (que convierte vectores en logits).
En TinyLlama 1.1B (32.000 tokens, dimensión 2048):
Input embeddings: 32.000 × 2048 = 65,5 millones de parámetros
Output LM head: 32.000 × 2048 = 65,5 millones de parámetros
Total embeddings: 131,0 millones de parámetros (11,9 % del modelo)
En Llama 3.2 3B (128.000 tokens, dimensión 4096):
Input embeddings: 128.000 × 4096 = 524,3 millones de parámetros
Output LM head: 128.000 × 4096 = 524,3 millones de parámetros
Total embeddings: 1.048,6 millones de parámetros (~33 % del modelo)
Un tercio del modelo se dedica a almacenar vectores para tokens cirílicos, kanjis, escritura árabe, caracteres devanagari… que si trabajas solo en español nunca vas a usar.
Paso 1: Escanear qué tokens se usan realmente
Primero necesitas un corpus representativo de español. Cuanto más variado, mejor. Para esta demo usamos ~2 MB de texto en español (artículos publicados, borradores del blog, y contenido técnico de IA).
from transformers import AutoTokenizer
from collections import Counter
model_id = "TinyLlama/TinyLlama-1.1B-Chat-v1.0"
tokenizer = AutoTokenizer.from_pretrained(model_id)
with open("corpus_espanol.txt", "r", encoding="utf-8") as f:
corpus = f.read()
used_ids = set()
freqs = Counter()
chunk_size = 100_000
for i in range(0, len(corpus), chunk_size):
chunk = corpus[i:i + chunk_size]
ids = tokenizer.encode(chunk)
used_ids.update(ids)
freqs.update(ids)
# Preservar tokens especiales (<s>, </s>, <pad>, etc.)
used_ids.update(tokenizer.all_special_ids)
saved_ids = sorted(list(used_ids))
Resultado con TinyLlama 1.1B:
Vocabulario total: 32.000 tokens
Tokens usados en español: 13.713 tokens
Tokens podables: 18.287 tokens
Reducción: 57,1 %
Solo el 43 % del vocabulario de TinyLlama se usa para texto en español. El 57 % restante son tokens para otros idiomas que podemos eliminar sin perder funcionalidad.
Los tokens más frecuentes en nuestro corpus español son los esperados: espacios, puntuación, artículos, preposiciones y palabras completas como «aprendizaje», «modelo», «datos».
Paso 2: Podar el tokenizer.json
El tokenizer de Hugging Face guarda su vocabulario en un archivo tokenizer.json. Basta con quedarse solo con los tokens que usamos y reasignarles IDs contiguos:
import json
# Cargar tokenizer.json
with open("tokenizer.json", "r") as f:
tok_data = json.load(f)
old_vocab = tok_data["model"]["vocab"]
old_to_new = {old_id: new_id for new_id, old_id in enumerate(saved_ids)}
# Nuevo vocabulario: solo los tokens que conservamos
new_vocab = {}
for token_str, old_id in old_vocab.items():
if isinstance(old_id, int) and old_id in old_to_new:
new_vocab[token_str] = old_to_new[old_id]
tok_data["model"]["vocab"] = new_vocab
tok_data["model"]["added_tokens"] = [
t for t in tok_data["model"].get("added_tokens", [])
if t.get("id") in old_to_new
]
with open("tokenizer.json", "w") as f:
json.dump(tok_data, f, ensure_ascii=False, indent=2)
print(f"Tokenizer.json: {len(old_vocab)} → {len(new_vocab)} tokens")
Paso 3: Podar las matrices del modelo
Las matrices embedding y lm_head tienen una fila por token. Creamos matrices nuevas con solo las filas de los tokens que conservamos:
import torch
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype=torch.float32)
old_emb = model.get_input_embeddings()
old_head = model.get_output_embeddings()
new_emb_weight = torch.zeros(len(saved_ids), old_emb.weight.shape[1], dtype=old_emb.weight.dtype)
new_head_weight = torch.zeros(len(saved_ids), old_head.weight.shape[1], dtype=old_head.weight.dtype)
with torch.no_grad():
for new_id, old_id in enumerate(saved_ids):
new_emb_weight[new_id] = old_emb.weight[old_id]
new_head_weight[new_id] = old_head.weight[old_id]
# Asignar las nuevas matrices
model.resize_token_embeddings(len(saved_ids))
# Copiar los pesos podados
with torch.no_grad():
model.get_input_embeddings().weight.copy_(new_emb_weight)
model.get_output_embeddings().weight.copy_(new_head_weight)
model.save_pretrained("./modelo-podado-es")
Resultado con TinyLlama 1.1B:
| Matriz | Antes | Después | Ahorro |
|---|---|---|---|
| Input embeddings | 262,1 MB | 112,3 MB | 149,8 MB |
| LM head | 262,1 MB | 112,3 MB | 149,8 MB |
| Total (fp32) | 524,3 MB | 224,7 MB | 299,6 MB |
| Total (fp16, GPU) | 262,1 MB | 112,3 MB | ~150 MB |
En una GTX 1070 de 8 GB, esos 150 MB pueden ser la diferencia entre que un modelo quepa o no quepa en VRAM. Y sin perder ninguna capacidad en español — solo eliminamos tokens de otros idiomas.
Paso 4: Verificar que el modelo funcione
Después de la poda, las secuencias de tokens de cualquier texto en español se conservan exactamente igual. Los IDs cambian (porque reasignamos a contiguos), pero el significado es el mismo:
Original: "Hola mundo, esto es una prueba del modelo podado."
IDs originales: [1, 379, 2963, 13864, 29892, 18261, 831, 1185, 28854, 2291, 628, 29472, 2532, 912, 29889]
IDs podados: [1, 193, 2266, 8407, 13530, 10071, 593, 904, 13259, 1754, 420, 13403, 1930, 663, 13527]
Tokens totales: 15 → 15 ✅
Caracteres del español bien manejados: la ñ, los acentos (áéíóú), los signos de apertura (¿¡) se tokenizan correctamente tanto antes como después de la poda.
Paso 5: Continual Pre-training
Podar el vocabulario no basta. El modelo se ha quedado sin los vectores de los tokens que eliminamos, pero nunca ha visto texto español continuo con su nuevo vocabulario. Ahora toca el continual pre-training.
La configuración de entrenamiento depende del hardware disponible:
Para RTX 3060 12GB (configuración recomendada)
from transformers import TrainingArguments
from trl import SFTTrainer
training_args = TrainingArguments(
output_dir="./llm-espanol-final",
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # Batch efectivo: 16
learning_rate=1e-5,
bf16=True, # Ampere+ soporta bf16
optim="adamw_8bit", # Ahorra VRAM
gradient_checkpointing=True,
max_steps=1000,
logging_steps=10,
save_strategy="steps",
save_steps=200,
)
Para GTX 1070 8GB (nuestro hardware)
La GTX 1070 (Pascal) no soporta bf16 por hardware. Alternativas:
- Usar fp16 (cuidado con underflow en gradientes pequeños)
- 4-bit quantization (QLoRA): reduce el modelo a 4 bits sin perder calidad
- Gradient checkpointing + CPU offloading: mueve capas a RAM cuando no se usan
- Usar Llama 3.2 1B en lugar de 3B (cabe justo en 8 GB con fp16)
from transformers import BitsAndBytesConfig
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16
)
El corpus de entrenamiento es el español que ya tenemos: Wikipedia en español (18 GB), nuestros artículos, y cualquier texto técnico en español que puedas recopilar. El modelo hace continual pre-training: no aprende desde cero, sino que ajusta sus pesos al nuevo idioma partiendo del conocimiento que ya tiene.
Proyección para Llama 3.2 3B
Los mismos pasos aplican a Llama 3.2 3B. La diferencia es el tamaño:
| Métrica | TinyLlama 1.1B | Llama 3.2 3B |
|---|---|---|
| Vocabulario | 32.000 | 128.000 |
| Dimensión embedding | 2048 | 4096 |
| Memoria embeddings (fp16) | 262 MB | 2,1 GB |
| Ahorro estimado (57 % poda) | 150 MB | ~1,2 GB |
| Modelo completo (fp16) | ~2,2 GB | ~6,0 GB |
En Llama 3.2 3B, las matrices de embedding ocupan 2,1 GB (un tercio del modelo). Podar el 57 % del vocabulario libera ~1,2 GB de VRAM — suficiente para duplicar el batch size o añadir más contexto.
Conclusiones
-
Los modelos multilingües desperdician ~57 % de su vocabulario en español. Si trabajas solo en este idioma, puedes podar sin perder calidad.
-
La poda de vocabulario libera ~150-1200 MB de VRAM dependiendo del modelo. En hardware modesto (GTX 1070 8 GB), esto puede ser la diferencia entre que quepa o no.
-
El proceso es barato: escanear el corpus y podar las matrices se hace en minutos en CPU. El continual pre-training posterior es la parte cara, pero solo hay que hacerlo una vez.
-
El código es reutilizable. El script poda_vocabulario.py funciona con cualquier modelo de la familia Llama.
-
Combinado con nuestro tokenizador BPE español, el ahorro es acumulativo: menos tokens al codificar + menos vectores que almacenar.
Fuentes y recursos
- Script completo: poda_vocabulario.py (repositorio del proyecto: https://github.com/Carlos-droid/bpe-espanol)
- Artículo anterior: Cómo entrenar tu propio tokenizador BPE para español (https://ventasymundopop.com/entrena-tu-tokenizador-bpe-espanol/)
- Artículo anterior: Tokenización y Embeddings (https://ventasymundopop.com/tokenizacion-embeddings-llm-desde-cero-2/)
- Giles Thomas, «Writing an LLM from scratch» (CC BY 4.0): https://www.gilesthomas.com/llm-from-scratch
- Sebastian Raschka, «Build a Large Language Model (from Scratch)»: https://sebastianraschka.com/llms-from-scratch/
- Repositorio de Raschka: https://github.com/rasbt/LLMs-from-scratch
- TinyLlama: https://huggingface.co/TinyLlama/TinyLlama-1.1B-Chat-v1.0
- Búsqueda original sobre poda: Zhou et al., «Vocabulary Pruning for Multilingual LLMs», 2024.

