Cerramos 05-03 con una limitación confesada: para capturar la verdad no lineal de Rutalia tuvimos que fabricar las features a mano (polinomios, interacciones). Las redes neuronales eliminan ese trabajo: aprenden también las transformaciones. La buena noticia es que ya tienes las dos piezas que las componen: la combinación lineal + sigmoide de la regresión logística (05-02) es exactamente una neurona, y el descenso de gradiente (05-03) es exactamente su algoritmo de entrenamiento. Lo único genuinamente nuevo es backpropagation: cómo calcular el gradiente cuando hay capas apiladas — la regla de la cadena convertida en algoritmo. En esta lección construiremos una red desde cero: forward pass calculado a mano con números concretos, la derivación guiada del backprop en una red mínima, y una implementación completa en numpy puro entrenada sobre el dataset canónico para predecir retrasos.

Contenido

  1. De la neurona a la red multicapa
  2. Por qué hacen falta activaciones no lineales
  3. Funciones de activación
  4. Forward pass a mano: una entrega concreta
  5. Backpropagation: la regla de la cadena hecha algoritmo
  6. Implementación completa en numpy
  7. Entrenamiento: épocas, batches y curvas de pérdida
  8. Qué es deep learning y cuándo compensa
  9. Frameworks: qué automatizan PyTorch y Keras

De la neurona a la red multicapa

Una neurona artificial es la regresión logística de 05-02: recibe entradas, calcula una combinación lineal y aplica una función de activación.

salida = g(w·x + b)          — con g = sigmoide, ES la regresión logística

La novedad estructural: apilar neuronas en capas. Cada neurona de una capa recibe las salidas de todas las de la capa anterior:

flowchart LR
    subgraph E["Entrada (features)"]
    x1[distancia_km]
    x2[hora_salida]
    end
    subgraph O["Capa oculta"]
    h1((h1))
    h2((h2))
    end
    subgraph S["Salida"]
    o((p_retraso))
    end
    x1 --> h1 & h2
    x2 --> h1 & h2
    h1 --> o
    h2 --> o

La interpretación clave: la capa oculta aprende features intermedias. En 05-03, el término distancia × hora_punta lo inventamos nosotros; aquí, una neurona oculta puede aprender a activarse justo ante "entrega larga en hora punta" — la red fabrica sus propias interacciones. Los pesos de cada capa se agrupan en una matriz, y todo el forward pass son multiplicaciones de matrices: por eso numpy (y las GPU) son el hábitat natural de las redes.

Por qué hacen falta activaciones no lineales

Pregunta de examen: ¿qué pasa si quitamos las activaciones (g = identidad)? Encadenemos dos capas lineales:

h = W1·x + b1
y = W2·h + b2 = W2·(W1·x + b1) + b2 = (W2·W1)·x + (W2·b1 + b2)

El resultado es W'·x + b': otra función lineal. Por muchas capas que apiles, una red sin activaciones colapsa exactamente a la regresión lineal de 05-03 — cientos de parámetros para expresar lo mismo que una recta. La no linealidad entre capas es lo que impide el colapso y da a la red su poder: con suficientes neuronas ocultas y una activación no lineal, una red puede aproximar cualquier función continua (teorema de aproximación universal). Poder representarla no garantiza poder aprenderla, pero abre la puerta.

Funciones de activación

Función Fórmula Rango Derivada Uso típico
Sigmoide 1/(1+e⁻ᶻ) (0, 1) σ(z)·(1−σ(z)) Capa de salida en clasificación binaria (es una probabilidad)
Tanh tanh(z) (−1, 1) 1−tanh²(z) Capas ocultas (histórica); centrada en 0
ReLU max(0, z) [0, ∞) 1 si z>0, si no 0 Capas ocultas (el estándar moderno)

