ia machine learning python autograd backpropagation gradiente descendente

Diferenciación Automática: cómo una computadora calcula derivadas solas

Entendé cómo funciona el autograd por dentro: grafos computacionales, regla de la cadena, forward y backward pass. Implementación mínima en Python basada en micrograd de Andrej Karpathy.

Por Compujuy · ·
- vistas
Diferenciación automática y backpropagation en Python

Diferenciación Automática: cómo una computadora calcula derivadas solas

Introducción

Cuando entrenamos un modelo de Machine Learning, necesitamos calcular derivadas. Todo el tiempo. En cada paso de entrenamiento, para cada parámetro del modelo.

Si alguna vez te preguntaste:

Este post es para vos.

La implementación que vamos a ver está basada en micrograd, el motor de diferenciación automática que construye Andrej Karpathy desde cero en su video The spelled-out intro to neural networks and backpropagation: building micrograd. Muy recomendado verlo después de leer este post.


El problema: necesitamos derivadas de funciones muy complejas

En gradiente descendente, la regla de actualización de cualquier parámetro ww es:

w=wαLww = w - \alpha \cdot \frac{\partial L}{\partial w}

Donde LL es la función de pérdida (loss). Si no estás familiarizado con el error cuadrático medio como función de pérdida, es un buen punto de partida. El problema es que LL no es una función simple. Es una composición de decenas, cientos o miles de operaciones anidadas.

Derivar eso a mano es imposible. Necesitamos que la computadora lo haga sola, de forma exacta y eficiente.


Las tres formas de calcular derivadas

Existen tres enfoques distintos:

1. Diferenciación simbólica

Es lo que hacés en papel o con una librería como sympy. Toma la expresión matemática y aplica las reglas de derivación para producir otra expresión.

from sympy import symbols, diff

x = symbols('x')
f = x**2 + 3*x
print(diff(f, x))  # 2*x + 3

Problema: Para funciones con miles de operaciones, la expresión resultante puede ser enormemente compleja y lenta de evaluar.

2. Diferenciación numérica

Usa la definición de derivada con una pequeña perturbación hh:

fxf(x+h)f(x)h\frac{\partial f}{\partial x} \approx \frac{f(x + h) - f(x)}{h}

def derivada_numerica(f, x, h=1e-5):
    return (f(x + h) - f(x)) / h

Problema: Introduce error de redondeo. Además, si tenemos nn parámetros, necesitamos hacer nn evaluaciones de la función, lo que es muy lento para modelos grandes.

3. Diferenciación automática

Combina lo mejor de los dos mundos: es exacta (como la simbólica) y eficiente (una sola pasada hacia atrás calcula todos los gradientes a la vez).

No deriva expresiones, sino que recorre un grafo de operaciones que fue construido durante la ejecución del programa.

MétodoExactaEficienteEscalable
Simbólica❌ expresiones enormes
Numérica❌ error de redondeo❌ n evaluaciones
Automática

La idea central: el grafo computacional

El secreto de la diferenciación automática es que registra cada operación que se realiza sobre los datos, formando un grafo dirigido.

Pensalo así: cuando ejecutás c = a + b, no solo calculás el resultado, sino que también guardás:

Nodos y aristas

El grafo tiene dos tipos de elementos:

Ejemplo: para la expresión L = (a + b) * c:

a ──┐
    ├──[+]── d ──┐
b ──┘            ├──[*]── L
c ───────────────┘

Cada nodo del grafo sabe de quién depende. Esa información es lo que permite calcular las derivadas hacia atrás.


El corazón: la regla de la cadena

La diferenciación automática se basa completamente en la regla de la cadena. Si querés ver cómo se aplica en detalle sobre el error cuadrático medio, lo desarrollamos paso a paso en este post.

Si LL depende de dd, y dd depende de aa, entonces:

La=Ld×da\frac{\partial L}{\partial a} = \frac{\partial L}{\partial d} \times \frac{\partial d}{\partial a}

En palabras: para saber cómo aa afecta a LL, basta con saber cómo aa afecta al nodo inmediato siguiente (dd), y cómo ese nodo afecta al final (LL).

Esto es lo que hace posible calcular todos los gradientes en una sola pasada hacia atrás: cada nodo solo necesita conocer el gradiente del nodo siguiente, no de toda la red.


Los dos pasos: forward y backward

Forward pass (hacia adelante)

Se ejecuta el cálculo normal, de izquierda a derecha. Se computan los valores y se construye el grafo.

a = 2,  b = 3,  c = 4

d = a + b = 5    → d._prev = {a, b},  d._op = '+'
L = d * c = 20   → L._prev = {d, c},  L._op = '*'

