Skip to content

English · Español

Lab 01 — Autograd a mano para nn.Linear(64, 600)

Derivas a mano los gradientes de un Linear(64, 600) — los tres: ∂L/∂x, ∂L/∂W, ∂L/∂b. Luego ejecutas loss.backward() en PyTorch y comparas. El umbral es 1e-7 a fp32. Si no cuadra, lo arreglas. Esta es la práctica que cierra la convicción "PyTorch autograd es exactamente lo que construimos en Fase ⅞ — más grande, no diferente".

Objetivo

Deriva a mano las fórmulas de backward para y = linear(x, W, b) = x @ W.T + b seguidas de una pérdida escalar L = (y - target).pow(2).sum() / 2. Computa ∂L/∂x, ∂L/∂W, ∂L/∂b analíticamente, después verifica contra el autograd de PyTorch a fp32 dentro de un 1e-7 elemento a elemento.

Setup

  • Fase ⅞ (autograd escalar/tensorial) y Fase 04 (teoría de cálculo).
  • Las formas del forward: x ∈ R^(2 × 64), W ∈ R^(600 × 64), b ∈ R^(600), y ∈ R^(2 × 600), target ∈ R^(2 × 600), L ∈ R.

Las matemáticas

Forward:

\[y = x W^T + b \qquad y_{i,j} = \sum_k x_{i,k} W_{j,k} + b_j\]

Pérdida:

\[L = \tfrac{1}{2}\sum_{i,j} (y_{i,j} - t_{i,j})^2 \qquad \frac{\partial L}{\partial y} = y - t\]

Regla de la cadena:

  • \(\dfrac{\partial L}{\partial x} = \dfrac{\partial L}{\partial y} \cdot \dfrac{\partial y}{\partial x} = (y - t) W\)   (forma (2, 64))
  • \(\dfrac{\partial L}{\partial W} = (y - t)^T x\)   (forma (600, 64))
  • \(\dfrac{\partial L}{\partial b} = \sum_i (y_i - t_i)\)   (forma (600,))

Éstas son las fórmulas que el nodo AddmmBackward0 computa internamente. El laboratorio lo verifica.

Tareas

Parte A — Forward + backward analítico

import torch
torch.manual_seed(42)

x = torch.randn(2, 64, requires_grad=True)
W = torch.randn(600, 64, requires_grad=True)
b = torch.randn(600, requires_grad=True)
target = torch.randn(2, 600)

y = torch.nn.functional.linear(x, W, b)
loss = 0.5 * ((y - target) ** 2).sum()

# Analytical gradients (no autograd):
with torch.no_grad():
    dy = y - target                          # (2, 600)
    dx_manual = dy @ W                       # (2, 64)
    dW_manual = dy.T @ x                     # (600, 64)
    db_manual = dy.sum(dim=0)                # (600,)

Parte B — Autograd de PyTorch

loss.backward()

print("dx max-err:", (x.grad - dx_manual).abs().max().item())
print("dW max-err:", (W.grad - dW_manual).abs().max().item())
print("db max-err:", (b.grad - db_manual).abs().max().item())

Los tres errores máximos deberían ser < 1e-5 a fp32 (y típicamente < 1e-7).

Parte C — Recorre la cadena grad_fn

node = loss.grad_fn
while node is not None:
    print(type(node).__name__, [t for t in node.next_functions])
    nexts = [t[0] for t in node.next_functions if t[0] is not None]
    node = nexts[0] if nexts else None

Salida esperada (aproximada; los nombres varían según versión):

DivBackward0   [(SumBackward0, 0)]            # the 0.5 *
SumBackward0   [(PowBackward0, 0)]
PowBackward0   [(SubBackward0, 0)]
SubBackward0   [(AddmmBackward0, 0), (None, 0)]
AddmmBackward0 [(AccumulateGrad, 0), (AccumulateGrad, 0), (TBackward0, 0)]

Identifica: - AddmmBackward0 — el nodo de matmul-y-suma-de-sesgo. - AccumulateGrad — nodos hoja que acumulan gradientes en .grad. - TBackward0 — la transposición implícita W → W^T que linear insertó.

Parte D — Verifica la afirmación 1e-7 a fp32

