English · Español
Lab 00 — LoRALinear a mano¶
🇪🇸 Implementar
LoRALineardesde cero. Tres invariantes innegociables: (1) en init la salida es idéntica a la delLinearbase; (2) sóloAyBreciben gradiente; (3)merge_produce pesos equivalentes byte-a-byte (dentro de1e-5). Sin estos tres, todo lo demás falla en silencio.
Anclas: src/minituner/BLUEPRINT.md; theory/02-parameter-count.md.
Qué produces¶
Un módulo LoRALinear funcional en src/minituner/lora.py que envuelve un nn.Linear existente. Los tests fallidos en tests/test_minituner.py vienen pre-scaffolded por Claude; tu trabajo es rellenar lora.py hasta que pasen.
Entregables en disco:
src/minituner/lora.py—LoRALinear,wrap_minigpt_with_lora,lora_state_dict,load_lora_state_dict.tests/test_minituner.py— en verde.- Una nota breve en markdown
experiments/28-lora-by-hand/notes.mdregistrando: diff inicial de salida vs base, comprobación del flujo de gradientes, equivalencia tras merge.
TODOs (esbozo)¶
Bloque A — LoRALinear identidad en init¶
- Construir
LoRALinear(base: nn.Linear, r=8, alpha=16.0, dropout=0.0, freeze_base=True). - Reservar dos matrices de parámetros:
A ∈ ℝ^{r × in_features},B ∈ ℝ^{out_features × r}. - Inicializar:
Avíakaiming_uniform_(A, a=math.sqrt(5))(coincide con el default de PyTorch para pesos de Linear);B = zeros. - Opcionalmente aplicar
nn.Dropout(p=dropout)a la entrada de la rama LoRA. - Forward:
y = base(x) + (alpha / r) * F.linear(F.linear(dropout(x), A), B). (Equivalente:(α/r) · x @ Aᵀ @ Bᵀ.) - Si
freeze_base: ponerbase.weight.requires_grad = Falseybase.bias.requires_grad = False(si existe bias).
Bloque B — Envolver un MiniGPT¶
wrap_minigpt_with_lora(model, r=8, alpha=16.0, target_modules=("q_proj", ..., "mlp.fc2"), dropout=0.05)recorremodel.named_modules().- Para cada
nn.Linearcuyo nombre con puntos termine en uno de lostarget_modules, reemplázalo in-place por unLoRALinearque envuelve el original. - Tras envolver: comprobar que
sum(p.requires_grad for p in model.parameters() if "lora_" in name) > 0y que todos los params base tienenrequires_grad=False.
Bloque C — Round-trip de checkpoint del adapter¶
lora_state_dict(model)devuelve un dict que mapea nombres totalmente cualificados de parámetros → tensores, restringido aAyBde LoRA (y cualquier escala por módulo si la guardaste como buffer).load_lora_state_dict(model, state)hace lo inverso, validando compatibilidad de formas.- Test de round-trip: guardar → recargar → la salida del forward coincide con la original dentro de
1e-5.
Bloque D — Merge para inferencia¶
merge_(self)calculaW_new = base.weight + (alpha/r) * B @ Ain-place, y luego poneA, Ba cero.- Post-merge:
forward(x)coincide con elforward(x)no fusionado dentro de1e-5(la única diferencia es el redondeo en coma flotante). - Después de fusionar (merge), las matrices LoRA pueden re-aleatorizarse para un nuevo adapter (no lo exijas — sólo no falles si ocurre).
Restricciones¶
- PyTorch está permitido (la Fase 24+ lo desbloqueó). Usa
torch.nn,torch.nn.functional. Sinimport peft. - El forward debe permanecer numéricamente idéntico al base en init — dentro de epsilon de máquina. El test
B = 0 ⇒ LoRA(x) == base(x)es innegociable. - No uses
nn.Linearcomo matrices LoRA — instanciann.Parameterdirectamente. Por qué: hace explícito el conjunto de parámetros; evita bias sorpresa. - No uses
register_module("base", base)a ciegas — asegúrate de que elrequires_grad=Falsedel base se preserva al guardar/cargar. - Reproducibilidad: aplica la semilla vía el fixture de conftest antes de inicializar
A.
Condiciones de parada¶
Has terminado cuando:
pytest tests/test_minituner.py -k "lora_init_identity or param_count or merge or freeze or roundtrip"está en verde.mypy --strict src/minituner/lora.pypasa.notes.mdregistra valores medidos: diff inicial de salida (debe ser0.0), diff pre/post-merge (debe ser< 1e-5), conteo de params entrenables para unLinear(64, 64)conr=8(debe ser1024).
Trampas (específicas de esta práctica)¶
B = zerosnoB ~ small_random. UnBaleatorio hace que el paso 0 no sea identidad; este es el bug de implementación de LoRA más común. Compruébalo explícitamente:assert (LoRALinear(x) - base(x)).abs().max() == 0tras__init__.- Olvidar la escala
α/r. Sin ella, duplicarrduplica la LR efectiva — experimentos confusos. Ponalpha=16.0por defecto; nunca dejes silenciosamentealpha=r. - Modificación in-place de los pesos base.
merge_debe producir un nuevo peso denn.Linearvíadata.copy_o construir unLoRALinearfresco con el nuevo base congelado. Cualquiera vale; no retengas accidentalmente los params LoRA tras el merge. - Fuga de
requires_grad. Si creas elnn.Parameterde LoRA después de ponerbase.requires_grad = False, pero una llamada posterior amodel.train()oto(device)resetea cosas — fácil de pasar por alto. Comprueba la propiedad de freeze explícitamente tras.to(device). - Dropout en la rama LoRA con
model.eval(). El dropout debe estar apagado en eval; si lo implementaste víaF.dropout(..., self.training)crudo, verifica. Más fácil con una instancia denn.Dropout.
Cuándo consultar las soluciones¶
Cuando los tests fallidos dejen de decirte algo nuevo (has mirado uno más de 20 minutos), abre solutions/00-lora-by-hand-ref.md. Las soluciones se escriben después del primer intento de Borja.
Tiempo estimado¶
3-5 horas. La dificultad conceptual es baja (ya has leído theory 02); la dificultad de implementación es moderada (sutilezas de nn.Module y el registro de parámetros en PyTorch).
Siguiente: lab/01-lora-counts.md.