Capítulo 15 de 21 10 secciones 8 min

Compartir

La regla de la cadena es la retropropagación

Qué dice la regla de la cadena, cómo se aplica eslabón por eslabón sobre una regresión logística, y por qué eso es exactamente lo que hace una red.

La regla de la cadena dice que la derivada de algo encadenado es el producto de las derivadas de cada eslabón. La retropropagación de una red neuronal es literalmente esa regla aplicada capa por capa, y el problema del gradiente que se desvanece es lo que pasa cuando esos factores son menores que uno y se multiplican muchas veces.

El capítulo que explica la retropropagación 🌸

Te lo digo de entrada para que sepas dónde estás: la retropropagación no es un algoritmo aparte. Es la regla de la cadena, que es una regla de derivadas de toda la vida, aplicada con orden.

Cuando termines este capítulo, "Repartir la culpa hacia atrás" de mi libro de deep learning se lee distinto.

Qué dice

Si una cosa depende de otra que depende de otra, la derivada del todo es el producto de las derivadas de cada paso.

dLdw=dLdp·dpdz·dzdw

la regla de la cadena dice que la derivada de algo encadenado es el producto de las derivadas de cada eslabón, cada una calculada donde le toca

El ejemplo de siempre: si un coche va al doble de rápido que otro, y ese otro al triple que un tercero, el primero va seis veces más rápido que el tercero. Se multiplica.

Volvemos a abrir el archivo, con la columna que se predice

Acá necesito una columna binaria, así que meto compro, que vale 1 si la venta se cerró y 0 si no.

import pandas as pd
import numpy as np

df = pd.read_csv('ventas-miss-yera.csv')
for c in ('unidades', 'monto', 'satisfaccion'):
    df[c] = pd.to_numeric(df[c], errors='coerce')

d = df[['unidades', 'monto', 'satisfaccion', 'compro']].dropna()
X = d[['unidades', 'monto', 'satisfaccion']].to_numpy()
t = d['compro'].to_numpy().astype(float)

Z = (X - X.mean(axis=0)) / X.std(axis=0)
A = np.column_stack([np.ones(len(Z)), Z])
print('A.shape:', A.shape)
print('compro 1:', int(t.sum()), '| compro 0:', int((1 - t).sum()))
A.shape: (2238, 4)
compro 1: 1285 | compro 0: 953

La cadena de tres eslabones

Una regresión logística hace tres cosas seguidas:

  1. z: el producto punto del capítulo 4, que da un número sin límites.
  2. p: pasarlo por la sigmoide, que lo aplasta entre 0 y 1 para que se pueda leer como probabilidad.
  3. L: la pérdida, que compara esa probabilidad con lo que pasó de verdad.
def sigmoide(z):
    return 1 / (1 + np.exp(-z))

w = np.array([0.0, 0.1, -0.2, 0.3])
z = A @ w
p = sigmoide(z)
L = float(-(t * np.log(p) + (1 - t) * np.log(1 - p)).mean())

print('z[:3]  :', np.round(z[:3], 4))
print('p[:3]  :', np.round(p[:3], 4))
print('perdida:', round(L, 6))
z[:3]  : [ 0.2637  0.0413 -0.23  ]
p[:3]  : [0.5656 0.5103 0.4427]
perdida: 0.697834

Tres eslabones: los pesos hacen la z, la z hace la p, la p hace la pérdida. Y lo que quiero saber es cuánto cambia la pérdida si muevo un peso, o sea el gradiente del capítulo 14.

Bajamos por la cadena, eslabón por eslabón

dL_dp = (-(t / p) + (1 - t) / (1 - p)) / len(t)
dp_dz = p * (1 - p)
dL_dz = dL_dp * dp_dz
grad = A.T @ dL_dz

print('gradiente por la cadena:', np.round(grad, 6))
gradiente por la cadena: [-0.07391   0.01318  -0.138736 -0.02479 ]

Léelo de arriba abajo, que es como se hace de verdad:

  • dL_dp: cuánto cambia la pérdida si cambia la probabilidad.
  • dp_dz: cuánto cambia la probabilidad si cambia z. Esa es la derivada de la sigmoide.
  • dL_dz: los dos multiplicados. Primer eslabón encadenado.
  • A.T @ dL_dz: el último eslabón, que es cuánto cambia z si cambia el peso, y como z es un producto punto, eso son los propios datos.

Eso es retropropagación. Empiezas por el final, calculas la derivada de un eslabón, la multiplicas por lo que traías, y sigues bajando 🐣

σ(z)=σ(z)(1σ(z))

la derivada de la sigmoide se escribe con la propia sigmoide, y vale como mucho un cuarto, justo en el medio

Las tres comprobaciones

corto = A.T @ (p - t) / len(t)
print('con la formula corta:', np.round(corto, 6))
print('iguales:', np.allclose(grad, corto))
con la formula corta: [-0.07391   0.01318  -0.138736 -0.02479 ]
iguales: True

Esa fórmula corta es la que sale en los libros: predicción menos realidad, por los datos. Parece magia y no lo es: es la cadena de arriba simplificada a mano. Los términos de la pérdida y de la sigmoide se cancelan.

Por eso la sigmoide y esa pérdida van juntas. No es costumbre, es que al juntarlas la cadena se simplifica 🌟

h = 1e-6
def perdida(ww):
    pp = sigmoide(A @ ww)
    return float(-(t * np.log(pp) + (1 - t) * np.log(1 - pp)).mean())

