English · Español
Lab 01 — Ops elementwise con backward correcto para broadcasting¶
Objetivo: añadir la familia de ops elementwise a
Tensor:add sub mul div neg exp log relu gelu tanh. Cada forward respeta el broadcasting de NumPy; cada backward suma correctamente a lo largo de los ejes broadcasteados. Cross-check de cada op contra PyTorch FP64 (tolerancia 1e-7) y gradcheck (tolerancia 1e-4 en FP64).Tiempo estimado: 3–4 horas (son muchas ops, con una idea peliaguda — el broadcasting reverso).
Prerrequisitos: Lab 00. Teoría 02 (
02-tensor-op-derivatives.md) — releer la derivación del broadcasting reverso.
Lo que produces¶
- Las 9 ops elementwise en
src/minitorch/tensor.py, expuestas vía dunders y métodos. tests/test_elementwise.pycon dos clases de tests por op: cross-check PyTorch + gradcheck.tests/test_broadcast_reverse.pycon los tests de pares broadcasting (un(3,)op un(4,3), un(N,1)op un(1,M), etc.).
El helper de broadcasting reverso¶
Según theory/02, el helper:
def _unbroadcast(grad: FloatArray, target_shape: tuple[int, ...]) -> FloatArray:
# If grad.shape is "wider" than target_shape on the left, sum out the extra leading dims.
while grad.ndim > len(target_shape):
grad = grad.sum(axis=0)
# For each remaining axis, if target dim is 1 but grad dim > 1, sum that axis with keepdims=True.
for i, t_dim in enumerate(target_shape):
if t_dim == 1 and grad.shape[i] > 1:
grad = grad.sum(axis=i, keepdims=True)
return grad
Implementa esto una vez. El _backward de cada op elementwise lo llama antes de asignar al .grad de un padre.
La trampa más común del lab: olvidar
_unbroadcasty dejar un gradiente con forma (shape) distinta a la del parent. Al sumarparent.grad += new_gradNumPy difunde la suma silenciosamente. El resultado: una matriz donde esperabas un vector. Asegúrate de que tras cada_backward,parent.grad.shape == parent.data.shape.
TODOs (por op)¶
Para cada op, haz las mismas cinco cosas:
- Forward: calcula
out_datacon NumPy. Construyeout = Tensor(out_data, _prev=(self, other), _op='<name>')y propagarequires_grad. - Backward: define un closure capturando los padres y cualquier valor cacheado; al ser llamado, contribuye
_unbroadcast(local_grad, parent.shape)al.gradde cada padre. - Test PyTorch: construye la misma expresión en PyTorch FP64; compara gradientes con
np.testing.assert_allclose(..., rtol=1e-7). - Test gradcheck: FD numérico en FP64; tolerancia 1e-4.
- Test broadcasting: al menos un par de formas (shapes) donde el forward deba broadcastear.
Bloque A — familia aditiva¶
-
__add__(self, other): forwardself.data + other.data. Backward:self.grad += _unbroadcast(out.grad, self.shape); lo mismo paraother(trata escalares/ints de Python envolviéndolos enTensor). -
__radd__: para2 + t. -
__neg__(self): forward-self.data. Backward:self.grad += _unbroadcast(-out.grad, self.shape). -
__sub__(self, other): reutilizaadd+negsi prefieres; o implementa directamente. Documenta la elección. -
__rsub__: para2 - t.
Bloque B — familia multiplicativa¶
-
__mul__(self, other): forwardself.data * other.data. Backward:self.grad += _unbroadcast(other.data * out.grad, self.shape);other.grad += _unbroadcast(self.data * out.grad, other.shape). Nota el swap:∂(a*b)/∂a = b. -
__rmul__. -
__truediv__(self, other): forwardself.data / other.data. Backward:∂(a/b)/∂a = 1/b,∂(a/b)/∂b = -a/b². Cacheaother.data(no cachees1/other.data— riesgo de división por cero en la caché). -
__rtruediv__: para2.0 / t.
Bloque C — no-linealidades unarias (métodos de Tensor)¶
-
exp(self): forwardnp.exp(self.data). Backward:self.grad += _unbroadcast(out.data * out.grad, self.shape)— nota que usamosout.data(que es igual aexp(self.data)). -
log(self): forwardnp.log(self.data). Backward:self.grad += _unbroadcast(out.grad / self.data, self.shape). Aseguraself.data > 0en el forward (lanza ruidosamente si el input es inválido). -
relu(self): forwardnp.maximum(0, self.data). Backward:self.grad += _unbroadcast(out.grad * (self.data > 0).astype(self.data.dtype), self.shape). Sub-gradiente en 0 = 0. -
tanh(self): forwardnp.tanh(self.data). Backward:self.grad += _unbroadcast(out.grad * (1 - out.data**2), self.shape). -
gelu(self): forward0.5 * self.data * (1 + np.tanh(...))(usa la aproximación tanh de la teoría). Backward: la derivada no es trivial — vuelve a derivarla en papel antes de programar; cachea intermedios.
Restricciones¶
- Solo funcional. Sin ops in-place.
- Sin
numpy.errstatesilenciando warnings. Deja quelog(<=0)lance excepción. - Cada op recibe una línea de resumen en español en el eventual
solutions/01-elementwise-ops-ref.md— mantén abierto tutheory/02-tensor-op-derivatives.mdpara las derivaciones. - Usa las utilidades de seeding de la fase 6 en los tests:
seed_everything(42).
Patrones de test¶
Cross-check PyTorch (un ejemplo)¶
import torch, numpy as np
from minitorch import Tensor
def test_mul_pytorch_cross():
rng = np.random.default_rng(42)
a_data = rng.standard_normal((3, 4)).astype(np.float64)
b_data = rng.standard_normal((3, 4)).astype(np.float64)
# Our autograd.
A = Tensor(a_data, requires_grad=True)
B = Tensor(b_data, requires_grad=True)
L = (A * B).sum()
L.backward()
# PyTorch reference.
At = torch.tensor(a_data, dtype=torch.float64, requires_grad=True)
Bt = torch.tensor(b_data, dtype=torch.float64, requires_grad=True)
Lt = (At * Bt).sum()
Lt.backward()
np.testing.assert_allclose(A.grad, At.grad.numpy(), rtol=1e-7)
np.testing.assert_allclose(B.grad, Bt.grad.numpy(), rtol=1e-7)
Escribe uno de estos por op. ~10 líneas cada uno.
Gradcheck (un ejemplo)¶
def test_mul_gradcheck():
rng = np.random.default_rng(0)
a = Tensor(rng.standard_normal((2, 3)), requires_grad=True)
b = Tensor(rng.standard_normal((2, 3)), requires_grad=True)
f = lambda: (a * b).sum()
assert gradcheck_pair(f, a, b, eps=1e-6, atol=1e-4)
Test de broadcasting (un ejemplo, con sabor gramatical)¶
def test_mul_broadcasts_person_logits():
# (3,) person one-hot times (3, 5) (person, tense) logits.
person = Tensor(np.array([0.0, 1.0, 0.0]), requires_grad=True)
logits = Tensor(np.random.default_rng(7).standard_normal((3, 5)), requires_grad=True)
out = (person[:, None] * logits).sum()
out.backward()
assert person.grad.shape == (3,) # _unbroadcast restored
assert logits.grad.shape == (3, 5)
Condiciones de parada¶
Hecho cuando:
- Las 9 ops elementwise implementadas.
- Cada una tiene al menos 2 tests: cross PyTorch + gradcheck. Todos verdes.
- Cada una tiene al menos 1 test de broadcasting. Todos verdes.
mypy --strictlimpio.- Puedes responder: "si olvido
_unbroadcasten el backward de__add__, ¿cuál es el test más pequeño que lo detecta?"
Escollos¶
out.gradesNoneen la hoja. La primera llamada a_backwardesroot._backward()trasroot.grad = np.array(1.0). Si escribiste_backwardpara leerout.grad, bien. Si por accidente capturasteouty luego leesout.gradantes de que fuera fijado por el paso reverse anterior, obtienesNone. Traza el orden con cuidado.- Signo erróneo en
__rsub__/__rtruediv__.2 - tes(-t) + 2, not - 2. Fácil de invertir. - Cachear
1/other.dataexplota. Manténother.datay divide en el closure; o protégete contra el cero. - Gradiente de
reluexactamente en 0. Convención: 0. No uses accidentalmente(self.data >= 0)— esa es una convención distinta. - El forward y el backward de
geluno coinciden. Dos formulaciones de GELU (exacta víaerfy aproximación tanh). Escoge una y úsala consistentemente.
Cuándo consultar solutions/¶
Después de que las 9 ops pasen las tres familias de tests. solutions/01-elementwise-ops-ref.md (al abrir la fase) compara tus closures contra las implementaciones canónicas.
Siguiente lab: lab/02-reduction-and-shape-ops.md.