En este momento NO se calcula ningún gradiente. Solo se guarda la estructura del grafo y una función _backward en cada nodo que sabe cómo propagar el gradiente cuando llegue el momento.

Backward pass (hacia atrás)

Se recorre el grafo en orden inverso (de la salida hacia las entradas), aplicando la regla de la cadena en cada nodo.

Usamos el mismo ejemplo: a=2, b=3, c=4, con d = a+b = 5 y L = d*c = 20.


Paso 0 — Estado inicial: todos los gradientes en 0

a (data=2, grad=0) ──┐
                     ├──[+]── d (data=5, grad=0) ──┐
b (data=3, grad=0) ──┘                              ├──[*]── L (data=20, grad=0)
c (data=4, grad=0) ─────────────────────────────────┘

Paso 1 — Inicializar L.grad = 1.0

¿Por qué 1? Porque estamos preguntando “¿cuánto cambia L respecto de L misma?”. La respuesta siempre es 1: si L sube 1, L sube 1.

∂L/∂L = 1.0   → L.grad = 1.0
a (grad=0) ──┐
             ├──[+]── d (grad=0) ──┐
b (grad=0) ──┘                     ├──[*]── L (grad=1.0) ← arrancamos acá
c (grad=0) ────────────────────────┘

Paso 2 — Propagar desde L hacia d y c (operación *)

L = d × c. Queremos saber cuánto afecta d a L y cuánto afecta c a L.

Derivadas de la multiplicación:

a (grad=0) ──┐
             ├──[+]── d (grad=4) ──┐
b (grad=0) ──┘                     ├──[*]── L (grad=1.0)
c (grad=5) ────────────────────────┘

Paso 3 — Propagar desde d hacia a y b (operación +)

d = a + b. Ya sabemos que d.grad = 4 (calculado en el paso anterior).

a (grad=4) ──┐
             ├──[+]── d (grad=4) ──┐
b (grad=4) ──┘                     ├──[*]── L (grad=1.0)
c (grad=5) ────────────────────────┘

Resultado final: a.grad=4, b.grad=4, c.grad=5.

Esto significa: si aumento a en 1 → L aumenta en 4. Si aumento c en 1 → L aumenta en 5.


¿Por qué 1.0 en la suma?

Cuando ves en el código self.grad += 1.0 * out.grad, el 1.0 puede parecer arbitrario. No lo es.

Primero: la intuición

Imaginá que tenés d = a + b con a=2, b=3, entonces d=5.

Ahora aumentá a en 1: a=3, b=3d=6. d aumentó en 1.

Aumentá a en 2: a=4, b=3d=7. d aumentó en 2.

Cada vez que a sube 1, d sube exactamente 1. La tasa de cambio es 1. Eso es la derivada:

da=1db=1\frac{\partial d}{\partial a} = 1 \qquad \frac{\partial d}{\partial b} = 1

Segundo: aplicar la regla de la cadena

El gradiente que nos interesa no es ∂d/∂a sino ∂L/∂a (cómo afecta a al resultado final L).

Aquí entra la regla de la cadena: el cambio de a en L pasa a través de d:

La=Ldya lo sabemos×da= 1\frac{\partial L}{\partial a} = \underbrace{\frac{\partial L}{\partial d}}_{\text{ya lo sabemos}} \times \underbrace{\frac{\partial d}{\partial a}}_{\text{= 1}}

En código, d.grad ya fue calculado en el paso anterior. Entonces:

def _backward():             # dentro de __add__
    self.grad  += 1.0 * out.grad   # 1.0 = ∂(a+b)/∂a
    other.grad += 1.0 * out.grad   # 1.0 = ∂(a+b)/∂b

¿Y en la multiplicación?

Con out = a * b, la intuición cambia:

(ab)a=b(ab)b=a\frac{\partial (a \cdot b)}{\partial a} = b \qquad \frac{\partial (a \cdot b)}{\partial b} = a

Por eso en el código:

def _backward():             # dentro de __mul__
    self.grad  += other.data * out.grad   # b · ∂L/∂out
    other.grad += self.data  * out.grad   # a · ∂L/∂out

La regla general

En todos los casos la estructura es siempre la misma:

nodo.grad += (derivada local de la operación) × out.grad

No estás “derivando” en tiempo de ejecución. Ya sabés de antemano cuál es la derivada de cada operación básica (suma, producto, potencia…). Solo la aplicás multiplicada por el gradiente que llegó desde arriba.


Las derivadas de las operaciones más comunes