¿Por qué ReLU destronó a la sigmoide en las capas ocultas? Por sus derivadas. La derivada de la sigmoide vale como máximo 0.25 y se hace casi 0 cuando |z| es grande (la neurona se satura): al encadenar capas, los gradientes se multiplican y se desvanecen — las primeras capas dejan de aprender. La derivada de ReLU es 1 en toda la zona activa: el gradiente atraviesa las capas sin encogerse, y además cuesta una comparación en vez de una exponencial. Regla práctica: ReLU en las ocultas, sigmoide en la salida binaria.

Forward pass a mano: una entrega concreta

Nada sustituye a hacer las cuentas una vez. Red mínima: 2 entradas (estandarizadas), 2 neuronas ocultas con ReLU, 1 salida sigmoide. Tomemos una entrega tipo C-1042: lejos y en hora punta → tras estandarizar, x = (1.0, 1.5) (distancia, hora). Pesos actuales (los habría dejado así el entrenamiento a medias):

W1 = [[ 0.8, -0.4],      b1 = [0.1, 0.2]
      [ 0.5,  0.9]]
W2 = [ 1.2, -0.7]        b2 = -0.3

Paso a paso:

Capa oculta (pre-activación):
  z1 = 0.8·1.0 + 0.5·1.5 + 0.1 = 1.65     → h1 = ReLU(1.65) = 1.65
  z2 = -0.4·1.0 + 0.9·1.5 + 0.2 = 1.15    → h2 = ReLU(1.15) = 1.15

Salida:
  z3 = 1.2·1.65 + (-0.7)·1.15 + (-0.3) = 0.875
  p  = σ(0.875) = 1/(1+e^-0.875) ≈ 0.706

La red estima un 70.6% de probabilidad de retraso para esta entrega. En código, todo el cálculo es:

relu = lambda z: np.maximum(0, z)
x = np.array([1.0, 1.5])
h = relu(W1.T @ x + b1)          # capa oculta: (2,)
p = sigmoide(W2 @ h + b2)        # salida: escalar, 0.706

Si la entrega llegó realmente tarde (y = 1), el error es p − y = −0.294: la red se quedó corta. La pregunta del millón: ¿cuánto debe cambiar cada uno de los 9 parámetros (4+2 de la capa oculta, 2+1 de la salida) para reducir ese error? Responderla es backpropagation.

Backpropagation: la regla de la cadena hecha algoritmo

En 05-03, con un solo nivel (ŷ = w·x + b), derivar el coste respecto a cada peso era directo. Ahora W1 afecta al coste a través de h, que afecta a través de z3, que afecta a través de p. La herramienta es la regla de la cadena: la derivada de una composición es el producto de las derivadas de los eslabones. Backpropagation es simplemente organizarla para no repetir cálculos: se computa el gradiente de la última capa y se propaga hacia atrás, reutilizando en cada capa lo ya calculado en la siguiente.

Derivemos guiados, sobre la red mínima, con coste de entropía cruzada L = −[y·ln p + (1−y)·ln(1−p)] (el estándar en clasificación; su gradiente es más limpio que el del MSE aquí).

