rukh · lab

// M4 · lección 01

Fine-tuning: el agujero y la teoría justa

Media escala de Elo que nunca se entrenó, el corpus que faltaba y por qué el de afinado es plano; después la teoría justa: afinar es cambiar de distribución, instrucción con un token, LoRA escrita a mano y comprobada contra peft, el decoder vestido de PreTrainedModel, y qué demuestra QLoRA.

  • 120 min
  • nivel medio
  • vigente
  • actualizado el21 de septiembre de 2026

Parte 1 de 3 del módulo «Fine-tuning e instrucción». Sigue en «Fine-tuning: cómo se mide y lo que salió».

Qué vas a construir

Al terminar esta sección sabrás qué deja de ser el modelo y en qué se convierte, qué cinco artefactos salen de aquí y cuál es la pregunta que el módulo contesta.

Hasta ahora el proyecto tenía un modelo. Al terminar este módulo tiene una familia: el mismo decoder de M2, con los mismos 115 120 128 parámetros, en cinco versiones que se diferencian en lo que se les enseñó después de aprender a jugar. Una obedece una instrucción que viaja en la propia entrada —«juega como alguien de 1500»—, otra ha pasado una última mano de partidas de maestros, dos son ficheros de un megabyte y medio que se enganchan encima y le cambian el repertorio, y la quinta no es nuestra: es un Qwen3 de 600 millones de parámetros afinado con las mismas partidas, que está aquí para responder a la pregunta que este curso lleva tres módulos sin contestar.

La pregunta del módulo es una sola: ¿qué cambia un afinado y qué no? Y la respuesta, medida cinco veces por caminos distintos, es que cambia el comportamiento y no cambia la competencia. Un adaptador de 1,6 MB —el 0,34 % del modelo— sube la primera jugada 1. e4 del 59,64 % al 99,85 % sin costar una décima de legalidad, de exactitud ni de puzles: control total de una decisión, gratis. Y ninguno de los dos afinados completos mueve el Elo de forma demostrable: los intervalos se solapan todos con los del modelo de partida.

Eso no es un fracaso del método, es su forma. Predecir el siguiente token sobre partidas humanas enseña qué se juega a cada nivel; nunca premia calcular mejor. Mover la competencia es el trabajo de M5 —recompensas, DPO, GRPO— y al terminar este módulo vas a saber exactamente por qué hace falta otra herramienta.

Vas a construir:

  • Un corpus rebalanceado. No es un detalle de intendencia: es el hito entero. La mitad baja de la escala de Elo nunca se había entrenado, y hasta arreglar eso ninguna otra cosa de este módulo podía funcionar.
  • rukh eval sweep, que corre la suite una vez por condición cambiando un solo número, y que responde a la pregunta con dos veredictos separados en vez de uno: si las estimaciones suben, y si los intervalos se separan. No son lo mismo y la diferencia ya nos costó una conclusión que hubo que retirar.
  • Y las tres herramientas con las que se distingue «no hay efecto» de «no lo he medido bien», que son la mitad del módulo y la que más se reutiliza: la aritmética de partidas necesarias, la corrida de control sobre el modelo sin tratar, y la medida de cuánto se mueve el montaje cuando no se cambia nada.
  • src/rukh/models/lora.py, LoRALoRALow-Rank Adaptation: en vez de modificar una matriz de pesos W, se aprende una corrección B·A de rango r (A de r × d, B de d × r, con B inicializada a cero para que el modelo adaptado sea el base en el paso 0) y se suma a W escalada por alpha/r. Entrena pocos parámetros —en `medium`, 393 216 con r = 8 sobre consulta y valor, el 0,34 %—, se guarda como un adaptador de 1,6 MB y se puede cambiar en caliente. QLoRA hace lo mismo sobre un modelo base cuantizado a 4 bits. escrita a mano, con la prueba que hace honesto escribirla: la misma configuración entrenada con nuestra implementación y con peft optimiza el mismo número, paso a paso.
  • src/rukh/models/hf.py, el decoder vestido de PreTrainedModel, que es lo que permite hacer esa comparación —y lo que abre la puerta a SFTTrainer, DPOTrainer y GRPOTrainer en M5 sin escribir ninguno.
  • Dos adaptadores de estilo de 393 216 parámetros cada uno —el 0,34 % del modelo— entrenados sobre 200 000 partidas que empiezan 1. e4 y 200 000 que empiezan 1. d4.
  • Un QLoRA de Qwen3-0.6B sobre las mismas partidas escritas como PGN, evaluado con el mismo harness, incluida una columna que el decoder no puede ni tener: cuántas de sus respuestas no eran jugadas.
  • Y en la demo, dos controles nuevos: el selector «juega como…», con las condiciones que están medidas y ninguna más, y el de estilo, que carga un adaptador de 1,6 MB sobre el modelo ya descargado. Ese segundo obliga a exportar el ONNX de otra manera —con los factores de LoRA como entradas del grafo— y es la diferencia entre 1,6 MB por estilo y 221 MB por estilo.

