1La tubería de despliegue
Después de entrenar con LoRA (Módulo 5) no tienes un modelo nuevo: tienes el modelo base congelado más una matriz de bajo rango que lo corrige. Para desplegar hay que colapsar ambas cosas en un único conjunto de pesos, cambiar a un formato que el runtime entienda, y reducir la precisión numérica para que entre en memoria. Cada flecha de la tubería es una transformación que puede — y debe — verificarse antes de pasar a la siguiente.
modelo HF + adaptador → merge_and_unload → pesos fp16/bf16 → convert_hf_to_gguf.py → GGUF f16 → quantize (Q8…Q4 + imatrix) → GGUF cuantizado → runtime
2Fusionar el adaptador
LoRA aprendió dos matrices pequeñas, $B$ y $A$, cuyo producto escalado modifica cada peso base $W_0$. Fusionar es literalmente aplicar esa suma y descartar las matrices de bajo rango, dejando pesos densos ordinarios:
En la práctica, con una carga PEFT sobre el modelo base, merge_and_unload()
recorre cada capa adaptada, calcula esa suma y devuelve un modelo estándar sin dependencia
de PEFT. Luego se guarda tanto el modelo como el tokenizer en el mismo directorio: el
tokenizer arrastra el vocabulario y — clave para lo que viene — la plantilla de chat.
model = AutoModelForCausalLM.from_pretrained(base, torch_dtype=torch.bfloat16) model = PeftModel.from_pretrained(model, adapter_dir) model = model.merge_and_unload() # W = W0 + (alpha/r)·B·A model.save_pretrained(merged_dir) # pesos fusionados fp16/bf16 tokenizer.save_pretrained(merged_dir) # vocab + chat_template
Fusiona y exporta en fp16/bf16, no en la precisión de entrenamiento en 4 bits. Si entrenaste con QLoRA, el modelo base estaba cuantizado para ahorrar VRAM, pero el artefacto que fusionas debe estar en punto flotante de 16 bits para que la cuantización posterior parta de una base limpia.
3Convertir a GGUF
GGUF es el formato de archivo que consumen los runtimes tipo llama.cpp: un solo binario con
los tensores, los metadatos de arquitectura (número de capas, cabezas, dimensión), el
vocabulario y la plantilla de chat. La conversión desde Hugging Face la hace el script
convert_hf_to_gguf.py, que primero produce un GGUF en punto flotante:
python convert_hf_to_gguf.py ./merged_dir \
--outfile modelo-f16.gguf \
--outtype f16
Este archivo todavía es grande — no se cuantizó nada — pero es la fuente desde la cual se derivan todas las variantes cuantizadas. Conviene generarlo una sola vez y reutilizarlo.
4Cuantización: k-quants por bloques
Cuantizar es representar cada peso con menos bits. Los k-quants de llama.cpp no cuantizan pesos aislados: agrupan los pesos en bloques y, dentro de cada bloque, guardan enteros de baja precisión más una (super)escala y, según el esquema, un mínimo. El coste real en memoria se mide en bits por peso (bpw), que promedia los datos cuantizados y el overhead de las escalas sobre el tamaño del bloque:
Por eso los nombres no corresponden a un número entero de bits: cada esquema tiene su propio bpw efectivo una vez contado el overhead.
Reconstruir un peso desde su forma cuantizada (dequant) es la operación inversa: se toma el entero $q$ del bloque, se multiplica por la escala del bloque y se suma el mínimo del bloque:
El término $+\min$ es de los esquemas con punto cero. Los esquemas simétricos (Q8_0, Q6_K) no lo llevan ($\min = 0$): dequantizan con solo $w = \text{escala}\cdot q$.
./llama-quantize modelo-f16.gguf modelo-Q4_K_M.gguf Q4_K_M ./llama-quantize modelo-f16.gguf modelo-Q5_K_M.gguf Q5_K_M ./llama-quantize modelo-f16.gguf modelo-Q6_K.gguf Q6_K ./llama-quantize modelo-f16.gguf modelo-Q8_0.gguf Q8_0
_M indica un mix (medio) de precisiones por tipo de tensor.5imatrix: no todos los canales pesan igual
Cuantizar minimizando el error cuadrático medio trata a todos los pesos por igual — pero no lo son. Un canal por el que pasan activaciones grandes contribuye mucho más a la salida que uno casi siempre inactivo. La matriz de importancia (imatrix) estima esa importancia corriendo el modelo sobre un pequeño set de calibración y midiendo la energía de la activación por canal:
Con esos pesos, la cuantización deja de minimizar el error crudo y pasa a minimizar el error ponderado por importancia:
El efecto es que los canales de alto impacto reciben más "presupuesto" de precisión y los poco importantes absorben la pérdida. La ganancia es marginal en Q6/Q8 pero decisiva a bajo bpw: en Q4 una imatrix bien calculada puede recuperar buena parte de la calidad que la cuantización naive tira.
./llama-imatrix -m modelo-f16.gguf -f calibracion.txt -o modelo.imatrix ./llama-quantize --imatrix modelo.imatrix modelo-f16.gguf modelo-Q4_K_M-imat.gguf Q4_K_M
El set de calibración debe parecerse a lo que el modelo verá en producción. Si tu fine-tuning es de dominio (código, un idioma, un formato de chat), calibra con texto de ese dominio. Una imatrix calculada sobre texto genérico protege los canales equivocados.
6Medir la degradación
Cuantizar sin medir es adivinar. La métrica base es el cambio de perplejidad sobre un set de evaluación: cuánto empeoró el modelo cuantizado frente al f16.
La PPL es una señal gruesa: promedia sobre todo el corpus y esconde regresiones localizadas. Una señal más fina es la divergencia KL entre la distribución del siguiente token del modelo f16 y la del cuantizado, token a token — captura cuándo el modelo cuantizado redistribuye masa de probabilidad aunque la PPL apenas se mueva:
En la práctica se reportan ambas: ΔPPL para el titular y la KL (media y percentiles altos) para detectar los casos peores. Una cuantización con ΔPPL pequeña pero cola de KL alta puede romper prompts concretos aunque el promedio se vea bien.
7Verificar el chat template
Un error silencioso frecuente: el modelo cuantiza bien, corre rápido, y responde como modelo base — sin respetar los roles ni los tokens especiales del formato de chat. Casi siempre la causa es que la plantilla o los tokens especiales no viajaron en la conversión. Antes de dar por bueno el artefacto, hay que confirmar que el mismo prompt, formateado con la plantilla, produce en el GGUF cuantizado una respuesta equivalente a la del modelo fusionado en Hugging Face.
| Paso | Qué transforma | Qué verificar |
|---|---|---|
| merge_and_unload | $W_0 + \frac{\alpha}{r}BA \to W$ | Sale un modelo fp16 autónomo, sin PEFT |
| convert_hf_to_gguf | HF → GGUF f16 | Metadatos, vocab y chat template presentes |
| quantize | f16 → Q8…Q4 | Tamaño esperado por bpw; sin errores de tensor |
| evaluar | — | ΔPPL y KL bajo umbral; respuestas con roles correctos |
La regla operativa del módulo: cada flecha de la tubería se verifica antes de cruzar la siguiente. Un chat template roto detectado al final obliga a repetir conversión y cuantización de todas las variantes.
Este es el módulo que produce el archivo que corre en el teléfono. El .gguf cuantizado (p.ej. Q4_K_M) es el artefacto de despliegue: pequeño, mmap-eable y listo para llama.cpp o candle. Elegir el bpw es un trade-off directo entre tamaño/velocidad en el dispositivo y calidad.
- Fusiona el LoRA (
merge_and_unload) y exporta a fp16. - Convierte a GGUF con
convert_hf_to_gguf.py. - Cuantiza a Q8_0 / Q6_K / Q5_K_M / Q4_K_M.
- Repite con una imatrix calculada sobre un set de calibración.
- Grafica ΔPPL y tamaño vs bpw y elige el bpw más bajo bajo tu umbral.
El GGUF es el formato nativo de llama.cpp, y candle también lo lee: el mismo archivo cuantizado lo carga tu app en Rust.
Aquí converge todo el curso: entrenas en Python, empaquetas a GGUF y el runtime Rust del teléfono lo consume.
Lecturas y recursos
✦Ejercicios
De menor a mayor complejidad. El último es el que hace un practicante de verdad.
Fusiona y guarda
Carga el modelo base con su adaptador LoRA, aplica merge_and_unload() y guarda modelo + tokenizer en un directorio. Confirma que el chat_template quedó en el tokenizer guardado.
Entrega: directorio fusionado + la línea del chat_template impresa. Pista: tokenizer.chat_template no debe ser None.
Convierte a GGUF f16
Ejecuta convert_hf_to_gguf.py sobre el directorio fusionado con --outtype f16. Inspecciona los metadatos del GGUF resultante y localiza el número de capas, cabezas y el vocabulario.
Entrega: tamaño del f16 + un volcado de sus metadatos clave. Pista: usa el lector de metadatos GGUF de llama.cpp o gguf-dump.
Cuantiza a cuatro niveles
Genera Q8_0, Q6_K, Q5_K_M y Q4_K_M desde el mismo f16. Tabula el tamaño de archivo de cada una y compáralo con el bpw teórico.
Entrega: tabla esquema → tamaño (MB) → bpw. Pista: $\text{bpw} \approx \frac{\text{bytes}\times 8}{\text{nº de parámetros}}$.
Mide la degradación
Calcula la perplejidad de la variante f16 y de cada cuantización sobre un set de evaluación fijo, y reporta $\Delta\mathrm{PPL}$. Añade la divergencia KL media frente al f16 y observa dónde diverge de la PPL.
Entrega: tabla esquema → ΔPPL → KL media. Pista: llama-perplexity sobre el mismo corpus para todas las variantes.
Curva bpw vs ΔPPL, naive contra imatrix
Fusiona el adaptador LoRA en Qwen3-0.6B, convierte a GGUF (convert_hf_to_gguf.py),
y cuantiza a Q8_0, Q6_K, Q5_K_M, Q4_K_M — una vez de forma naive y otra con una imatrix
calculada sobre un set de calibración — y grafica $\Delta$PPL (y tamaño de archivo) vs bpw
para ambos, identificando el bpw más bajo que se mantiene bajo tu umbral aceptable de
degradación.
Entrega: una curva bpw-vs-$\Delta$PPL/tamaño mostrando la ganancia de la imatrix en Q4 y tu cuantización de despliegue elegida. Pista: calcula la imatrix con llama-imatrix sobre texto de tu dominio antes de cuantizar la rama con imatrix.
- La tubería es fusionar → convertir a GGUF → cuantizar → medir; cada flecha se verifica antes de la siguiente.
merge_and_unloadcolapsa $W_0 + \frac{\alpha}{r}BA$ en pesos densos fp16; guarda modelo y tokenizer.- Los k-quants cuantizan por bloques con escala/mínimo; el coste real se mide en bpw, no en bits nominales.
- La imatrix pondera el error por importancia de activación y es decisiva a bajo bpw (Q4).
- Mide con ΔPPL y KL, y confirma que el chat template viajó intacto antes de desplegar.