OperaciónForwardBackward (∂L/∂self)
out = a + ba + bself.grad += 1.0 * out.grad
out = a * ba * bself.grad += b.data * out.grad
out = a ** na^nself.grad += n * a^(n-1) * out.grad
out = sin(a)sin(a)self.grad += cos(a) * out.grad
out = exp(a)e^aself.grad += e^a * out.grad
out = log(a)ln(a)self.grad += (1/a) * out.grad

En todos los casos la estructura es idéntica: self.grad += (derivada local) * out.grad.


Implementación mínima en Python

Esta implementación está basada en el código de micrograd de Andrej Karpathy. Es la versión más reducida posible para entender la idea central, sin nada extra:

import math

class Value:

    def __init__(self, data, _children=(), _op='', label=''):
        self.data = data          # el valor numérico
        self.grad = 0.0           # el gradiente acumulado (empieza en 0)
        self._backward = lambda: None  # función que propaga el gradiente
        self._prev = set(_children)    # nodos de los que depende
        self._op = _op            # operación que generó este nodo
        self.label = label

    def __repr__(self):
        return f"Value(data={self.data})"

    def __add__(self, other):
        out = Value(self.data + other.data, (self, other), '+')

        def _backward():
            # Derivada de la suma: ∂(a+b)/∂a = 1, ∂(a+b)/∂b = 1
            self.grad  += 1.0 * out.grad
            other.grad += 1.0 * out.grad
        out._backward = _backward

        return out

    def __mul__(self, other):
        out = Value(self.data * other.data, (self, other), '*')

        def _backward():
            # Derivada del producto: ∂(a*b)/∂a = b, ∂(a*b)/∂b = a
            self.grad  += other.data * out.grad
            other.grad += self.data  * out.grad
        out._backward = _backward

        return out

    def backward(self):
        # 1. Ordenar el grafo topológicamente (de hojas a raíz)
        topo, visited = [], set()
        def build_topo(v):
            if v not in visited:
                visited.add(v)
                for child in v._prev:
                    build_topo(child)
                topo.append(v)
        build_topo(self)

        # 2. El gradiente de la salida respecto de sí misma es siempre 1
        self.grad = 1.0

        # 3. Recorrer en orden inverso y propagar
        for node in reversed(topo):
            node._backward()

Ejemplo paso a paso

# Crear las variables (hojas del grafo)
a = Value(2.0, label='a')
b = Value(3.0, label='b')
c = Value(4.0, label='c')

# FORWARD PASS: calcular y construir el grafo
d = a + b   # d = 5
d.label = 'd'
L = d * c   # L = 20
L.label = 'L'

# BACKWARD PASS: calcular todos los gradientes
L.backward()

print(f"∂L/∂a = {a.grad}")  # 4.0  ← Si a sube 1, L sube 4
print(f"∂L/∂b = {b.grad}")  # 4.0  ← Si b sube 1, L sube 4
print(f"∂L/∂c = {c.grad}")  # 5.0  ← Si c sube 1, L sube 5
print(f"∂L/∂d = {d.grad}")  # 4.0

Verificación manual:

L=(a+b)cL = (a + b) \cdot c

La=c=4Lc=(a+b)=d=5\frac{\partial L}{\partial a} = c = 4 \checkmark \qquad \frac{\partial L}{\partial c} = (a + b) = d = 5 \checkmark


Ejemplo 2: tres variables independientes (L = a * b + c)

Ahora agregamos una variable que entra directamente al resultado final, sin pasar por un nodo intermedio.

a = Value(3.0, label='a')
b = Value(4.0, label='b')
c = Value(2.0, label='c')

d = a * b; d.label = 'd'   # d = 12
L = d + c; L.label = 'L'   # L = 14

L.backward()

print(f"∂L/∂L = {L.grad}")  # 1.0  (siempre)
print(f"∂L/∂d = {d.grad}")  # 1.0  (L = d + c  →  ∂(d+c)/∂d = 1)
print(f"∂L/∂c = {c.grad}")  # 1.0  (L = d + c  →  ∂(d+c)/∂c = 1)
print(f"∂L/∂a = {a.grad}")  # 4.0  (∂L/∂d × ∂d/∂a = 1 × b = 1 × 4)
print(f"∂L/∂b = {b.grad}")  # 3.0  (∂L/∂d × ∂d/∂b = 1 × a = 1 × 3)

Lo interesante es c: entra directamente a L sin pasar por ningún nodo intermedio. Su gradiente es 1 porque L = d + c y la derivada de una suma respecto de cualquiera de sus partes es 1.

a ──┐
    ├──[*]── d ──┐
b ──┘            ├──[+]── L
c ───────────────┘

Verificación manual:

L=ab+cL = a \cdot b + c