El agujero: media escala que nunca se entrenó

Al terminar esta sección sabrás por qué el condicionamiento por Elo no funcionaba, cómo se encontró, y por qué la respuesta estaba en un fichero YAML de P1 y no en el modelo.

El vocabulario de Rukh tiene, desde M1, veintisiete tokens de Elo por bando: <w0600>, <w0700>, …, <w3200>, y sus gemelos <b….>. Cada partida de entrenamiento empieza con tres tokens — <bos> <wXXXX> <bXXXX>— y a partir de ahí las jugadas. La idea es instruction tuningInstruction tuningAfinar un modelo para que obedezca una instrucción que viaja en la propia entrada, en vez de aprender una tarea fija. En Rukh la instrucción son los dos tokens de cabecera `<wXXXX> <bXXXX>`: «juega como alguien de este nivel». Existían desde P1, pero el corpus solo tenía partidas de 1800 en adelante, así que la mitad baja de la instrucción nunca se entrenó y pedirla no hacía nada. en su forma más pequeña que se pueda: la orden no es una capa ni una cabeza, es un token; el modelo aprende qué sigue a ese token porque lo ha visto seguido de partidas de gente con esa fuerza.

En M2 medimos que el eje existía. En el hito de la barrera de 1200 volvimos a medirlo y salió una conclusión rara: pedirle al modelo que jugara a 2600 no lo hacía mejor. Se archivó como «imitar a un fuerte no compra táctica», que es verdad y no era toda la verdad.

Lo que faltaba estaba aquí:

configs/data/lichess-2025-01-02.yaml
min_elo: 1800

Ese filtro se aplica a los dos jugadores, y es el filtro con el que se descargó todo. Los 5,9 millones de partidas del corpus base, los 13,1 millones de la Elite Database, los 19 millones de tokens-v4 sobre los que se entrenó el modelo que juega en la demo: todo, de 1800 para arriba. El manifiesto del conjunto balanceado de P1 lo dice sin ambigüedad ninguna: su bin más bajo es el 1800.

Así que los doce tokens por debajo de 1800 —<w0600> a <w1700>nunca recibieron un gradiente. Su embedding sigue exactamente donde lo dejó la inicialización: un vector normal de media cero y desviación 0,02, sin relación con nada. Pedirle al modelo «juega como 1500» no era pedirle que jugara peor. Era ponerle delante ruido.

Es el botón de un ascensor que lleva a un piso que no existe: está en el panel, se ilumina al pulsarlo y el ascensor no va a ninguna parte. Nadie quitó el botón; solo faltaba el piso.

el vocabulario · 27 tokens por bando, desde P1el corpus de preentrenamiento · min_elo 1800, los dos jugadoresdoce tokens sin un solo gradientepedirle «juega como 1500» le enseña un vector de la inicializacióntras el afinado de M4 · corpus plano de 1000 a 2600, encima del anteriorcuatro siguen sin gradiente: por debajo de 1000 no se descargó nada600100014001800220026003000
El eje existía en el vocabulario desde el primer día. Lo que no existía eran los datos con los que aprender qué significa la mitad izquierda; el afinado de M4 rellena de 1000 en adelante y deja cuatro tokens, de 600 a 900, tal y como estaban.

Vale la pena detenerse en cómo se ve un fallo así, porque no se ve. No hay excepción, no hay aviso, no hay test que falle: el token existe en el vocabulario, el tokenizador lo emite sin quejarse, el modelo lo lee y produce una distribución perfectamente normal sobre jugadas legales. Lo único que pasa es que la distribución no cambia de la manera que uno esperaría, y eso se confunde con facilidad con «el condicionamiento no funciona». Lo que lo destapó no fue leer el modelo: fue mirar el histograma del corpus antes de volver a entrenarlo.

El corpus que faltaba

La reparación es la mitad baja del eje, descargada con el mismo filtro salvo el rango: mismos 180 segundos de base, mismas terminaciones, sin variantes, mínimo 20 plies. Solo cambia que min_elo es 1000 y aparece un max_elo de 1799, y que va a su propio directorio, porque data/uci es lo que entrenó todo lo publicado y mezclarle una banda más débil cambiaría en silencio lo que significa.