Eslabón 1 — de la pérdida a z3. Componiendo la derivada de L respecto a p con la de la sigmoide (σ' = σ(1−σ)) se produce una cancelación memorable:

δ3 = ∂L/∂z3 = p − y = 0.706 − 1 = −0.294

El "mensaje de error" de la salida es, literalmente, el error de predicción. (La misma forma que tenía el gradiente en la regresión lineal: no es casualidad, la entropía cruzada está elegida para eso.)

Eslabón 2 — gradientes de la capa de salida. z3 = W2·h + b2, así que cada peso de W2 recibe el error escalado por la activación que lo atravesó:

∂L/∂W2 = δ3 · h = (−0.294·1.65, −0.294·1.15) = (−0.485, −0.338)
∂L/∂b2 = δ3 = −0.294

Eslabón 3 — propagar el error a la capa oculta. ¿Qué parte de la culpa tiene cada neurona oculta? La que le transmite su peso hacia la salida, filtrada por la derivada de su ReLU (1 aquí, porque ambas z eran positivas; una neurona "apagada" no recibiría culpa):

δ1 = δ3 · W2[0] · ReLU'(z1) = −0.294 · 1.2 · 1 = −0.353
δ2 = δ3 · W2[1] · ReLU'(z2) = −0.294 · (−0.7) · 1 = +0.206

Fíjate en el signo de δ2: como su peso de salida es negativo (−0.7), a h2 le conviene bajar para que p suba. La red reparte la culpa con signo.

Eslabón 4 — gradientes de la capa oculta. Mismo patrón que el eslabón 2, un nivel más abajo: ∂L/∂W1[i,j] = δj · xi, ∂L/∂b1 = δ.

Y con todos los gradientes, un paso de descenso de 05-03 tal cual: W ← W − α·∂L/∂W. Eso es todo backpropagation: forward guardando las activaciones, backward multiplicando errores por pesos y derivadas de activación, capa a capa hacia atrás. Computacionalmente es programación dinámica sobre el grafo de cálculo (01-03): cada δ se calcula una vez y se reutiliza para todos los gradientes de su capa; el backward cuesta lo mismo que el forward, O(nº de pesos), cuando la aplicación ingenua de la regla de la cadena costaría mucho más.

Implementación completa en numpy

Generalicemos a matrices y al dataset canónico completo (13 features, capa oculta de 16, salida binaria):

class RedNeuronal:
    """Red de 2 capas (oculta ReLU + salida sigmoide) en numpy puro."""

    def __init__(self, d_entrada, d_oculta, semilla=42):
        rng = np.random.default_rng(semilla)
        # Inicialización pequeña y aleatoria: romper la simetría es esencial
        # (con pesos iguales, todas las neuronas aprenderían lo mismo)
        self.W1 = rng.normal(0, np.sqrt(2 / d_entrada), (d_entrada, d_oculta))
        self.b1 = np.zeros(d_oculta)
        self.W2 = rng.normal(0, np.sqrt(2 / d_oculta), (d_oculta, 1))
        self.b2 = np.zeros(1)

    def forward(self, X):
        """Predice y GUARDA las activaciones (backprop las necesitará)."""
        self.Z1 = X @ self.W1 + self.b1        # (n, 16) pre-activación
        self.H = np.maximum(0, self.Z1)        # (n, 16) ReLU
        self.P = sigmoide(self.H @ self.W2 + self.b2)  # (n, 1) probabilidad
        return self.P

    def backward(self, X, y, alpha=0.1):
        """Backpropagation + un paso de descenso de gradiente."""
        n = len(y)
        # Eslabón 1: error en la salida (entropía cruzada + sigmoide)
        d3 = self.P - y.reshape(-1, 1)                   # (n, 1)
        # Eslabón 2: gradientes de la capa de salida
        gW2 = self.H.T @ d3 / n                          # (16, 1)
        gb2 = d3.mean(axis=0)
        # Eslabón 3: propagar el error a la capa oculta
        d1 = (d3 @ self.W2.T) * (self.Z1 > 0)            # (n, 16); ReLU' = máscara
        # Eslabón 4: gradientes de la capa oculta
        gW1 = X.T @ d1 / n                               # (13, 16)
        gb1 = d1.mean(axis=0)
        # Paso de descenso (05-03, sin cambios)
        self.W2 -= alpha * gW2; self.b2 -= alpha * gb2
        self.W1 -= alpha * gW1; self.b1 -= alpha * gb1

    def perdida(self, X, y):
        p = np.clip(self.forward(X), 1e-9, 1 - 1e-9)     # evita log(0)
        y = y.reshape(-1, 1)
        return float(-(y * np.log(p) + (1 - y) * np.log(1 - p)).mean())

Cada línea del backward es uno de los eslabones que derivamos a mano, vectorizado para n ejemplos a la vez. La máscara (self.Z1 > 0) es la derivada de ReLU aplicada en bloque: las neuronas apagadas no propagan culpa.

Entrenamiento: épocas, batches y curvas de pérdida

El bucle de entrenamiento es el mini-batch de 05-03, literalmente:

# Dataset canónico, objetivo retraso, pipeline de 05-01
red = RedNeuronal(d_entrada=X_tr_e.shape[1], d_oculta=16)
rng = np.random.default_rng(0)
historia = {"train": [], "test": []}

for epoca in range(300):
    orden = rng.permutation(len(y_tr))                 # barajar cada época
    for i in range(0, len(y_tr), 64):                  # mini-batches de 64
        lote = orden[i:i + 64]
        red.forward(X_tr_e[lote])
        red.backward(X_tr_e[lote], y_tr[lote], alpha=0.1)
    historia["train"].append(red.perdida(X_tr_e, y_tr))
    historia["test"].append(red.perdida(X_te_e, y_te))

pred = (red.forward(X_te_e) > 0.5).astype(int).ravel()
print(f"Exactitud red numpy: {(pred == y_te).mean():.3f}")

Vocabulario que ya conoces de 05-03, ahora en su hábitat definitivo:

  • Época: una pasada completa por el entrenamiento. Aquí 300; las redes necesitan muchas.
  • Mini-batch: 64 entregas por paso de gradiente — el equilibrio ruido/coste de 05-03.
  • Curva de pérdida: historia["train"] y historia["test"] son tu electrocardiograma. Si ambas bajan, todo bien; si train baja y test sube, la red ha empezado a memorizar — es el momento de parar (early stopping: la versión temporal de la poda de 05-02).

En este problema la red debería igualar o superar ligeramente al random forest de 05-02, capturando la estructura multiplicativa sin ingeniería de features. Y una advertencia importante: a diferencia del MSE lineal de 05-03, la superficie de pérdida de una red no es convexa — hay mínimos locales y mesetas, la inicialización importa (por eso rompe la simetría con valores aleatorios) y dos entrenamientos con semillas distintas pueden dar redes distintas. El ruido del mini-batch, curiosamente, ayuda a escapar de los malos rincones.

Qué es deep learning y cuándo compensa

Deep learning = redes con muchas capas ocultas, donde cada capa aprende representaciones sobre las de la anterior (píxeles → bordes → formas → objetos). La profundidad compone transformaciones, y eso es exponencialmente expresivo. Pero no es gratis, y para datos como los de Rutalia rara vez es la primera opción:

Escenario Datos Modelo recomendado Por qué
Tabular pequeño/mediano (nuestro dataset) 10³-10⁵ filas Random forest / gradient boosting / red pequeña Los árboles rinden igual o mejor, sin ajuste fino y más baratos
Tabular masivo con interacciones complejas >10⁶ filas Redes medianas compiten El volumen alimenta la capacidad
Imágenes (¿paquete dañado en la foto?) Muchas CNN Convoluciones: explotan la estructura espacial
Secuencias/series (demanda por hora) Muchas RNN / transformers Explotan el orden temporal
Texto (notas de incidencia del repartidor) Muchas Transformers Atención sobre contexto; base de los LLM

CNN, RNN y transformers son arquitecturas especializadas: la misma maquinaria (neuronas, activaciones, backprop, descenso de gradiente) con conexiones estructuradas para cada tipo de dato. No las desarrollamos aquí — con lo aprendido, tienes la base exacta sobre la que se construyen.

Advertencia práctica de coste: entrenar deep learning en serio es computacionalmente caro — horas o días de GPU, búsqueda de hiperparámetros (arquitectura, α, batch, épocas...) que multiplica los entrenamientos, y factura energética y económica real. Nuestra red de 16 neuronas entrena en segundos en CPU; un modelo de visión son órdenes de magnitud más. Antes de desplegar deep learning en Rutalia, la pregunta obligada es si un random forest, entrenado en minutos e interpretable, no da ya el 95% del beneficio.

Frameworks: qué automatizan PyTorch y Keras

En la práctica profesional nadie escribe el backward a mano: se usa PyTorch o Keras/TensorFlow. Lo que automatizan, en orden de importancia:

  • Autograd (diferenciación automática): defines solo el forward; el framework registra el grafo de operaciones y genera el backward solo. El autograd hace por ti, para cualquier arquitectura, exactamente el backprop que acabas de escribir — por eso valía la pena escribirlo una vez: ahora sabes qué hay dentro de la caja.
  • GPU: las multiplicaciones de matrices se despachan a hardware masivamente paralelo con cambiar una línea (.to("cuda")).
  • Optimizadores avanzados: Adam, momentum... — descenso de gradiente con learning rate adaptativo por parámetro; el bucle es el mismo.
  • Capas prefabricadas: convoluciones, atención, normalización, dropout — los ladrillos de las arquitecturas de la tabla anterior.

Nuestra red completa, en Keras, para que veas la correspondencia:

# modelo = keras.Sequential([
#     keras.layers.Dense(16, activation="relu"),     # nuestra W1, b1 + ReLU
#     keras.layers.Dense(1, activation="sigmoid"),   # nuestra W2, b2 + sigmoide
# ])
# modelo.compile(optimizer="adam", loss="binary_crossentropy")
# modelo.fit(X_tr_e, y_tr, epochs=300, batch_size=64)   # nuestro bucle

Cuatro líneas que encapsulan todo lo que hemos construido. Úsalas — pero ahora sabiendo qué ejecutan.

Errores Comunes y Consejos

  • Olvidar las activaciones no lineales. Una red profunda sin ellas es una regresión lineal cara (lo demostramos algebraicamente). Si tu red no supera a la lineal, revisa que las activaciones estén ahí.
  • Inicializar los pesos a cero. Todas las neuronas de una capa reciben entonces el mismo gradiente y aprenden lo mismo para siempre (simetría no rota). Inicialización aleatoria pequeña, siempre.
  • No guardar las activaciones del forward. El backward necesita H y Z1; recalcularlas duplica el coste, no tenerlas hace el backprop imposible. Es el mismo principio de la memoización de 01-03.
  • log(0) en la entropía cruzada. Cuando la sigmoide satura, p llega a 0.0 o 1.0 en coma flotante y la pérdida da NaN. np.clip(p, 1e-9, 1-1e-9) te salva el entrenamiento.
  • Datos sin estandarizar. Todo lo dicho en 05-03 sobre el descenso de gradiente aplica multiplicado: entradas grandes saturan las sigmoides y descompensan los gradientes desde la primera época.
  • Consejo: monitoriza siempre las DOS curvas de pérdida (train y test). Train baja + test sube = sobreajuste en directo: para ahí (early stopping). Es el diagnóstico más barato y fiable que existe.

Ejercicios

  1. El colapso lineal, verificado. Modifica RedNeuronal para que la capa oculta use la identidad en lugar de ReLU (self.H = self.Z1, y en el backward elimina la máscara). Entrena ambas versiones 300 épocas y compara exactitud en test con la regresión logística de sklearn. ¿Confirmas que la red "lineal" no supera a la logística mientras la ReLU sí?

  2. Un paso de backprop a mano. Con la red mínima del forward manual (pesos dados, x=(1.0, 1.5), y=1) y α=0.5, calcula a mano los 9 gradientes, actualiza los parámetros y vuelve a hacer el forward. Comprueba que la nueva p es mayor que 0.706 (la red corrige hacia la clase correcta).

  3. Curvas de pérdida y sobreajuste provocado. Entrena la red con d_oculta=128 sobre solo las 200 primeras entregas del entrenamiento durante 2000 épocas, registrando ambas pérdidas cada 50. Localiza la época a partir de la cual la pérdida de test empieza a subir mientras la de train sigue bajando, y aplica early stopping: ¿qué exactitud da la red parada en ese punto frente a la red entrenada hasta el final?

Soluciones

Ejercicio 1:

# En forward:  self.H = self.Z1
# En backward: d1 = d3 @ self.W2.T        (sin la máscara (Z1 > 0))

La versión identidad se estanca en una exactitud indistinguible de LogisticRegression() — como demostramos, W2·(W1·x+b1)+b2 es una función lineal de x, así que su frontera de decisión es un hiperplano, igual que la logística. La versión ReLU la supera al capturar las interacciones (distancia×hora, zona congestionada) que el hiperplano no puede expresar.

Ejercicio 2:

δ3 = p − y = −0.294
∂L/∂W2 = δ3·h = (−0.485, −0.338)      ∂L/∂b2 = −0.294
δ1 = δ3·1.2·1 = −0.353                δ2 = δ3·(−0.7)·1 = 0.206
∂L/∂W1 = [[δ1·x1, δ2·x1], [δ1·x2, δ2·x2]]
       = [[−0.353, 0.206], [−0.529, 0.309]]
∂L/∂b1 = (−0.353, 0.206)

Actualización (θ ← θ − 0.5·g):
W2 → (1.443, −0.531)   b2 → −0.153
W1 → [[0.976, −0.503], [0.765, 0.745]]   b1 → (0.276, 0.097)

Nuevo forward: z1 = 0.976 + 0.765·1.5 + 0.276 = 2.400 → h1 = 2.400
               z2 = −0.503 + 0.745·1.5 + 0.097 = 0.712 → h2 = 0.712
               z3 = 1.443·2.400 − 0.531·0.712 − 0.153 = 2.933
               p = σ(2.933) ≈ 0.949 > 0.706 ✓

Un solo paso y la probabilidad de retraso sube de 0.706 a ≈0.95: cada parámetro se movió en la dirección exacta que la regla de la cadena le asignó. (Con α=0.5 el paso es grande y didáctico; en entrenamiento real, pasos más pequeños y muchos ejemplos a la vez.)

Ejercicio 3:

red = RedNeuronal(X_tr_e.shape[1], d_oculta=128)
Xp, yp = X_tr_e[:200], y_tr[:200]
for epoca in range(2000):
    red.forward(Xp); red.backward(Xp, yp, alpha=0.1)
    if epoca % 50 == 0:
        print(epoca, round(red.perdida(Xp, yp), 3),
              round(red.perdida(X_te_e, y_te), 3))

Con 128 neuronas y solo 200 ejemplos, la red tiene capacidad de sobra para memorizar: la pérdida de train baja hacia 0 indefinidamente, mientras la de test alcanza su mínimo relativamente pronto y luego remonta — la curva en U de 05-01, dibujada ahora sobre el eje del tiempo de entrenamiento. La red detenida en el mínimo de test (early stopping) da mejor exactitud que la entrenada 2000 épocas: entrenar más no es entrenar mejor.

Conclusión

Ya no hay caja negra: una red neuronal son regresiones logísticas apiladas con activaciones no lineales (sin ellas, colapso algebraico a la lineal de 05-03), entrenadas con el descenso de gradiente de 05-03 y con los gradientes calculados por backpropagation — la regla de la cadena organizada como programación dinámica sobre el grafo de cálculo, propagando la culpa capa a capa hacia atrás. La calculaste a mano, la vectorizaste en numpy, la entrenaste con mini-batches sobre el dataset canónico y viste el sobreajuste dibujarse en las curvas de pérdida. También sabes cuándo no usarla: en datos tabulares como los de Rutalia, un random forest suele dar batalla con una fracción del coste, y las arquitecturas profundas (CNN, RNN, transformers) brillan en imágenes, secuencias y texto, donde el autograd de PyTorch/Keras hará por ti el backward que ahora entiendes por dentro. Con esto cerramos el aprendizaje supervisado. Queda la última frontera del módulo: ¿y si no hay etiqueta? Nadie ha marcado qué zonas de la ciudad "se comportan igual" — hay que descubrirlo. En 05-05, clustering: aprendizaje no supervisado, donde además cerraremos un círculo que dejamos abierto en el módulo 3 con el MST de Kruskal.

© Copyright 2026. Todos los derechos reservados