torch.manual_seed(123)
errors = []
for _ in range(20):
    x = torch.randn(2, 64, requires_grad=True)
    W = torch.randn(600, 64, requires_grad=True)
    b = torch.randn(600, requires_grad=True)
    target = torch.randn(2, 600)

    y = torch.nn.functional.linear(x, W, b)
    loss = 0.5 * ((y - target) ** 2).sum()
    with torch.no_grad():
        dy = y - target
        dx_m, dW_m, db_m = dy @ W, dy.T @ x, dy.sum(dim=0)
    loss.backward()

    errors.append((
        (x.grad - dx_m).abs().max().item(),
        (W.grad - dW_m).abs().max().item(),
        (b.grad - db_m).abs().max().item(),
    ))

import numpy as np
e = np.array(errors)
print("dx: max", e[:, 0].max(), " median", float(np.median(e[:, 0])))
print("dW: max", e[:, 1].max(), " median", float(np.median(e[:, 1])))
print("db: max", e[:, 2].max(), " median", float(np.median(e[:, 2])))

Esperado: las medianas son ~ 1e-7, los máximos son < 1e-5. La razón por la que no es 0.0 aunque la fórmula es idéntica: el orden de la suma en coma flotante difiere entre addmm de PyTorch y tu @. Documenta esto.

Parte E — Repite a fp16, observa la degradación

x = torch.randn(2, 64, dtype=torch.float16, requires_grad=True)
# ... same as Part D

Esperado: los errores son ahora 1e-3 o peores. El problema del orden de la suma se amplifica en baja precisión. Ésta es la razón canónica por la que el entrenamiento fp16 necesita loss-scaling y precisión mixta (la Fase 18 lo mencionó; la Fase 26 se zambulle en ello).

Parte F — Escribe el informe

experiments/25-autograd-by-hand/REPORT.md:

  1. Las matemáticas (renderizadas en LaTeX, tres fórmulas).
  2. La tabla de errores de la Parte D (20 ejecuciones, mediana + máximo por gradiente).
  3. El resultado fp16 de la Parte E con una explicación de 2 frases sobre por qué la precisión importa.
  4. La impresión de la cadena grad_fn de la Parte C.
  5. Un párrafo: "El autograd de PyTorch calculó las mismas fórmulas que escribí a mano. La desviación a fp32 es < 1e-5, dominada por diferencias en el orden de suma. A fp16 la desviación es 1e-3, lo bastante grande como para afectar a la convergencia — éste es el modo de fallo que el entrenamiento en precisión mixta aborda."

Entregable

experiments/25-autograd-by-hand/: - REPORT.md — los puntos anteriores. - errors.csv — la tabla de errores 20-ejecuciones × 3-gradientes. - manifest.json.

Aceptación

  • fp32: las 20 ejecuciones × 3 gradientes tienen un error máximo < 1e-5.
  • fp16: al menos un gradiente tiene un error máximo > 1e-3 (prueba la sensibilidad de precisión).
  • La impresión de la cadena grad_fn identifica AddmmBackward0, AccumulateGrad, y la transposición.
  • El párrafo de interpretación atribuye correctamente la discrepancia fp32 al orden de suma.

Pitfalls

  • Dirección de transposición errónea. linear(x, W, b) = x @ W.T + b. Si escribes x @ W + b, las formas no encajarán (x: (2,64), W: (600,64), no se puede hacer matmul).
  • Olvidarse del 0.5 * en la pérdida. Entonces ∂L/∂y = 2(y - t), no (y - t). Casa la constante con la pérdida.
  • Recomputar loss.backward() sin poner a cero .grad. Los gradientes se acumulan; la segunda llamada duplica la respuesta. Usa x.grad.zero_() entre ejecuciones, o reconstruye los tensores frescos.
  • fp16 produciendo nan. Magnitudes mayores de target desbordan al cuadrado. Escala target por 0.1 si ves inf.
  • Comparar dW.T con dW. PyTorch guarda W.grad en el mismo layout que W. Si tu dW_manual se calcula como x.T @ dy (forma (64, 600)), necesitarás .T. Comprueba formas antes de comparar.

Stretch

  • Añade una segunda capa lineal y2 = linear(y, W2, b2) y deriva el backward completo de dos capas. Compara contra autograd.
  • Reemplaza la pérdida cuadrada por cross-entropy + softmax sobre las 600 clases de gramática. Deriva ∂L/∂y = softmax(y) - one_hot(target). Ésta es la fórmula contra la que la Fase 18 entrena realmente.
  • Usa torch.autograd.gradcheck en lugar de diferencias finitas para verificación.

Siguiente lab: lab/02-custom-op.md.