Y aquí hay una lección de infraestructura que no estaba en el plan. La descarga original de P1 leía los parquet remotos directamente con DuckDB sobre hf://, que es el diseño elegante: solo las filas que pasan el filtro llegan a tocar el disco. Dejó de funcionar. Un mes son 72 ficheros de un gigabyte, leerlos en remoto son miles de peticiones de rango contra un mismo anfitrión, y el anfitrión contesta HTTP 429 a mitad de camino. El segundo intento, ya autenticado y con ocho reintentos, se quedó cincuenta minutos a 0,02 MB/s con 8,6 GB de buffer en memoria y cero bytes escritos. Descargar el mismo shard entero, de una pieza, va a 14,8 MB/s.

Así que ahora cada shard se descarga una vez, se filtra en local y se borra. El pico de disco es un fichero, el mes se reanuda donde se quedó, y limit para la descarga en vez de solo recortar el resultado: con 505 547 partidas de la banda 1000-1799 en el primer shard, 1,2 millones salen de tres. La descarga entera pasó de cincuenta minutos atascada a noventa segundos.

Paso Partidas
Partidas descargadas (1000-1799, dos meses) 2 400 000
Convertidas a UCI 2 292 475
Ilegales encontradas 0
Descartadas por cortas (< 20 plies) 107 513
Descartadas por largas (> 300 plies) 12

Por qué el corpus de afinado es plano y el mundo no

Con las dos mitades en disco, el corpus del afinado se construye plano: el mismo número de partidas por banda de 200 Elo, de 1000 a 2600.

Eso no es lo que hay en Lichess ni de lejos, y es a propósito. Un corpus con la forma real le enseña al modelo que <w1500> es raro, que no es lo mismo que enseñarle qué significa. La frecuencia de una condición y su contenido son dos cosas distintas, y aquí solo interesa la segunda: el modelo tiene que saber qué viene después de cada token de Elo, no con qué probabilidad aparece. Un diccionario le dedica la misma entrada a «casa» que a «abstruso» aunque la primera se use mil veces más, porque lo que se le pide al diccionario es qué significa cada palabra, no cuántas veces sale. El corpus plano es ese diccionario: una entrada del mismo tamaño para cada cabecera.

Banda Partidas
1000 150 000
1200 150 000
1400 150 000
1600 150 000
1800 150 000
2000 150 000
2200 150 000
2400 146 232
2600 23 191

Las dos últimas filas son el límite de los datos y se publican tal cual. No hay 150 000 partidas de 2600+ en dos meses de Lichess; hay 23 191, y ninguna manera honesta de fabricar las que faltan. El corpus queda plano donde puede y se estrecha donde el mundo se estrecha, y como el criterio de GOAL.md vive entero dentro de la parte plana (1500, 2000 y 2400), eso no lo compromete. Total: 1 219 423 partidas, 95 722 318 tokens.

Y por abajo el corpus también se acaba, esta vez por decisión: la descarga empieza en 1000, así que <w0600>, <w0700>, <w0800> y <w0900> siguen sin un solo gradiente después de este módulo. El eje que se arregla va de 1000 en adelante; esas cuatro cabeceras siguen siendo el botón del piso que no existe, y por eso ni el barrido de la parte 2 ni el selector de la demo las ofrecen.

Contado sobre el flujo empaquetado, token a token, así queda la cabecera que el modelo lee:

Token Partidas Token Partidas
<w1000> 59 335 <w1900> 79 730
<w1100> 87 662 <w2000> 85 267
<w1200> 71 956 <w2100> 68 393
<w1300> 78 537 <w2200> 85 367
<w1400> 71 209 <w2300> 68 169
<w1500> 79 820 <w2400> 81 258
<w1600> 81 664 <w2500> 47 622
<w1700> 69 817 <w2600> 19 073
<w1800> 76 633 <w2700>+ 7 911

Qué costó el afinado

medium-v4 afinado 3 800 pasos sobre ese corpus, a la tasa de afinado del spec (1e-4, dieciséis veces por debajo del preentrenamiento), son 194,56 M de tokens: unas dos pasadas.

La validación mide imitación de juego 1800+, que es justo lo que este afinado deja de optimizar, así que se espera que empeore. Empeoró, y poco:

