Skip to content

English · Español

Break 00 — Poner rank = 0 en LoRA (espacio de actualización degenerado)

🇪🇸 Configuramos rank = 0 en LoRALinear. La factorización B A con B ∈ ℝ^(out × 0) y A ∈ ℝ^(0 × in) da un producto vacío que es matemáticamente la matriz cero. El optimizador no tiene gradiente que ajustar. La pérdida se queda exactamente donde estaba. La predicción es trivial; la lección es por qué el espacio de actualizaciones tiene que tener al menos dimensión 1.

Este ejercicio /break apunta a la restricción rank ≥ 1 en LoRA. El bug es un número; el modo de fallo es cero aprendizaje, lo cual es más sonoro que cualquier salida confusa.

Anclas: theory/02-parameter-count.md, theory/05-lora-on-mini-gpt-exact-count.md, .claude/commands/break.md.


Hipótesis

El aprendiz predice: "Poner rank = 0 hace que B tenga forma (out, 0) y A tenga forma (0, in). El producto B @ A es una matriz cero (out, in). ΔW = (α/r) · B @ A está indefinida (r = 0 divide entre cero) — pero si el código resulta cortocircuitar sobre matmul vacío, ΔW = 0 y la salida del modelo es exactamente la base congelada (frozen weights). La pérdida no se mueve. La accuracy no mejora. El optimizador tiene cero parámetros que actualizar."

El break

En src/minimodel/peft/lora.py:

 class LoRALinear(Module):
-    def __init__(self, base: Linear, rank: int = 8, alpha: float = 16.0):
+    def __init__(self, base: Linear, rank: int = 0, alpha: float = 16.0):  # /break: degenerate
         super().__init__()
         self.base = base
         self.base.weight.requires_grad = False
         if self.base.bias is not None:
             self.base.bias.requires_grad = False
         self.rank = rank
         self.alpha = alpha
         out_f, in_f = base.weight.shape
-        self.A = Parameter(torch.empty(rank, in_f).normal_(std=1 / rank ** 0.5))
+        # /break: rank = 0 forces both A and B to empty tensors.
+        self.A = Parameter(torch.empty(rank, in_f).normal_())
         self.B = Parameter(torch.zeros(out_f, rank))

Un número: rank: 8 → 0. Fíjate que el escalado de inicialización 1 / rank**0.5 se convierte en un ZeroDivisionError si no manejas con elegancia rank = 0 — que es el primer síntoma que Borja verá (un crash en la construcción). El diff de arriba también suaviza ese crash para que el bug sea observable a través del entrenamiento.

Predice, luego ejecuta

Para Mini-GPT con rank = 0:

  • A tiene forma (0, in) — vacía en la dim 0; legal pero degenerada.
  • B tiene forma (out, 0) — vacía en la dim 1; legal pero degenerada.
  • B @ A tiene forma (out, in) y valor 0 (matmul vacío). Tanto NumPy como PyTorch devuelven una matriz cero en este caso.
  • ΔW = (α/r) · B @ A: división entre cero si calculas literalmente α / 0. Si el código usa α * (B @ A) / r es 0 * (1/0) que es nan. Si el código envuelve con if rank == 0: skip, el modelo se comporta como el base congelado (frozen weights).

Predicciones

  • Training loss: no se mueve (o salta a NaN si el escalado se aplica de forma ingenua). El optimizador tiene 0 parámetros entrenables del adapter.
  • Accuracy de eval sobre verbos irregulares §A13: igual a la accuracy del modelo base congelado (probablemente ~60-70%, antes del ajuste fino).
  • p.numel() for p in LoRALinear.parameters() if p.requires_grad: 0. El step del optimizador es un no-op.
  • torch.optim.AdamW: lanza un warning de deprecación o ValueError("optimizer got an empty parameter list") según la versión. Esta es la señal limpia — el optimizador literalmente no tiene nada que optimizar.

Escribe tus predicciones en learners/borja/phase-28/notes/breaks.md antes de ejecutar.

Observa

Ejecuta el ajuste fino LoRA con la config rota:

just exp 28-lora --rank 0

