// 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.
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 conpeftoptimiza el mismo número, paso a paso.src/rukh/models/hf.py, el decoder vestido dePreTrainedModel, que es lo que permite hacer esa comparación —y lo que abre la puerta aSFTTrainer,DPOTraineryGRPOTraineren 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. e4y 200 000 que empiezan1. 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í:
min_elo: 1800Ese 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.
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.
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.
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).
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.
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+00Idé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:
labelsaquí no se desplaza. Casi todos los modelos causales detransformersdesplazan las etiquetas dentro deforward. El flujo empaquetado de este proyecto ya guardayun paso por delante dex, 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.transformerslee un atributo de la config con ese nombre como la mitad decodificadora de un par encoder-decoder e intenta llamarleto_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.weightcomparte almacenamiento con la tabla de embeddings;safetensorsguarda 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 querukh.train.checkpoint.TIED_SOURCESregistra 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. Nf3partido 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 | sí |
| 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ó».