pérdida de validación top-1 de validación
medium-v4 (paso 48 000) 1,3733 54,73 %
medium-elo (paso 3 800) 1,3987 54,66 %

Veinticinco milésimas de nat y siete centésimas de punto de top-1. Es el precio del eje, medido sobre el mismo conjunto y con el mismo medidor. Lo que queda por saber —y es lo que decide el hito— es qué compró.

// Ejercicio 01¿Por qué best.pt es exactamente el paso 200, y no el 0 ni el 3 800?

Mira configs/train/medium-elo.yaml (eval_every, warmup, max_steps) y la rama del bucle que guarda best.pt en src/rukh/train/loop.py. Antes de leer la solución, escribe en qué paso se quedaría best.pt si eval_every fuera 50, y qué tendría que pasar con la pérdida de validación para que se quedara en el último paso.

// SoluciónVer la solución

Tres hechos y ninguno es del modelo. Primero, el bucle no evalúa en el paso 0: best_val empieza en infinito y la primera evaluación llega cuando done % eval_every == 0, es decir, en el paso 200. Segundo, esa primera evaluación siempre mejora a infinito, así que best.pt se escribe en el 200 pase lo que pase. Tercero, la validación mide imitación de partidas de 1800+ y el corpus del afinado tiene el 49 % de partidas por debajo, así que la pérdida de validación sube desde la primera evaluación y ninguna posterior bate a la del 200. Con eval_every: 50 se quedaría en el paso 50, un modelo todavía más intacto (y dentro del warmup de 200 pasos, con la tasa de aprendizaje aún subiendo). Para que best.pt fuera el paso 3 800 haría falta que la pérdida de validación bajara durante el afinado, o sea, que el corpus nuevo se pareciera más a la validación que el viejo, que es justo lo contrario de lo que este afinado hace a propósito. Por eso el fichero correcto es step-3800.pt y por eso el bucle lo avisa por escrito.

Teoría justa

Al terminar esta sección podrás explicar qué distingue un afinado de un preentrenamiento, por qué una matriz de rango 8 puede mover un modelo de 115 millones de parámetros, qué gana el proyecto vistiendo su decoder de PreTrainedModel y qué demuestra —y qué no— hacer QLoRA a esta escala.

Afinar no es cambiar de objetivo, es cambiar de distribución

Lo primero que hay que quitarse de encima es la idea de que el fine-tuning supervisado es una técnica. No lo es. Es el mismo bucle, la misma pérdida, el mismo optimizador; lo único que cambia es de dónde salen los lotes. medium-elo se entrena con entropía cruzada sobre el siguiente token, exactamente igual que medium-v4; con otros datos y con la tasa de aprendizaje dividida por dieciséis.

Un cocinero formado en España que se va a trabajar a Japón no vuelve a aprender a cocinar. Corta, sazona y controla el fuego igual; lo que cambia es la despensa, y con la despensa cambian los platos que salen. Afinar es eso: el mismo oficio con otros ingredientes, no otro oficio.

Eso tiene dos consecuencias prácticas que conviene tener claras antes de tocar nada.

La primera es que el afinado hereda todo lo que el preentrenamiento aprendió, incluido lo que uno preferiría que olvidara, y al revés: puede deshacer cosas que nadie le pidió deshacer. Eso es el olvido catastróficoOlvido catastróficoCuando afinar un modelo con datos nuevos le hace perder lo que sabía. No avisa: la pérdida sobre los datos nuevos baja mientras la capacidad vieja se deshace. La defensa no es una técnica sino una medición: se afina con tasa baja, pocos pasos, y se vuelve a medir contra el listón anterior. En M4 el listón es el Elo del modelo base; si el afinado por Elo lo hundiera, el eje habría salido caro., y no se evita con un truco sino con una medida. La defensa de este módulo es aburrida y es la correcta: tasa baja, pocos pasos, y volver a medir el listón anterior. Si el Elo del modelo condicionado en su mejor condición se hundiera respecto a los 1504 de medium-v4, el eje habría salido caro y habría que decirlo.

La segunda es que la pérdida de validación deja de ser el objetivo. Validamos sobre 100 000 partidas de 1800+, y el afinado entrena sobre un corpus donde 600 000 de 1 219 423 partidas —el 49 %— están por debajo de 1800. Que la pérdida suba no es un problema, es la definición de lo que se está haciendo. El error sería leer esa subida como «va mal» y parar antes de tiempo, o —peor— guardar el checkpoint de menor pérdida y publicarlo. Seguir mirando esa pérdida como si fuera el objetivo es leer el termómetro de la casa de la que te acabas de mudar: mide perfectamente, pero mide otra casa.

