¿Merece la pena un LoRA más grande? Mide la precisión frente al coste, no por intuición
Un pequeño banco de pruebas que trata las recetas de LoRA como datos y reporta precisión, tiempo de entrenamiento, latencia y memoria, para que la elección de rango e iteraciones se base en números.
Una carpeta con trece experimentos
Imagina una carpeta con trece experimentos de fine-tuning. Abarcan transferencia de estilo, extracción de JSON, enrutamiento de herramientas, ajuste por preferencias para mensajes de commit, destilación de cadena de razonamiento y una ejecución multimodal que lee gráficos de barras. Todos son LoRA sobre un Llama de 1B, entrenados en local en un Mac con chip M mediante MLX. El experimento número doce fue el que cambió la forma de trabajar. No le enseña nada nuevo al modelo. Solo mide bien a los demás.
La pregunta que se responde a ojo
Pongamos una tarea de clasificación. Podría ser enrutar tickets de soporte. Aquí se trataba de etiquetar pull requests de GitHub con categorías de conventional commits: bug, feat, docs, refactor, test, chore, perf y style. Son ocho clases con conjuntos de entrenamiento y prueba bien separados.
Haces fine-tuning de un modelo pequeño y funciona. Luego llega la pregunta que siempre llega: ¿rendiría mejor el rango 16 que el rango 4?
Lo habitual es responderla razonando. El rango fija la dimensionalidad de la actualización de bajo rango, más rango significa más capacidad, la tarea tiene ocho clases y cierta sutileza lingüística, así que probablemente sí. Entonces se sube el rango, la precisión se mueve una cantidad que bien podría ser ruido, y el cambio se queda porque el tiempo ya está invertido. Eso no es ingeniería. Es una corazonada con un archivo de configuración adjunto.
El verdadero hueco era que nadie se preguntaba si un adaptador más grande se pagaba solo, y para eso hace falta algo entre lo que dividir. Un adaptador de rango 16 sobre 16 capas tiene unas ocho veces más parámetros entrenables que uno de rango 4 sobre 8 capas. Tarda más en entrenar, tarda más en fusionarse y es un artefacto distinto que hay que desplegar. Si aporta un punto de precisión, es mal negocio. Si aporta quince, la decisión es fácil. Sin medir nunca el lado del coste, no había forma de saber en qué situación estabas.
Recetas como simples datos
La solución fue un banco de pruebas basado en una idea aburrida, y precisamente de eso se trata: una configuración de entrenamiento es datos, no código.
RECIPES = [
{"name": "base-zs", "train": None, "shots": 0},
{"name": "base-fs", "train": None, "shots": 4},
{"name": "lora-r4-l8", "train": {"lora_rank": 4, "num_layers": 8, "iters": 600}},
{"name": "lora-r16-l16", "train": {"lora_rank": 16, "num_layers": 16, "iters": 600}},
{"name": "lora-long", "train": {"lora_rank": 8, "num_layers": 8, "iters": 1500}},
]
Cinco entradas de lista, nada más. Para cada una, el barrido lo hace todo: entrena el adaptador, lo fusiona con los pesos base, añade el modelo fusionado a Ollama mediante un Modelfile generado, lo evalúa con el conjunto de prueba reservado y registra el resultado.
Dos de las recetas no implican entrenamiento. Es a propósito, y es la idea que más vale la pena copiar. base-zs es el modelo de 1B tal cual, en zero-shot. base-fs es el mismo modelo con cuatro ejemplos en el prompt de sistema:
FS_EXAMPLES = (
"Title: fix crash when checkout receives empty payload\nBody: Guard added.\nLabel: bug\n\n"
"Title: add bulk delete to dashboard\nBody: New endpoint + UI.\nLabel: feat\n\n"
"Title: bump dependencies for billing\nBody: Routine maintenance.\nLabel: chore\n\n"
"Title: speed up search list query with index\nBody: p95 480ms -> 120ms.\nLabel: perf\n"
)
El few-shot es casi gratis: cuesta un prompt más largo y unos diez minutos de escribir ejemplos. El fine-tuning trae consigo una ejecución de entrenamiento, un artefacto de modelo fusionado, toda una historia de despliegue y el compromiso de reentrenar cada vez que cambie el conjunto de etiquetas. Si no comparas con la opción gratuita, no puedes afirmar que la cara era necesaria. Ninguno de los once proyectos anteriores tenía una línea base few-shot, algo que visto ahora resulta bochornoso.
Cuatro números por receta
Cada receta recibe cuatro mediciones, y el valor está en verlas una al lado de otra:
- La precisión, que es la razón de hacer todo esto
- El tiempo de entrenamiento, como tiempo real medido con
perf_counteralrededor del subproceso real - La latencia de inferencia, usando la mediana de todo el conjunto de prueba y no la media
- La memoria máxima, que te dice si aquello cabe en la máquina que tienes
Los tiempos se miden en lugar de estimarse:
t0 = time.perf_counter()
subprocess.run(cmd, check=True)
train_secs = time.perf_counter() - t0
Se prefiere la mediana a la media porque un único valor atípico de arranque en frío al principio de una ejecución puede arrastrar la media hasta un valor sin sentido, y perderías una tarde persiguiendo un problema en un modelo que está bien.
En un M1 Pro el barrido completo tarda unos 45 minutos, y ahí está la verdadera ganancia. Cuarenta y cinco minutos desatendidos son tan baratos que es más fácil lanzar el barrido que debatirlo, y eso cambia tu forma de actuar por defecto. Las opiniones sobre hiperparámetros dejan paso a una tabla.
Lo que el barrido permite separar
Un hallazgo inesperado no tenía que ver con ningún número concreto. La receta lora-long (rango 8, 8 capas, 1500 iteraciones) existe para separar dos variables que se confunden constantemente. «Un LoRA más grande ayudó» suele significar que se subió el rango y además el entrenamiento duró más, porque la configuración se cambió una vez y se leyó un solo resultado. Son palancas distintas. Más rango añade capacidad, mientras que más iteraciones significa converger más con la capacidad que ya hay. Una tarea puede ir escasa de una y sobrada de la otra, y el remedio cambia por completo según el caso.
Fija el rango en 8 y sube las iteraciones de 600 a 1500, y el efecto queda aislado. Pagas dos veces y media el tiempo de entrenamiento con el mismo tamaño de artefacto y el mismo coste de inferencia. Si el resultado se queda cerca del de 600 iteraciones, la tarea nunca estuvo limitada por capacidad y el rango era una pista falsa. Si se dispara, el modelo estuvo infraentrenado todo el tiempo y el debate sobre el rango apuntaba al mando equivocado. Por mucho que mires un archivo de configuración, no vas a encontrar esa respuesta.
Un breve párrafo final del informe se elige con reglas sencillas: nombra la receta con mayor precisión y la que tiene mejor precisión por segundo de entrenamiento. Que salgan dos ganadoras distintas es lo normal. Nombrar ambas obliga a tomar una decisión de verdad, cuando «gana el número más alto» la habría tomado por ti sin que te dieras cuenta.
Un límite que conviene señalar
El banco de pruebas cubre clasificación, que es el caso fácil: la precisión es una coincidencia exacta con una etiqueta conocida, así que puntuar es solo comparar cadenas, sin coste alguno. Las tareas de generación no funcionan así. Una comparación del mismo tipo en un proyecto con un bucle de automejora tuvo que usar un LLM como juez, con comprobaciones de coincidencia exacta solo para los campos estructurados, y entonces la fiabilidad del juez se convirtió en otra pregunta.
Aun así, la estructura se mantiene. Las recetas siguen siendo datos, el coste se mide con el mismo reloj que la calidad y la tabla conserva sus cuatro columnas. Solo se complica la función de precisión.
En resumen
Casi todo lo que se habla de fine-tuning gira en torno a la calidad, pero la mayoría de las decisiones que de verdad tienes que tomar tienen que ver con el precio. Rango, capas e iteraciones cuestan cosas distintas. Para compararlos con justicia necesitas un banco de pruebas tan rápido de lanzar que a nadie le compense discutir en su lugar. Un trabajo desatendido de 45 minutos sale más barato que un ingeniero muy seguro de sí mismo y se equivoca menos, así que primero haz la tabla y después fórmate la opinión.