numerico = []
for i in range(len(w)):
    mas, menos = w.copy(), w.copy()
    mas[i] += h
    menos[i] -= h
    numerico.append((perdida(mas) - perdida(menos)) / (2 * h))

print('gradiente numerico  :', np.round(numerico, 6))
gradiente numerico  : [-0.07391   0.01318  -0.138736 -0.02479 ]

Tres caminos, el mismo vector. El gradient checking del capítulo 13 otra vez, y ahora sobre algo que sí se parece a un modelo.

Y ahora el problema que hundió al deep learning veinte años

for z_ in (0.0, 5.0, -5.0, 10.0):
    s = sigmoide(z_)
    print('z =', z_, '-> derivada', round(float(s * (1 - s)), 6))
z = 0.0 -> derivada 0.25
z = 5.0 -> derivada 0.006648
z = -5.0 -> derivada 0.006648
z = 10.0 -> derivada 4.5e-05

La derivada de la sigmoide vale como mucho 0.25, y eso solo justo en el medio. En cuanto te alejas se desploma.

Ahora acuérdate de que la cadena multiplica. Diez capas con sigmoide, cada una aportando 0.0066, dejan un gradiente de diez elevado a menos veintiuno.

Traducido: las primeras capas no aprenden nada. El gradiente que les llega es cero para todos los efectos.

Eso se llama el problema del gradiente que se desvanece, y fue lo que tuvo frenadas a las redes profundas durante años. La solución que se acabó imponiendo es la ReLU, cuya derivada vale uno pelado en la mitad positiva, así que la cadena no la apaga. Toda esa historia está contada en mi libro de deep learning; acá solo quería que vieras de dónde sale el número 🙂

Lo que te llevas

  • La regla de la cadena multiplica las derivadas de cada eslabón.
  • La retropropagación es esa regla, empezando por el final.
  • La derivada de la sigmoide se escribe con la propia sigmoide.
  • La fórmula corta "predicción menos realidad" es la cadena simplificada.
  • La sigmoide y su pérdida van juntas porque al juntarlas se cancelan cosas.
  • Factores menores que uno multiplicados muchas veces apagan el gradiente.

Comprueba que se entendió

Comprueba que lo tienes

En una red profunda, la derivada de la sigmoide vale 0.0066 en varias capas seguidas. ¿Qué le pasa al gradiente que llega a las primeras?

  • Se apaga, porque la cadena multiplica números chiquitos una y otra vez
  • Se dispara, porque hay muchos factores acumulados
  • Se queda igual, porque la regla de la cadena suma en vez de multiplicar
  • No le pasa nada, porque cada capa calcula su gradiente por separado

Ejercicios

1. La cadena con números a mano

Una sola fila, tres eslabones, sin vectores.

def sigmoide(z):
    return 1 / (1 + np.exp(-z))

z0 = 0.2637
p0 = sigmoide(z0)
t0 = 1.0

dL_dp = -(t0 / p0)
dp_dz = p0 * (1 - p0)
print('dL_dp    :', round(dL_dp, 6))
print('dp_dz    :', round(dp_dz, 6))
print('dL_dz    :', round(dL_dp * dp_dz, 6))
print('directo  :', round(p0 - t0, 6))
dL_dp    : -1.768204
dp_dz    : 0.245704
dL_dz    : -0.434454
directo  : -0.434454

Dos derivadas feas multiplicadas dan una resta simple. Cuando en un paper veas que la derivada de la logística es "p menos t", ya sabes qué se canceló para llegar ahí.

2. Diez capas de sigmoide

Multiplica el factor diez veces y mira el desastre.

factor = 0.006648
g = 1.0
for capa in range(1, 11):
    g = g * factor
    if capa in (1, 3, 5, 10):
        print('capa', capa, '-> gradiente', g)
capa 1 -> gradiente 0.006648
capa 3 -> gradiente 2.9381436979200003e-07
capa 5 -> gradiente 1.2985391681147732e-11
capa 10 -> gradiente 1.6862039711282078e-22

Diez elevado a menos veintidós. En coma flotante eso sigue siendo un número, pero al sumárselo a un peso no cambia ni el último decimal. La capa está viva y congelada a la vez.

3. La ReLU no apaga nada

Compara las dos derivadas encadenadas.

def d_relu(z):
    return 1.0 if z > 0 else 0.0

def d_sig(z):
    s = 1 / (1 + np.exp(-z))
    return s * (1 - s)

gs, gr = 1.0, 1.0
for _ in range(10):
    gs = gs * d_sig(5.0)
    gr = gr * d_relu(5.0)
print('sigmoide, 10 capas:', gs)
print('relu, 10 capas    :', gr)
sigmoide, 10 capas: 1.6863477168817078e-22
relu, 10 capas    : 1.0

Uno pelado después de diez capas. Ese es el motivo entero de que la ReLU se comiera el mundo, y ahora ya sabes que no fue una corazonada: fue una multiplicación. Ojo con la otra cara, que la ReLU apaga del todo la mitad negativa, y ese es otro problema con su propio nombre.

Practica este capítulo 📓

Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.

¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.

¿Le sirve a alguien que conoces?

Pásale el libro. Es gratis, está entero y no pide registro 🐣

Instagram y TikTok no dejan compartir enlaces desde la web: esos dos copian la URL para que la pegues en tu historia.

¿Tienes alguna duda o consulta?