Instruction tuning con un token: qué puede y qué no

<w1500> es una instrucción. No hay parser, ni plantilla, ni «System:». La instrucción es el contexto, y el contexto es lo único que un decoder causal tiene.

Rukh · tokens de control<bos><w1800><b1900>e2e4e7e5Un LLM de chat · plantilla<|im_start|>systemEres…<|im_end|><|im_start|>userFew-shot · los ejemplos van dentrotexto→etiquetatexto→etiquetatexto→tokens de controlcontenidolo que se pide completar
La misma idea con tres disfraces. Los dos tokens de cabecera de Rukh, la plantilla de chat de un LLM y los ejemplos de un prompt few-shot son una sola secuencia plana: unos pocos tokens acordados condicionan lo que viene después. Cuando la parte 2 mida que «juega como 1200» cambia el repertorio, estará midiendo un prompt de sistema de un token.

Lo que esto puede hacer es exactamente lo que hace un modelo de lenguaje: cambiar la distribución de lo que viene después. Si el corpus contiene partidas de 1500 precedidas de <w1500> y partidas de 2400 precedidas de <w2400>, el modelo aprende dos distribuciones distintas sobre jugadas y elige según el prefijo. Es imitación condicionada, y es lo mismo que hace Maia salvo que Maia entrena un modelo por nivel y aquí es uno solo con un interruptor.

Lo que no puede hacer es inventar fuerza que no está en los datos. Pedir <w2600> no convierte al modelo en un 2600: le pide que imite las elecciones de un 2600, y esas elecciones sin la búsqueda táctica que las produjo son un 2600 perdiendo partidas por táctica. Esa parte de la conclusión del hito anterior sigue en pie y es importante que siga: el eje es una herramienta para bajar, y la pregunta de este módulo es si baja de forma medible y ordenada.

LoRA: la corrección tiene que caber por un cuello de ocho

Afinar los 115 millones de parámetros deja un checkpoint de 1,39 GB, y un proyecto que quiera cinco estilos necesita cinco copias del mismo modelo con diferencias minúsculas. Conviene tener los tamaños en la cabeza, porque el módulo va a manejar tres y son el mismo modelo, contados en megabytes decimales: 460 MB en float32 (461 621 570 bytes, el model.onnx de medium-v4); 231 MB el model-fp16.onnx que descarga la demo (221 MiB: la cifra que muestra la interfaz y la que verás por todo el curso); y 1,39 GB el checkpoint best.pt con los dos momentos de Adam (1 387 921 435 bytes: tres copias de los pesos). Afinar entero cuesta el tercero; publicar, el segundo.

Frente a eso, un adaptador es una fe de erratas. En vez de reimprimir el libro entero con las veinte líneas que cambian, se imprime una hoja con las veinte líneas y el libro se comparte. Cinco estilos son cinco hojas y un libro. La LoRALoRALow-Rank Adaptation: en vez de modificar una matriz de pesos W, se aprende una corrección B·A de rango r (A de r × d, B de d × r, con B inicializada a cero para que el modelo adaptado sea el base en el paso 0) y se suma a W escalada por alpha/r. Entrena pocos parámetros —en `medium`, 393 216 con r = 8 sobre consulta y valor, el 0,34 %—, se guarda como un adaptador de 1,6 MB y se puede cambiar en caliente. QLoRA hace lo mismo sobre un modelo base cuantizado a 4 bits. plantea otra pregunta: en vez de mover W, aprender una corrección B·A cuyo rangoRango (de una matriz)Cuántas direcciones independientes describe una matriz. Una de 768×768 puede tener hasta 768; el producto A·B de LoRA con r=8 tiene como mucho 8, y ahí está todo: es lo que lo abarata (12 288 números en vez de 589 824) y también lo que lo limita, porque cualquier corrección que el adaptador quiera aprender tiene que caber en esas ocho direcciones. sea lo bastante pequeño como para que los dos factores juntos no pesen nada al lado de W.

entrada768W · congelada768 × 768589 824 númerosB · A · el adaptadorA8 × 768r = 8768 → 8 → 768: la señal se aplasta y vuelve a abrirseB768 × 812 288 números · el 2,1 % de W+salida768
La corrección no puede ser cualquier cosa: tiene que caber por ocho dimensiones, y en el cuello la señal se aplasta hasta pasar. Eso es lo que la abarata y también lo que la limita.