La=b=4Lb=a=3Lc=1\frac{\partial L}{\partial a} = b = 4 \checkmark \qquad \frac{\partial L}{\partial b} = a = 3 \checkmark \qquad \frac{\partial L}{\partial c} = 1 \checkmark


Ejemplo 3: una variable en dos ramas (L = (a + b) * (a + c))

Este es el caso más instructivo: la variable a aparece en dos lugares distintos del grafo.

a = Value(2.0, label='a')
b = Value(1.0, label='b')
c = Value(3.0, label='c')

d = a + b; d.label = 'd'   # d = 3
e = a + c; e.label = 'e'   # e = 5
L = d * e; L.label = 'L'   # L = 15

L.backward()

print(f"∂L/∂d = {d.grad}")  # 5.0  (∂L/∂d = e = 5)
print(f"∂L/∂e = {e.grad}")  # 3.0  (∂L/∂e = d = 3)
print(f"∂L/∂b = {b.grad}")  # 5.0  (∂L/∂d × 1 = 5)
print(f"∂L/∂c = {c.grad}")  # 3.0  (∂L/∂e × 1 = 3)
print(f"∂L/∂a = {a.grad}")  # 8.0  ← suma de DOS caminos

El grafo tiene dos caminos que llegan a a:

a ──┐
    ├──[+]── d ──┐
b ──┘            ├──[*]── L
a ──┐            │
    ├──[+]── e ──┘
c ──┘

El gradiente de a se acumula desde ambas ramas:

rama d:  ∂L/∂d × ∂d/∂a = 5 × 1 = 5
rama e:  ∂L/∂e × ∂e/∂a = 3 × 1 = 3
─────────────────────────────────────
a.grad = 5 + 3 = 8

Verificación manual (expandiendo algebraicamente):

L=(a+b)(a+c)=a2+ac+ab+bcL = (a+b)(a+c) = a^2 + ac + ab + bc

La=2a+c+b=22+3+1=8\frac{\partial L}{\partial a} = 2a + c + b = 2 \cdot 2 + 3 + 1 = 8 \checkmark

Este es exactamente el motivo por el que en _backward se usa += y no =: si usaras =, a.grad quedaría con el valor del último camino que se ejecute (3 o 5), perdiendo la contribución del otro.


El truco del += en los gradientes

Notás que en todos los _backward se usa += y no =. Esto es fundamental.

Una variable puede aparecer en múltiples lugares del grafo. Por ejemplo, en v4 = (x + y) * x, la variable x aparece dos veces.

Durante el backward, los gradientes que llegan por cada camino se acumulan:

x aparece en:
  (1) la multiplicación: aporta grad_1
  (2) la suma:           aporta grad_2

x.grad = grad_1 + grad_2  ← por eso se usa +=

Si usaras = en lugar de +=, perderías las contribuciones de los caminos anteriores y el gradiente sería incorrecto.


El orden topológico: por qué es necesario

El backward debe procesar los nodos en un orden específico: primero los nodos que están más cerca del resultado final, luego los que están más cerca de las hojas.

Esto se garantiza con un ordenamiento topológico del grafo. Es el mismo concepto que en la teoría de grafos: si hay una arista de A a B, entonces A aparece antes que B en el orden.

Si el orden fuera incorrecto, un nodo podría propagarse antes de que su propio gradiente esté completo, generando resultados erróneos.

# El backward() construye este orden internamente:
# topo = [a, b, d, c, L]  ← orden de hojas a raíz

# Luego lo recorre al revés:
# reversed(topo) = [L, c, d, b, a]  ← de raíz a hojas

Resumen: los conceptos clave

ConceptoQué es
Grafo computacionalRegistro de todas las operaciones y sus dependencias
Nodo hojaVariable original, sin operación que la genere (ej: pesos del modelo)
Forward passEjecutar el cálculo y construir el grafo
Backward passRecorrer el grafo al revés aplicando la regla de la cadena
gradGradiente acumulado: ∂L/∂nodo
_backwardFunción guardada en cada nodo que sabe cómo propagar su gradiente
Orden topológicoGarantiza que cada nodo procesa su gradiente cuando ya está completo

Lo que viene después

Con esta base ya es posible entender cómo se construyen motores de diferenciación automática completos, como el núcleo de PyTorch (autograd).

En el próximo post vamos a ver micrograd en detalle: la implementación completa de Andrej Karpathy que extiende estos conceptos para soportar más operaciones (tanh, exp, potencias) y llega a construir una red neuronal completa desde cero.

Si querés ir adelantando, te recomiendo:


Más sobre ia Probar Yagware gratis