Skip to content

English · Español

02 — Derivadas de las operaciones

🇪🇸 La derivada local de cada operación que Value soporta — sumar, multiplicar, dividir, potenciar, exp, log, ReLU, tanh. Estas son las únicas matemáticas nuevas de la fase. Cada derivada cabe en una línea; juntas son la materia prima de toda la retropropagación.


Lo que derivarás aquí

Para cada operación c = f(a, b) (o c = f(a) para unarias), la derivada local es ∂c/∂a (y ∂c/∂b para binarias). El closure _backward de c las usará para contribuir a los padres:

a.grad += (∂c/∂a) * c.grad
b.grad += (∂c/∂b) * c.grad

Esta es la regla de la cadena, un nodo a la vez.

Suma: c = a + b

  • ∂c/∂a = 1
  • ∂c/∂b = 1

_backward:

a.grad += c.grad
b.grad += c.grad

Comprobación de cordura: c = a + b = 2 + 3 = 5. Si incrementamos a en ε, c aumenta en ε (luego ∂c/∂a = 1). Si incrementamos b en ε, c aumenta en ε (luego ∂c/∂b = 1). ✓

Resta: c = a - b

Reutilizar: c = a + (-b). Por tanto:

  • ∂c/∂a = 1
  • ∂c/∂b = -1

_backward:

a.grad += c.grad
b.grad += -c.grad

Implementación equivalente: define __neg__ (que fija _backward para la negación: ∂(-a)/∂a = -1), luego implementa __sub__ como __add__(a, -b). Ambas funcionan; la última es más limpia y reutiliza código.

Multiplicación: c = a * b

  • ∂c/∂a = b
  • ∂c/∂b = a

_backward:

a.grad += b.data * c.grad
b.grad += a.data * c.grad

Comprobación de cordura: c = 2 * 3 = 6. Incrementa a en ε: c → (2+ε)·3 = 6 + 3ε. Luego ∂c/∂a = 3 = b. ✓ Lo mismo para b.

Nota: leemos .data de los padres, no los padres en sí. La derivada local es un número (un float), no un Value. (La Fase 8 lo revisitará cuando los gradientes pasen a ser tensores.)

División: c = a / b

Tratar como c = a · b^(-1). Por regla del producto + regla de la cadena:

  • ∂c/∂a = b^(-1) = 1/b
  • ∂c/∂b = a · (-1) · b^(-2) = -a / b²

_backward:

a.grad += (1 / b.data) * c.grad
b.grad += (-a.data / (b.data ** 2)) * c.grad

Comprobación de cordura: c = 6/3 = 2. Incrementa a en ε: c → (6+ε)/3 = 2 + ε/3. Luego ∂c/∂a = 1/3 = 1/b. ✓ Incrementa b en ε: c → 6/(3+ε) ≈ 6/3 · (1 - ε/3) = 2 - 2ε/3. Luego ∂c/∂b = -2/3 = -a/b². ✓

Cuidado: división por cero. Forward producirá inf o lanzará ZeroDivisionError. Backward propagará inf/nan. Decide: clamp, o lanzar. El BLUEPRINT elegirá una convención.

Potencia: c = a ** n (con n constante)

Para un exponente constante n (no un Value):

  • ∂c/∂a = n · a^(n-1)

_backward:

a.grad += (n * a.data ** (n - 1)) * c.grad

Por qué n debe ser una constante (int o float de Python), no un Value: si n también fuera diferenciable, necesitarías ∂(a^n)/∂n = a^n · ln(a), que requiere a > 0 y es más frágil. En minigrad.scalar solo soportamos exponentes constantes. Documenta esta restricción en el BLUEPRINT. El pow de PyTorch soporta ambas formas; nosotros no, porque el rédito educativo es bajo y el foot-gun es real.

Comprobación de cordura: c = 2³ = 8. ∂c/∂a = 3·2² = 12. Incrementa a a 2.01: c ≈ 2.01³ = 8.12. Δc/Δa ≈ 12. ✓

Exponencial: c = exp(a)

  • ∂c/∂a = exp(a) = c

_backward:

a.grad += c.data * c.grad

(Usamos c.data, no math.exp(a.data) — son iguales pero c.data ya está computado.)

Logaritmo: c = log(a) (logaritmo natural)

  • ∂c/∂a = 1/a

_backward:

a.grad += (1 / a.data) * c.grad

Cuidado: a ≤ 0 hace que el forward devuelva nan o -inf. Decide: clampar a a un ε pequeño, lanzar, o confiar en el llamador. El patrón combinado cross_entropy(softmax(...)) en la Fase 8 evita esto por completo al no materializar nunca log(0).

ReLU: c = max(0, a)

  • ∂c/∂a = 1 si a > 0 si no 0

_backward:

a.grad += (1.0 if a.data > 0 else 0.0) * c.grad