La aritmética es la mitad del argumento. Una proyección de consulta de medium es una matriz de 768 × 768 = 589 824 números. Su adaptador con r = 8 son dos matrices, 8 × 768 y 768 × 8: 12 288 números, el 2,08 %. Sobre las proyecciones de consulta y valor de las dieciséis capas, 393 216 parámetros entrenables de 115 120 128, el 0,34 %, que en float32 son 1 572 864 bytes: 1,6 MB.

La otra mitad del argumento es el cuello. Todo lo que el adaptador pueda aprender tiene que pasar por ocho dimensiones por matriz; esa es la razón por la que es barato y también el techo de lo que puede hacer. Sirve para inclinar un modelo hacia un estilo, un dominio o un formato. No sirve para enseñarle algo que no sabe.

// Ejercicio 02Cuenta el adaptador que se le dio a Qwen, pero sobre nuestro decoder

La parte 2 afina Qwen3 con r = 16 sobre las cuatro proyecciones de atención. Calcula cuántos parámetros tendría ese mismo adaptador —r = 16 sobre q, k, v y attn.proj— en medium (dieciséis capas, d_model 768), qué fracción del modelo es y cuánto pesa en float32. Después di por qué la fórmula del lab 4, n_capas × n_objetivos × 2 × r × d_model, vale para esas cuatro matrices y no valdría para mlp.fc.

// SoluciónVer la solución

Cada matriz adaptada recibe una A de r × 768 y una B de 768 × r: 2 × 16 × 768 = 24 576 números. Cuatro matrices por capa son 98 304, y dieciséis capas, 1 572 864 parámetros. Sobre 115 120 128 es el 1,37 %, cuatro veces el adaptador de estilo (r = 8 sobre dos matrices: 393 216, el 0,34 %), y en float32 son 6 291 456 bytes, 6,3 MB. Fíjate en la coincidencia: 1 572 864 es exactamente el número de bytes del adaptador de estilo, porque duplicar el rango y duplicar las matrices multiplica por cuatro, igual que pasar de contar números a contar bytes.

La fórmula vale porque las cuatro matrices tienen entrada y salida de 768: q, k y v son tres franjas de 768 filas de la misma qkv de 768 × 2304, y attn.proj es una 768 × 768. mlp.fc es 768 × 3072: su A seguiría siendo r × 768, pero su B sería 3072 × r, y el término 2 × r × d_model ya no describe las dos. Es el mismo hecho por el que el exportador de la parte 3 admite q, k y v en el grafo y no admite el MLP: los factores viajan como un tensor por rango y tienen que medir todos lo mismo.

Y eso no es una metáfora, es una propiedad que se mide con una línea de álgebra. Los valores singulares de una matriz son las direcciones por las que puede empujar, y los del adaptador entrenado son exactamente ocho: el noveno es cero salvo redondeo de float32 (2,8·10⁻⁷ frente a 0,18 del octavo).

W768 × 768 · 768 valores singulares, ninguno cero(cada fila a su escala)ΔW = (α/r)·B·A· 8 valores singulares · 4,4 % de la norma de Wa partir de aquí son cero, no pequeños15101520índice
El rango no es una metáfora: son ocho direcciones, y la novena es cero salvo redondeo de float32 (2,8·10⁻⁷ frente a 0,18 de la octava). Cada fila va a su propia escala, porque en norma la corrección es una vigésima quinta parte de la matriz y a una sola escala no se vería. Medido sobre el adaptador publicado con labs/m4/lora_spectrum.py.

Sobre el adaptador que se publica en este módulo, la matriz de consulta del primer bloque tiene 768 valores singulares y ninguno es cero; su corrección tiene 8, y en norma de Frobenius pesa el 4,41 % de la matriz que corrige. Un adaptador es a la vez muy pequeño y capaz de cambiar del todo una decisión, y las dos cosas salen de la misma imagen.

Dos detalles de la implementación que no son cosméticos:

B empieza exactamente en cero. Así el modelo adaptado es el modelo base en el paso 0. Cualquier otra inicialización movería los pesos antes de que llegara un solo gradiente, y toda comparación contra la base empezaría desde un modelo que ya es distinto.

Cada matriz adaptada tiene su propio subespacio. Esto importa aquí más que en la mayoría de implementaciones, porque la atención de Rukh usa una proyección qkv fusionada: no hay un módulo q_proj que envolver, hay una sola nn.Linear de 768 × 2304 que produce consulta, clave y valor pegadas. Adaptar «consulta y valor» significa adaptar dos rangos contiguos de salida de esa matriz, y cada uno recibe su propia A y su propia B. Compartir una A entre los dos sería más barato y no sería LoRA: el punto del método es que cada matriz adaptada tiene su propio subespacio de rango r.

