Lead
La semana pasada hablamos de MCP. Esta semana iba a mostrarte cómo fine-tunear un modelo de lenguaje por $0 en Colab Free para que genere SVG. Y voy a hacerlo — pero con una advertencia honesta de entrada: la primera versión del modelo colapsó.
Dos horas de training limpias. Loss bajando bonito de 0.91 a 0.61. Adapter de 72 MB guardado en disco. Cuando terminó, le pedí "a simple blue star" con greedy decoding y me respondió:
No valid response from model.
Cinco seeds distintos después, con sampling, con temperatura 0.7 — la misma cadena de 29 caracteres, exacta. El fine-tune aprendió a contestar basura como respuesta óptima.
Este post cuenta tres cosas: la receta para llegar desde cero hasta un adapter funcional en 2 horas en una T4 gratis, la saga de dtypes que no está en los tutoriales oficiales, y la trampa del dataset que me colapsó el modelo — cómo la detecté, por qué pasó, y cómo se resuelve. El v2 está en cola para entrenar en cuanto Colab libere la cuota; actualizo este post con los números finales apenas corra.
TL;DR
Modelo: Qwen2.5-Coder-1.5B-Instruct (Apache-2.0), fine-tune con SFT + QLoRA 4-bit vía TRL v1.0.
Hardware: Google Colab Free (1× T4, 16 GB VRAM). Costo: $0.
Tiempo: ~2h training + ~5 min eval.
Resultado v1: mode collapse por contaminación del dataset (84 de 4,750 muestras = 1.77% tenían
"No valid response from model."como completion).Fix: filtro de 4 líneas + re-training con dataset limpio. v2 en cola por límites de cuota diaria de Colab.
Entrega: repo completo, notebook reproducible, eval script.
Resultados v1 (esto es mode collapse)
Evaluación greedy sobre 5 prompts distintos, adapter v1:
Métrica | Adapter v1 |
|---|---|
Contiene bloque | 0/5 |
Well-formed XML | 0/5 |
Renderizable con | 0/5 |
Output = | 5/5 |
No es mala suerte del sampling. Es la respuesta que el fine-tune asignó como globalmente más probable ante cualquier prompt. Números de v2 (con dataset limpio) se publican en este mismo post apenas termine el re-training.
[Imagen side-by-side base model | adapter v2 | ground truth — llega con v2]
La trampa: 1.77% que colapsó el 100%
Cuando terminó el training, hice un sanity test de 30 segundos: cargar el adapter, prompt simple, ver qué sale. Salió la cadena de 29 chars. Cinco seeds después, con temperatura 0.7, confirmado: no era azar, era mode collapse.
La pregunta obvia: ¿de dónde sale esa cadena? "No valid response from model." no es algo que Qwen inventaría — tiene sintaxis demasiado específica. Tenía que estar en los datos:
ds = load_dataset("thesantatitan/deepseek-svg-dataset", split="train")
bad = sum(1 for ex in ds if "No valid response from model" in ex["completion"])
print(f"{bad}/{len(ds)} ({100*bad/len(ds):.2f}%)")
# → 84/4750 (1.77%)
Ahí estaba. Cuando los autores del dataset generaron las respuestas con DeepSeek, 84 fallaron y el pipeline guardó el mensaje de error literal como si fuera una respuesta válida. Un ejemplo real:
>>> ds[6]["completion"]
'No valid response from model.'
¿Por qué 1.77% colapsa el modelo al 100%? Tres factores amplificados entre sí:
Con
assistant_only_loss=True, la loss solo cuenta sobre los tokens de la respuesta del asistente. El stub tiene ~8 tokens; un SVG real tiene 600–1,200. Por token, el modelo ve el stub con muchísimo más peso relativo que cualquier fragmento de SVG.Las 84 muestras son idénticas — el mismo string repetido. Eso es un atractor brutalmente estable en el paisaje de loss.
El stub es trivialmente fácil de predecir. Loss sobre él baja a casi cero en pocos pasos. Loss sobre SVG nunca baja tanto porque SVG tiene alta entropía. El optimizer encuentra el atajo.
El fine-tune aprendió que, ante cualquier prompt, la respuesta más segura (menor loss) es el stub. Ganó la basura.
El fix es una función de 4 líneas:
def is_clean(ex):
c = ex["completion"]
return (
"No valid response from model" not in c
and len(c) >= 200
and "<svg" in c
)
ds_clean = ds.filter(is_clean) # 4750 → 4666
Esa es la lección que me llevo, y la paso aquí: nunca asumas que un dataset público está limpio, ni con miles de ⭐ en HuggingFace. La auditoría toma 10 líneas de código y se hace antes de entrenar. El mode collapse toma 2h de GPU, y si no haces el sanity test post-training lo descubres cuando ya subiste el modelo al Hub y alguien reporta el bug.
Lo que nadie te cuenta: la saga de dtypes en QLoRA moderno
La parte donde todos los tutoriales te dicen "pon fp16=True y listo" es, en Turing (la arquitectura de la T4), una trampa silenciosa. Cuatro intentos antes de dar con la receta:
Intento 1 — Carga ingenua, fp16=True:
NotImplementedError: _amp_foreach_non_finite_check_and_unscale_cuda not implemented for 'BFloat16'
¿Pero si pedí fp16, de dónde sale bf16? De Qwen2.5 — su config.json declara torch_dtype=bfloat16. Los weights no cuantizados (embeddings, norms) heredan ese dtype, y PEFT castea los adaptadores LoRA al dtype del base layer. El GradScaler de fp16 AMP no maneja gradientes bf16 → crash.
Intento 2 — dtype=torch.float16 explícito. Mismo error. Los bitsandbytes.Linear4bit ignoran ese parámetro.
Intento 3 — bf16=True en SFTConfig. Funciona. Pero el ETA salta a 8 h 46 min. La T4 es Turing; sus Tensor Cores solo aceleran fp16. En bf16 cae al path FP32 emulado, 8× más lento.
Intento 4 (el que funciona) — Tres cosas combinadas:
prepare_model_for_kbit_training(model)justo después de cargar.fp16=True, bf16=Falseen SFTConfig.Cast manual de los adaptadores LoRA a fp32 después de construir el SFTTrainer:
for name, p in trainer.model.named_parameters():
if p.requires_grad and p.dtype != torch.float32:
p.data = p.data.to(torch.float32)
Con eso, la recipe queda:
Componente | Dtype |
|---|---|
Base weights | nf4 (4-bit) |
Compute | fp16 (Tensor Cores activos) |
LoRA adapters | fp32 |
Optimizer states | fp32 |
Gradient scaling | fp16 AMP + GradScaler |
1 h 51 min de training, loss 0.91 → 0.61.
El otro cable suelto: Colab Free desconecta a los 90 minutos
Lo aprendí a la mala. Primera corrida: empecé a las 23:00 con save_strategy="epoch", proyección 1 h 52. A las 8:00 de la mañana el browser mostraba la tabla de loss congelada en step 223 con el display de los 41 minutos iniciales, pero el runtime llevaba horas muerto. 41 min de GPU perdidos, nada en disco — el epoch nunca terminó.
Dos medidas obligatorias para training en Colab Free:
1. Checkpoints intermedios:
save_strategy="steps"
save_steps=100
save_total_limit=3
2. Keep-alive en DevTools (F12 → Console):
setInterval(() => {
document.querySelector("colab-connect-button")?.click();
}, 60000);
Feo, hacky, y el único workaround confiable para el idle timeout de 90 min.
Cómo usar el modelo (cuando v2 esté listo)
En cuanto termine el re-train con el dataset filtrado, el adapter v2 queda en Hugging Face como bitneuronal/qwen-svg-coder-lora. Carga en ~2 GB de VRAM — una T4, una RTX 3060 o cualquier GPU mediana alcanza:
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import PeftModel
import torch
bnb = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16)
base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-Coder-1.5B-Instruct",
quantization_config=bnb, device_map="auto", dtype=torch.float16)
model = PeftModel.from_pretrained(base, "bitneuronal/qwen-svg-coder-lora")
# ... generate() como cualquier transformers model
Estado de v2 al cierre de este post: dataset filtrado listo (4750 → 4666 muestras), training en cola, bloqueado por la cuota diaria de Colab Free. Re-running en 12–24 h.
Código y recursos
Todo el pipeline es reproducible punto por punto — clonas, abres el notebook en Colab, ejecutas:
Repo: github.com/BitNeuronal/qwen-svg-finetune — código,
requirements.txt,LICENSE, README con la historia completa y la receta técnica.Notebook reproducible:
notebooks/train_colab.ipynb— end-to-end en Colab T4 Free: instala, filtra dataset, entrena, guarda, evalúa.Eval script:
src/eval.py— compara adapter vs. base con 3 métricas (well-formed XML, renderizable, tags válidos) + side-by-side PNG.Adapter en Hugging Face (llegará con v2): bitneuronal/qwen-svg-coder-lora.
Mientras tanto: si te rompiste la cabeza con un bug parecido al de hoy — mode collapse, training que parece funcionar pero genera basura, datasets con ruido no obvio — escríbeme a @BitNeuronal en X. Los casos raros son el tipo de conocimiento que quiero ir acumulando acá.
Y si llegaste hasta aquí y te sirvió, reenvíalo a alguien que esté peleándose con fine-tuning. El mode collapse por contaminación es un error que casi nadie documenta — saberlo antes de pasar las dos horas es el tipo de favor que uno se hace entre amigos.
Nos leemos el domingo.
— Olivers
BitNeuronal