Skip to content

English · Español

Lab 01 — Implementar las operaciones

Objetivo: dar cuerpo a Value con + - * / ** exp log relu tanh. Cada operación obtiene un forward (crea el nodo + registra padres) y un closure _backward (contribuye a los gradientes de los padres vía la derivada local).

Tiempo estimado: 3–4 horas.

Prerrequisitos: lab 00. Lee theory/02-op-derivatives.md hasta que puedas recitar la tabla de memoria.


Lo que produces

  1. src/minigrad/scalar.py extendido con todas las operaciones implementadas.
  2. tests/test_scalar_autograd.py — tests por operación contrastados contra PyTorch FP64 con tolerancia 1e-9.
  3. tests/test_scalar_graph.py — test de dependencia en diamante de theory/03.

TODOs

Bloque A — operaciones binarias vía dunders

Implementa estas en Value:

  • __add__(self, other). Envuelve other en Value(other) si es un número.
  • __radd__(self, other) — para que 3 + Value(2) funcione.
  • __mul__(self, other), __rmul__(self, other).
  • __neg__(self) — definido como self * -1, o nativamente.
  • __sub__(self, other)self + (-other).
  • __rsub__(self, other)(-self) + other.
  • __truediv__(self, other)self * other ** -1. Requiere __pow__.
  • __rtruediv__(self, other).
  • __pow__(self, n) — requiere que n sea un int o float de Python, NO un Value. Lanza TypeError en otro caso.

Cada operación:

  1. Calcula el data del forward.
  2. Crea out = Value(data, _prev=(self, other), _op=symbol).
  3. Define el closure _backward capturando los padres y la tabla de derivada local de theory/02.
  4. Fija out._backward = _backward.
  5. Devuelve out.

Bloque B — operaciones unarias como métodos

  • exp(self) -> Valueout.data = math.exp(self.data), _backward añade out.data * out.grad a self.grad.
  • log(self) -> Valueout.data = math.log(self.data), _backward añade (1/self.data) * out.grad. Lanza con self.data <= 0.
  • relu(self) -> Valueout.data = max(0.0, self.data), _backward añade (1.0 if self.data > 0 else 0.0) * out.grad. Documenta la elección de sub-gradiente en el docstring.
  • tanh(self) -> Valueout.data = math.tanh(self.data), _backward añade (1 - out.data**2) * out.grad.

Bloque C — tests por operación

En tests/test_scalar_autograd.py, para cada operación escribe un test con esta forma (pseudocódigo — Borja rellena los cuerpos):

def test_op_NAME():
    # Configura las entradas.
    a = Value(2.5)
    b = Value(-1.3)

    # Forward en minigrad.
    c = a OP b  # o a.OP(b) para unaria
    c.backward()

    # Forward + backward en PyTorch FP64 como oráculo.
    ta = torch.tensor(2.5, dtype=torch.float64, requires_grad=True)
    tb = torch.tensor(-1.3, dtype=torch.float64, requires_grad=True)
    tc = ta OP tb
    tc.backward()

    # Comparar.
    assert abs(c.data - tc.item()) < 1e-9
    assert abs(a.grad - ta.grad.item()) < 1e-9
    assert abs(b.grad - tb.grad.item()) < 1e-9

Claude ha proporcionado la lista de tests como comentarios en test_scalar_autograd.py. Rellena los cuerpos. Un test por operación, más algunos casos límite:

  • ReLU en a.data == 0 (convención de sub-gradiente).
  • 1 / Value(2.0) — camino de __rtruediv__.
  • Value(2.0) ** 3 y Value(2.0) ** 0.5 — operación de potencia con exponente float.
  • log(Value(2.0)) y exp(Value(0.5)).

Bloque D — test del diamante

En tests/test_scalar_graph.py:

def test_diamond_accumulation():
    a = Value(2.0)
    b = Value(3.0)
    c = Value(4.0)
    L = (a*b + c) * (a - c)
    L.backward()
    assert math.isclose(L.data, -20.0)
    assert math.isclose(a.grad, 4.0)
    assert math.isclose(b.grad, -4.0)
    assert math.isclose(c.grad, -12.0)

Este es el ejemplo trabajado de theory/03. Si este test pasa, tu acumulación += funciona en patrones de diamante. Si falla, casi seguramente usaste = en algún sitio dentro de _backward.

Bloque E — test trampa de closures

Para cazar el bug común "capturé la variable equivocada en un bucle":

def test_closure_captures_correctly():
    values = [Value(float(i)) for i in range(5)]
    total = values[0]
    for v in values[1:]:
        total = total + v
    total.backward()
    # Cada valor contribuyó equitativamente a la suma.
    for v in values:
        assert math.isclose(v.grad, 1.0)

Si escribiste _backward como una lambda capturando la variable del bucle descuidadamente, este test falla (todos los v.grad salvo el último serán 0 o erróneos). El arreglo es capturar padres en tiempo de creación de la operación dentro de una función no-bucle — lo cual la forma de método por operación ya hace correctamente. Pero testéalo.

Bloque F — propiedad de contraste

Opcional pero recomendado: usa hypothesis para generar pequeñas expresiones aleatorias y contrastarlas contra PyTorch. Esto es sobre todo asunto de la Fase 8; para la Fase 7, los tests por operación + el test del diamante son suficientes.

Restricciones

  • Tolerancia de PyTorch 1e-9. FP64 debería coincidir hasta ~1e-12; 1e-9 deja margen.
  • Mínimo un test por operación. No agrupes "test todas las ops" en un mega-test.
  • Sin import de numpy. Usa math para exp, log, tanh.
  • Type hints obligatorios en todos los nuevos métodos y variables libres de closures.
  • Todos los tests deben pasar bajo pytest -x (parar en el primer fallo — saca bugs a la luz más rápido).

Condiciones de parada

Hecho cuando:

  1. Las diez operaciones implementadas en scalar.py.
  2. Tests por operación verdes para las diez operaciones.
  3. Test del diamante verde.
  4. Test de captura de closure verde.
  5. mypy --strict y ruff limpios.

Escollos

  • Orden de argumentos de __radd__. __radd__(self, other) se llama cuando se evalúa other + self y other.__add__(self) devolvió NotImplemented. Así que other es el operando izquierdo. Para operaciones conmutativas (add, mul) no importa; para resta y división sí.
  • Value(0.0) ** 0. 0**0 en Python es 1. Matemáticamente debatible. Nuestro __pow__ debe seguir la convención de Python; PyTorch hace lo mismo.
  • log(Value(0)). Debe lanzar. Si computas en forward math.log(0) obtienes -inf y el backward obtiene inf de 1/0. Decide: lanzar en forward, o dejar que se propague a inf/nan. Default de la Fase 7: lanzar. Documenta.
  • relu(Value(0)). Testea específicamente que grad == 0.0 en este punto.
  • Igualdad de Value. No sobrescribas __eq__ — eso haría Value(2) == Value(2) verdadero y rompería cosas como if v in some_set. Usa la igualdad por identidad por defecto.
  • __hash__ está bien. El hash por defecto de object es basado en identidad; Value es no hashable... no, en realidad el __hash__ por defecto funciona para cualquier clase. Lo necesitamos para el set de visited en el topo-sort.
  • Importar PyTorch en tests es lento. ~1s. Está bien para una suite de tests; solo no te sorprendas.

Cuándo consultar solutions/

Después de que todos los tests listados pasen. Luego solutions/01-implement-ops-ref.md (en la apertura de fase) muestra la estructura canónica de cada operación para comparación.


Siguiente lab: lab/02-train-xor.md.