src/rukh/models/lora.py
TARGETS = {
"q": ("attn.qkv", 0, 1), # el primer tercio de la salida fusionada
"k": ("attn.qkv", 1, 2),
"v": ("attn.qkv", 2, 3),
"attn_out": ("attn.proj", 0, 1),
"mlp_in": ("mlp.fc", 0, 1),
"mlp_out": ("mlp.proj", 0, 1),
}

Cómo se comprueba que lo que escribiste es LoRA

Escribir a mano una técnica publicada tiene un riesgo evidente: que lo escrito se parezca a la técnica y no lo sea. Un alpha aplicado en el sitio equivocado, A y B intercambiadas, una inicialización distinta —cualquiera de las tres da un modelo que entrena, converge y produce números creíbles, y ninguna da LoRA. Es la misma razón por la que nadie que implementa un algoritmo de cifrado se conforma con que «parece que cifra»: lo pasa por los vectores de prueba oficiales, con entradas y salidas publicadas, porque una implementación que se desvía en un bit también produce una salida perfectamente ilegible. Aquí los vectores de prueba son peft.

La prueba está en tests/unit/test_hf_wrapper.py y es la razón por la que existe la envoltura HF de la sección siguiente. Se montan dos modelos sobre los mismos pesos base: uno con apply_lora, otro con peft.get_peft_model. Se copia nuestra A en la suya, para que la comparación sea de las matemáticas y no de dos sorteos aleatorios (B es cero en los dos lados por construcción). Y entonces los dos dan pasos de SGD sobre el mismo lote, con la misma tasa, y se comparan las pérdidas paso a paso:

for step, (a, b) in enumerate(zip(my_curve, their_curve, strict=True)):
assert a == pytest.approx(b, abs=1e-5), f"step {step}: {a} vs {b}"

Si la regla de actualización difiriera en cualquier detalle —el escalado, de qué lado multiplica A, si B arranca en cero— las curvas se separarían en el primer paso. Lo que sale es más fuerte que «no se separan»:

4. step ours peft |delta|
0 4.17093372 4.17093372 0.00e+00
1 4.16829062 4.16829062 0.00e+00
2 4.16558743 4.16558743 0.00e+00
3 4.16275978 4.16275978 0.00e+00
4 4.15973043 4.15973043 0.00e+00
5 4.15640736 4.15640736 0.00e+00

Idénticas hasta el último bit, seis pasos seguidos. Eso es lo que convierte «implementamos LoRA» en una afirmación comprobable en vez de en la palabra del autor. El mismo script (labs/m4/lora_check.py) comprueba antes las otras tres: el adaptador sin entrenar es la identidad con max |delta| = 0.00e+00, el recuento de parámetros es exactamente la fórmula, y fundir es exacto a 1.19e-07 sobre el decoder de juguete —sobre medium, con dieciséis capas de verdad, son 6,8e-5 sobre logits de escala 18,9, y la jugada elegida no cambia en ninguna posición.

Vestir el decoder de PreTrainedModel

El decoder de este curso es PyTorch a secas, a propósito: leerlo no debería exigir conocer el framework de nadie. Pero el resto del ecosistema habla PreTrainedModel, y una envoltura son cien líneas mientras que reimplementar media librería no lo es. Es un adaptador de enchufe: no cambia el aparato, cambia la clavija para que entre en la pared de otro país.

(Dicho lo cual: M5 acabó escribiendo DPO y GRPO a mano, por lo mismo que este módulo escribió LoRA a mano. Con una jugada por respuesta, la pérdida de DPO son dos log-probabilidades en una posición y la de GRPO es una media de un grupo: los dos ficheros juntos son más cortos que la configuración que haría falta para que DPOTrainer tratara este caso. La envoltura sigue siendo lo que permite elegir, y esa es su función.)

La envoltura contiene un MoveDecoder, no reimplementa uno. Hay una sola definición del paso hacia delante en todo el repositorio, y el test de que las dos dan los mismos logits comprueba que el envoltorio es fiel, no que una segunda implementación no se ha desviado. Los tensores son los mismos objetos, que es también por qué from_decoder no cuesta nada.

