Skip to content

English · Español

Lab 03 — Gradcheck por diferencias finitas para el autograd escalar

Prerrequisito: lab 01 (Value y operaciones implementadas). Opcional pero recomendado: lab 02 (el MLP entrenado da expresiones no triviales para gradcheck). Objetivo: construir una utilidad de gradcheck de 30 líneas que compara los gradientes analíticos de tu minigrad contra diferencias finitas centradas sobre una batería de expresiones. El entregable es un CLI que falla ruidosamente cuando el backward de cualquier operación no cuadra.

Tiempo estimado: 60–90 minutos. Corre en segundos en el i5-8250U.


§1 Por qué existe este lab

El lab 01 testeó cada operación contra un gradiente esperado derivado a mano. El lab 02 entrenó un MLP y confió en que la pérdida bajando significa que los gradientes son correctos. Ambos son útiles pero ninguno captura un bug sutil de signo en una operación que no testeaste a mano.

Un gradcheck es la defensa más barata y general contra esa clase de bug: compara el gradiente analítico df/dx (tu Value._backward) contra una estimación numérica (f(x + ε) - f(x - ε)) / (2ε). Deben coincidir dentro de una pequeña tolerancia. Si no, el backward de tu operación está mal — o tu forward, o tu topo-sort, o tu acumulación +=.

PyTorch incluye exactamente esta utilidad (torch.autograd.gradcheck). Construirás una versión escalar diminuta de ella.

§2 Lo que produces

Un único archivo src/minigrad/gradcheck.py (~50 líneas), con una función pública:

def gradcheck(
    f: Callable[..., Value],
    inputs: tuple[Value, ...],
    *,
    eps: float = 1e-5,
    atol: float = 1e-4,
    rtol: float = 1e-3,
) -> bool:
    """Return True iff every input's analytic gradient matches the
    central-difference numerical gradient within tolerance.

    Raises AssertionError with a diagnostic message on failure.
    """

Más tests/test_gradcheck.py ejercitando cada operación del lab 01 más tres expresiones compuestas: - El diamante de theory/03: L = (a*b + c) * (a - c). - Una cadena: L = relu(a * b + c) (usa relu si la implementaste; si no, usa tanh). - Una división: L = a / (b + 1.0) para cazar bugs de signo en __truediv__.

§3 Pasos

  1. Esboza el algoritmo en papel primero. Para cada input x_i:
  2. Guarda x_i.data. Fíjalo a x_i.data + ε, ejecuta f(inputs), registra f_plus. Restaura.
  3. Fija x_i.data = x_i.data - ε, ejecuta f(inputs), registra f_minus. Restaura.
  4. Gradiente numérico: (f_plus - f_minus) / (2ε).
  5. Pon a cero todos los .grad, ejecuta f(inputs).backward(). Gradiente analítico: x_i.grad.
  6. Compara con abs(num - analytic) <= atol + rtol * abs(num).
  7. En fallo, imprime: índice del input, esperado (numérico), obtenido (analítico), diff absoluta, diff relativa.

  8. Implementa. Anota tipos. ~50 líneas.

  9. Escribe los casos de test. Cada test crea Values frescos, define un f pequeño, llama gradcheck, comprueba True. Para cada operación en tu biblioteca, escribe al menos un test de gradcheck.

  10. Rompe y confirma. Introduce intencionalmente un bug de signo en __sub__._backward (p. ej., a.grad += out.grad, faltando el -1 para el operando derecho). Ejecuta gradcheck; confirma que falla con un mensaje de error claro. Luego revierte.

§4 Condiciones de parada

  • uv run pytest tests/test_gradcheck.py pasa (todos los gradchecks de operaciones + las tres expresiones compuestas).
  • mypy --strict src/minigrad/gradcheck.py pasa.
  • ruff check src/minigrad/gradcheck.py pasa.
  • Ejecutaste el experimento del "bug de signo intencional", confirmaste que gradcheck lo cazó, y revertiste. Documenta el mensaje del bug que viste en learners/borja/phase-07/notes/gradcheck.md.
  • Escribiste un párrafo corto (4-6 líneas) explicando por qué eps = 1e-5 es un default razonable y qué pasa si eliges 1e-12 o 1e-2 (cancelación vs truncamiento).
  • Commit: lab: phase-07 add scalar gradcheck CLI and tests.

§5 Pistas

  1. Restaura .data tras cada perturbación ε — si lo olvidas, las operaciones posteriores ven el valor perturbado y la estimación numérica es errónea.
  2. Pon a cero todos los gradientes antes de cada pase analítico. Un .grad persistente de un test previo envenena al siguiente.
  3. Para inputs de los que la expresión no depende, gradcheck debe reportar 0.0 analítico y 0.0 numérico. Asegúrate de que tu test no incluye inputs "fantasma" por accidente.
  4. Elección de tolerancia: atol = 1e-4, rtol = 1e-3 funciona para eps = 1e-5 en expresiones escalares típicas con magnitudes de Value ~O(1). Si necesitas tolerancia más estrecha, elige un ε mayor; si tu expresión tiene magnitudes ~O(1e6), sube atol proporcionalmente.
  5. Mensaje de error bonito:
    gradcheck FAILED at input index 1:
      numerical = 4.000003
      analytic  = -6.000000
      abs diff  = 10.000003
      rel diff  = 2.5
    

§6 Lo que habrás aprendido

  • El patrón gradcheck: numérico vs analítico, diferencias centradas, diseño de tolerancia.
  • Por qué las diferencias finitas son una comprobación de cordura, no una herramienta primaria (el "punto dulce" de eps es estrecho).
  • Una utilidad reutilizable a la que volverás en la Fase 8 (gradcheck vectorizado sobre tensores).
  • La mentalidad de "testea el gradiente, no la pérdida" — el bucle de entrenamiento de la Fase 16 lo reutilizará como puerta de CI antes de cada ejecución larga.

§7 Referencias

  • El fuente de torch.autograd.gradcheck de PyTorch (~200 líneas) es el análogo de producción; leerlo después de este lab son 15 minutos provechosos.
  • Bishop, Pattern Recognition and Machine Learning, §5.3.5 — derivación de comprobaciones de gradiente por diferencias finitas y sus modos de fallo.