La cuestión del sub-gradiente: ¿cuál es ∂c/∂a en a = 0? Matemáticamente, ReLU no es diferenciable en 0 — la derivada por la izquierda es 0, por la derecha es 1. El conjunto sub-gradiente es [0, 1]. Los frameworks eligen una convención; opciones comunes son 0 (la mayoría), 0.5 (algunos), o 1 (raros).

Elegimos 0, coincidiendo con PyTorch. Documenta esto en el BLUEPRINT y haz un test unitario explícito:

a = Value(0.0)
c = a.relu()  # c.data = 0.0
c.backward()
assert a.grad == 0.0  # no 0.5, no 1.0

La elección rara vez importa en la práctica (los floats casi nunca son exactamente cero), pero los tests deben fijarla.

Tanh: c = tanh(a)

Recuerda tanh(a) = (e^a - e^(-a)) / (e^a + e^(-a)). Derivar:

\[ \frac{d}{da} \tanh(a) = 1 - \tanh^2(a) = 1 - c^2 \]

_backward:

a.grad += (1 - c.data ** 2) * c.grad

(Usamos c.data, no una recomputación, por la misma razón que exp.)

Por qué tanh está en el conjunto básico de operaciones: es la "no-linealidad suave acotada" más sencilla útil para redes diminutas. El XOR-MLP en el experimento de esta fase usa tanh como su activación oculta. Sigmoid y softmax no añaden poder expresivo para autograd escalar y abarrotarían la API; los añadiremos en la Fase 8 donde se combinan con cross-entropy.

Una nota lateral: tanh vía exp, ¿o nativo?

Dos elecciones de implementación:

Opción A (nativa): tanh es una operación primitiva con su propio _backward usando 1 - c². Un nodo en el grafo.

Opción B (vía exp): descomponer tanh(a) = (exp(2a) - 1) / (exp(2a) + 1). Construir desde operaciones existentes. ~5 nodos en el grafo; la visualización muestra la estructura; los tests comprueban la misma respuesta.

Ambas son correctas. La opción A es más rápida, menos asignaciones. La opción B es más transparente pedagógicamente (sin operación especial, solo composición).

Default para minigrad.scalar: Opción A (nativa). Razón: sigue siendo pedagógicamente clara (un closure corto) y coincide con la estructura de PyTorch. La opción B es un buen ejercicio; hazla una vez para entender, luego ve con A.

El BLUEPRINT registra la elección. Si Borja prefiere B en la apertura de fase, BLUEPRINT cambia.

Negación unaria: c = -a

  • ∂c/∂a = -1

_backward:

a.grad += -1.0 * c.grad

Implementar como __neg__ en Value, luego reutilizar en __sub__.

Tabla resumen

Op Forward Derivada local
+ c = a + b ∂c/∂a = 1, ∂c/∂b = 1
- (binaria) c = a - b ∂c/∂a = 1, ∂c/∂b = -1
- (unaria) c = -a ∂c/∂a = -1
* c = a · b ∂c/∂a = b, ∂c/∂b = a
/ c = a / b ∂c/∂a = 1/b, ∂c/∂b = -a/b²
** n c = aⁿ ∂c/∂a = n · aⁿ⁻¹
exp c = e^a ∂c/∂a = c
log c = ln(a) ∂c/∂a = 1/a
relu c = max(0, a) ∂c/∂a = 1 si a > 0 si no 0
tanh c = tanh(a) ∂c/∂a = 1 - c²

Imprime esta tabla. Pégala en la pared. Para el final de la Fase 7 no deberías necesitar mirarla.

Escollos (morderán en el lab)

  1. Leer a.data después de que cambió. El closure captura a por referencia; si mutas a.data entre forward y backward, el closure ve el nuevo valor. No mutes parámetros en mitad de un forward.
  2. Olvidar c.grad en la contribución. La derivada local se multiplica por el gradiente upstream. a.grad += b.data está mal; debe ser a.grad += b.data * c.grad.
  3. Usar c.data cuando la regla necesita a.data. Ten cuidado con qué .data agarras. Backward de mul: a.grad += b.data * c.gradb.data, no a.data. La sustitución descuidada es un bug común.
  4. Sub-gradiente de ReLU en 0. Elige una convención, documéntala, testéala.
  5. pow con exponente no constante. No lo soportes. Lanza TypeError si n es un Value.

Recapitulación en un párrafo

Las diez operaciones de minigrad.scalar tienen cada una una derivada local que cabe en una línea. La retropropagación las usa vía la regla de la cadena: cada nodo contribuye derivada_local · gradiente_upstream al gradiente de cada padre. Las decisiones de diseño más delicadas son convenciones (ReLU en 0 → 0; el exponente de pow debe ser constante; log(x≤0) es responsabilidad del llamador). Memoriza la tabla. A partir de aquí, el lab de la Fase 7 es solo implementación — las matemáticas están zanjadas.


Siguiente: 03-worked-backprop.md