Tres cosas que aprendimos montándola y que no están en ningún tutorial:

  • labels aquí no se desplaza. Casi todos los modelos causales de transformers desplazan las etiquetas dentro de forward. El flujo empaquetado de este proyecto ya guarda y un paso por delante de x, así que volver a desplazarlas entrenaría al modelo a predecir la jugada de después de la siguiente —y la pérdida seguiría teniendo buena pinta.
  • El método de la config no se puede llamar decoder. transformers lee un atributo de la config con ese nombre como la mitad decodificadora de un par encoder-decoder e intenta llamarle to_dict(), lo que convierte un método enlazado en una excepción la primera vez que algo pide una config de generación.
  • Hay que declarar los pesos atados. lm_head.weight comparte almacenamiento con la tabla de embeddings; safetensors guarda tensores, no alias, y rechaza un state dict con dos nombres para un tensor salvo que se le diga cuál es la copia. Es un enlace simbólico: un segundo nombre para el mismo fichero, y un copiador que no sepa de enlaces guarda dos ficheros o se niega a guardar ninguno. Es el mismo hecho que rukh.train.checkpoint.TIED_SOURCES registra para el formato propio del proyecto.

QLoRA a 0,6 B: qué demuestra y qué no

QLoRA es LoRA sobre un modelo base cuantizadoCuantizaciónGuardar y calcular los pesos con menos bits de los que se entrenaron: fp16 (la mitad de tamaño, sin pérdida apreciable) o int8 dinámico (una cuarta parte, con algo de error). En Rukh se cuantizan solo las matrices (`MatMul` y `Gemm`) y cada fichero pasa una prueba de paridad contra PyTorch antes de subirse. a 4 bits. La razón de existir de la cuantización ahí es la memoria: permite afinar un modelo de 70 B en una GPU de consumo, que sin ella no cabría.

A 0,6 B parámetros en una tarjeta de 32 GB no hay nada que ahorrar. El modelo en bf16 son 1,2 GB: es comprimir una foto de 10 KB para que quepa en un disco de un terabyte. La compresión funciona, y no hacía falta. Así que lo honesto es intentarlo, medir el pico de memoria con y sin, y decir cuál de las dos cosas pasó —y no fingir que se ha demostrado una técnica cuya única ventaja no aplica a la escala en la que se ejecutó. El código lo hace explícito: si bitsandbytes no carga, la corrida cae a bf16 y lo escribe en su informe.

Lo que sí se demuestra con Qwen, y es el motivo de que esté en el módulo, es otra cosa: si merecía la pena escribir un modelo propio. Y para que esa respuesta valga, las dos mitades tienen que diferenciarse en una sola cosa.

  • Las partidas son las mismas, del mismo mes, con la misma validación reservada.
  • Las métricas son las mismas, porque el Qwen afinado entra en el harness de M2 como un jugador más y juega contra la misma escalera de Stockfish.
  • La representación es lo que cambia, y es justo el punto. El decoder lee un token por jugada legal. Qwen lee 1. e4 e5 2. Nf3 partido en los trozos que su BPE decida, y nada en él le impide escribir una jugada que no existe.

Esa última frase es una columna de la tabla. Un decoder cuyo vocabulario es el conjunto de jugadas solo puede fallar de una manera: una jugada legal en la posición equivocada. Un modelo que escribe SANSANNotación algebraica estándar, la de los libros: `Nf3`, `O-O`, `exd5+`. Es compacta para humanos pero ambigua sin el tablero (hay que saber qué caballo puede ir a f3). Lichess publica las partidas en SAN; Rukh las convierte a UCI con `python-chess`. tiene tres maneras más, y separarlas es la mitad de lo que enseña la comparación:

Fallo Qué significa ¿Puede el decoder?
ilegal aquí jugada bien formada que esta posición no permite
no es una jugada Nf9, O-O-O-O, una frase no
SAN ambigua dos piezas podrían hacerla y no desambiguó (Nd2 en vez de Nbd2) no
nada no escribió ningún token no

Es la diferencia entre un desplegable y un campo de texto libre en un formulario. El desplegable no admite un país que no existe; el campo de texto admite «Espanya», «España » con un espacio al final y «asdf», y cada una de esas tres es un error distinto que hay que contar aparte. El decoder es el desplegable. Qwen es el campo de texto.

Hasta aquí el agujero y la teoría: qué faltaba en los datos, qué es afinar, qué cabe por un cuello de ocho y qué se le va a pedir a un modelo general. La parte siguiente es la medición: por qué el criterio del hito son dos afirmaciones y no una, qué salió del barrido por condición y del control, y qué mueve de verdad un adaptador de 1,6 MB. Sigue en «Fine-tuning: cómo se mide y lo que salió».