Diagnósticos:

  1. Constructor: debería imprimir LoRALinear(rank=0, trainable=0). Si no lo hace, el conteo numel() está mal reportado.
  2. Construcción del optimizador: debería raise ValueError("optimizer got an empty parameter list") desde PyTorch, o imprimir un warning según la versión.
  3. Si engañas al optimizador con un parámetro dummy y dejas que el entrenamiento siga: la curva de loss es plana (dentro del ruido de coma flotante). La accuracy en el eval de verbos irregulares coincide con la base congelada.

Síntoma que Borja verá

  • O un ValueError en la construcción del optimizador, o
  • una curva de loss completamente plana, o
  • un número de accuracy idéntico a la baseline sin ajuste fino.
  • Sea cual sea la variante que produzca el código, el bug es observable dentro de la primera época — no hace falta entrenar hasta la convergencia.

Causa oculta (una frase)

La descomposición LoRA ΔW = BA con B ∈ ℝ^(out × 0) y A ∈ ℝ^(0 × in) representa el espacio de actualización vacío — el único elemento de las matrices rank-0 es la matriz cero — así que ninguna señal de ajuste fino puede fluir.

Cascada de pistas

  1. Imprime sum(p.numel() for p in self.parameters() if p.requires_grad) para tu LoRALinear. ¿Es cero? ¿Debería serlo?
  2. ¿Qué produce B @ A cuando B.shape == (out, 0) y A.shape == (0, in)? Imprímelo. Imprime (B @ A).abs().max().
  3. Relee theory/05-lora-on-mini-gpt-exact-count.md §"ΔW = BA, escrito en detalle". ¿Cuál es el rank significativo más pequeño?

Diff de la solución

 class LoRALinear(Module):
-    def __init__(self, base: Linear, rank: int = 0, alpha: float = 16.0):
+    def __init__(self, base: Linear, rank: int = 8, alpha: float = 16.0):
+        if rank < 1:
+            raise ValueError(f"LoRA rank must be ≥ 1; got rank={rank}")
         super().__init__()
         ...
-        self.A = Parameter(torch.empty(rank, in_f).normal_())
+        self.A = Parameter(torch.empty(rank, in_f).normal_(std=1 / rank ** 0.5))

Restaura el rank al default de 8, y añade una guarda contra rank < 1 para que el fallo sea ruidoso en la construcción (que es el sitio correcto para fallar — principio "raise loud" de la Fase 9).

Por qué esto enseña el concepto

Toda la propuesta de LoRA es "sólo necesitas un rank bajo para capturar actualizaciones útiles". Este break hace concreta la pregunta obvia que sigue: ¿cuál es la cota inferior de r? La respuesta es r = 1 (un producto exterior rank-1 da una matriz out × in completa con un grado de libertad — útil para una sola dirección de actualización). r = 0 es el caso degenerado — y el modo de fallo (cero aprendizaje) es exactamente lo que predecirías desde álgebra lineal. La lección generaliza a QLoRA, AdaLoRA, y cualquier descomposición de bajo rango (low-rank): el parámetro rank acota la expresividad, y la cota inferior es 1, no 0.

La tarea del tutor de gramática §A13 también es un buen ancla: 8 verbos irregulares × 5 tiempos × 3 personas ≈ 120 entradas que potencialmente corregir. Rank-1 puede codificar una dirección de corrección (p. ej., "el past simple en tercera persona de eat añade una terminación -e"), pero necesitas ranks 2-8 para codificarlas todas. Esa es la curva rank-vs-accuracy que vas a medir en lab/02-lora-finetune.md.

Referencia

  • Hu et al., LoRA (arXiv:2106.09685), §4.1 — discute rank vs accuracy en RoBERTa y reporta r = 8 como sweet spot.
  • Teorema de Eckart-Young-Mirsky (teoría de aproximación de matrices) — el enunciado formal de que cualquier aproximación rank-r de una matriz M tiene la misma expresividad que la SVD truncada de M en rank r. r = 0 ⟹ la matriz cero.

Siguiente: restaura el rank a 8 y ejecuta lab/02-lora-finetune.md. Grafica la curva rank-vs-accuracy para confirmar el punto de saturación en r = 8.