Ir al contenido
wannacode

Retropropagación

En resumen: La retropropagación calcula con la regla de la cadena la pendiente del error de todos los pesos a la vez, yendo desde la salida hacia atrás, hacia la entrada.

Problema

Nueve números. ¿Cuál mueves?

La red de la lección sobre capas resolvió la regla de los países distintos porque sus pesos estaban elegidos a mano. Una red real empieza con pesos que no significan nada, y tiene que encontrarlos ella misma.

Aquí está la misma red con pesos iniciales. Un cliente en el extranjero paga con una tarjeta extranjera desde una IP extranjera, caso (1, 1). Está bien, la etiqueta es 0. La red dice 0,4489.

La red tiene nueve números: cuatro pesos y dos sesgos en la capa oculta, dos pesos y un sesgo en la salida. ¿Cuál mueves, en qué dirección y cuánto? Si pruebas a ciegas, cada intento es una pasada hacia adelante. Con una red de un millón de pesos (un ejemplo de escala) son millones de intentos para un solo paso.

La idea

Empuja el peso y mira el error

El error de un pago es (p − y)², el cuadrado de la diferencia. Toma un peso, v[0], y muévelo un poco hacia arriba y un poco hacia abajo, 0,0001. Cada vez calculas el error de nuevo.

La red como una clase con los nueve números, las funciones de la lección anterior y el error:

class Net:
def __init__(self, w, b, v, c):
self.w = w # la capa oculta: pesos y sesgos
self.b = b
self.v = v # la salida: pesos y sesgo
self.c = c
@staticmethod
def zeros():
return Net([[0.0, 0.0], [0.0, 0.0]], [0.0, 0.0], [0.0, 0.0], 0.0)
def hidden(net, x):
return [neuron(x, net.w[0], net.b[0]), neuron(x, net.w[1], net.b[1])]
def forward(net, x):
return neuron(hidden(net, x), net.v, net.c)
def loss(net, x, y):
d = forward(net, x) - y
return d * d

Los pesos iniciales y el pago:

# los pesos iniciales: la red todavía no ha aprendido
net = Net([[1.5, 1.0], [2.5, 2.0]], [-0.5, -3.0], [1.5, -1.5], -0.3)
x = [1, 1] # una tarjeta extranjera desde una IP extranjera: un cliente en el extranjero
y = 0
print(f"p = {forward(net, x):.4f}, error = {loss(net, x, y):.6f}")
old = net.v[0]
net.v[0] = old + 0.0001
up = loss(net, x, y)
net.v[0] = old - 0.0001
down = loss(net, x, y)
net.v[0] = old
print(f"v[0] + 0.0001: error = {up:.6f}")
print(f"v[0] - 0.0001: error = {down:.6f}")
print(f"pendiente = {(up - down) / 0.0002:.4f}")
Salida
p = 0.4489, error = 0.201500
v[0] + 0.0001: error = 0.201520
v[0] - 0.0001: error = 0.201481
pendiente = 0.1956

Hacia arriba el error crece; hacia abajo baja. La diferencia dividida por la distancia entre los dos puntos (0,0002) es la pendiente: 0,1956. Positiva significa «un v[0] mayor da un error mayor». Así que v[0] tiene que bajar. Es la pendiente del descenso del gradiente, solo que para uno de los nueve pesos.

Funciona, pero necesita dos pasadas hacia adelante por cada peso. Necesitamos una forma de obtener la pendiente sin tocar el peso.

La idea

La regla de la cadena con números

v[0] no toca el error directamente. Toca la suma z de la salida, z toca p, y p toca el error. Una cadena de tres eslabones. Si sabes cuánto se mueve cada eslabón cuando mueves el anterior, la pendiente es su producto:

  1. El error (p − y)² se mueve 2·(p − y) cuando mueves p.
  2. p = σ(z) se mueve p·(1 − p) cuando mueves z. Esa es la pendiente de la sigmoide.
  3. z = v[0]·h[0] + v[1]·h[1] + c se mueve h[0] cuando mueves v[0].
h = hidden(net, x)
p = neuron(h, net.v, net.c)
a = 2 * (p - y) # cómo cambia el error con p
s = p * (1 - p) # cómo cambia p con su suma z
dz = h[0] # cómo cambia z con v[0]
print(f"2(p - y) = {a:.4f}")
print(f"p(1 - p) = {s:.4f}")
print(f"h[0] = {dz:.4f}")
print(f"pendiente de v[0] = {a * s * dz:.4f}")
Salida
2(p - y) = 0.8978
p(1 - p) = 0.2474
h[0] = 0.8808
pendiente de v[0] = 0.1956

0,8978 × 0,2474 × 0,8808 = 0,1956. El mismo número que con el empujón, pero sin un solo cálculo nuevo de la red. Esta es la regla de la cadena: la pendiente a lo largo de una cadena es el producto de las pendientes de sus eslabones.

La idea

El error viaja hacia atrás

Los dos primeros eslabones son comunes a todo lo que hay antes de la salida. Su producto es la parte de la salida, dOut = 2·(p − y)·p·(1 − p) = 0,2221. A partir de ahí:

  • Los pesos de la salida: la pendiente de v[j] es dOut × h[j].
  • El sesgo de la salida: la pendiente de c es el propio dOut, porque z se mueve 1 cuando mueves c.
  • Las neuronas ocultas: h[j] entra en z con el peso v[j]. Su parte es dH = dOut × v[j] × h[j]·(1 − h[j]): la responsabilidad de la salida en el error, por la fuerza de la arista, por la pendiente de su propia sigmoide.
  • Los pesos ocultos: la pendiente de w[j][i] es dH × x[i], y la de b[j] es dH.

La responsabilidad en el error viaja hacia atrás por las mismas aristas por las que la señal fue hacia adelante, y cada peso recibe su parte. Esta es la retropropagación del error (backprop, para abreviar): una pasada hacia adelante, para conocer h y p, y una hacia atrás, para todas las pendientes a la vez.

def backward(net, x, y):
h = hidden(net, x)
out = neuron(h, net.v, net.c)
g = Net.zeros()
# la parte de la salida
d_out = 2 * (out - y) * out * (1 - out)
g.c = d_out
for j in range(2):
g.v[j] = d_out * h[j]
# la parte de la neurona oculta j
d_h = d_out * net.v[j] * h[j] * (1 - h[j])
g.b[j] = d_h
for i in range(2):
g.w[j][i] = d_h * x[i]
return g
Net.zeros() es una red de ceros con la misma forma: hay una pendiente por cada peso, así que las pendientes se guardan en la misma clase.

Míralo

Empujón contra retropropagación, para los nueve pesos

Para recorrer los nueve números con un ciclo, get y set los numeran de 0 a 8. nudge es el empujón de arriba, para el peso número k:

NAMES = ["w[0][0]", "w[0][1]", "w[1][0]", "w[1][1]", "b[0]", "b[1]", "v[0]", "v[1]", "c"]
# k: w[0][0], w[0][1], w[1][0], w[1][1], b[0], b[1], v[0], v[1], c
def get(n, k):
if k < 4:
return n.w[k // 2][k % 2]
if k < 6:
return n.b[k - 4]
if k < 8:
return n.v[k - 6]
return n.c
def set_param(n, k, value):
if k < 4:
n.w[k // 2][k % 2] = value
elif k < 6:
n.b[k - 4] = value
elif k < 8:
n.v[k - 6] = value
else:
n.c = value
def nudge(net, k, x, y):
old = get(net, k)
set_param(net, k, old + 0.0001)
up = loss(net, x, y)
set_param(net, k, old - 0.0001)
down = loss(net, x, y)
set_param(net, k, old)
return (up - down) / 0.0002
g = backward(net, x, y)
print(" empujón backprop")
for k in range(9):
print(f"{NAMES[k]:<8}{nudge(net, k, x, y):10.4f}{get(g, k):10.4f}")
Salida
empujón backprop
w[0][0] 0.0350 0.0350
w[0][1] 0.0350 0.0350
w[1][0] -0.0497 -0.0497
w[1][1] -0.0497 -0.0497
b[0] 0.0350 0.0350
b[1] -0.0497 -0.0497
v[0] 0.1956 0.1956
v[1] 0.1816 0.1816
c 0.2221 0.2221

Los nueve coinciden hasta el cuarto decimal. Las entradas son (1, 1), así que los dos pesos y el sesgo de una neurona oculta tienen la misma pendiente: cada uno mueve el z de esa neurona en 1. Y las dos neuronas ocultas tienen signos opuestos, porque v[0] = 1,5 y v[1] = −1,5 entran en la salida en direcciones opuestas.

Míralo

La señal hacia adelante, el error hacia atrás

def backward(net, x, y):
h = hidden(net, x)
out = neuron(h, net.v, net.c)
g = Net.zeros()
# la parte de la salida
d_out = 2 * (out - y) * out * (1 - out)
g.c = d_out
for j in range(2):
g.v[j] = d_out * h[j]
# la parte de la neurona oculta j
d_h = d_out * net.v[j] * h[j] * (1 - h[j])
g.b[j] = d_h
for i in range(2):
g.w[j][i] = d_h * x[i]
return g
Paso a paso, en palabras
  1. Los pesos iniciales y el caso x = (1, 1): los dos del extranjero, así que está bien, y = 0.
  2. Primero hacia adelante: h1 = 0,8808, h2 = 0,8176, p = 0,4489.
  3. El error: (0,4489 − 0)² = 0,2015. La red debería decir menos.
  4. Hacia atrás desde la salida: la parte es 2(p − y) · p(1 − p) = 0,8978 × 0,2474 = 0,2221, y también la pendiente de c.
  5. La pendiente de v[j] es la parte por h[j]: 0,2221 × 0,8808 = 0,1956 y 0,2221 × 0,8176 = 0,1816.
  6. Hacia atrás por v y a través de σ: h1 recibe 0,2221 × 1,5 × 0,1050 = 0,0350, h2 recibe −0,0497. También son las pendientes de b.
  7. La pendiente de w[j][i] es la parte de h[j] por x[i]. Las dos entradas son 1: 0,0350 y −0,0497.
  8. Nueve pendientes de una sola pasada hacia atrás, las mismas que con el empujón. Un peso con pendiente positiva baja.

Los valores viajan hacia adelante, las pendientes viajan hacia atrás. La pendiente de cada arista es la parte de la neurona a la que lleva la arista, por el valor que pasó por ella.

Pruébalo

Predice la salida

La parte de la salida es 0,4, el peso hacia la salida es 1,5, y la neurona oculta dio 0,5. ¿Qué imprimirá: la pendiente de ese peso y la parte de la neurona oculta?

d_out = 0.4 # la parte de la salida
v = 1.5
h = 0.5
d_h = d_out * v * h * (1 - h)
print(f"{d_out * h:.2f} {d_h:.2f}")
Respuesta

Pruébalo

Encuentra el error

Un colega escribe backward por su cuenta. La red aprende, pero despacio y de forma rara. La comprobación con el empujón muestra que las pendientes de la salida coinciden, mientras que las ocultas son de 7 a 10 veces demasiado grandes. ¿En qué línea está el error?

Elige la línea con el error.

Míralo

Un paso de aprendizaje

Las pendientes dan la dirección. El paso es el del descenso del gradiente: cada número se mueve en contra de su pendiente, por la tasa de aprendizaje. La pendiente es la media de los cuatro pagos, porque la red tiene que acertar en todos:

def mean_loss(net, cases, labels):
s = 0.0
for x, y in zip(cases, labels):
s += loss(net, x, y)
return s / len(cases)
cases = [[0, 0], [1, 0], [0, 1], [1, 1]]
labels = [0, 1, 1, 0]
# la pendiente media de los cuatro pagos
mean = Net.zeros()
for x, y in zip(cases, labels):
g = backward(net, x, y)
for k in range(9):
set_param(mean, k, get(mean, k) + get(g, k) / 4)
rate = 2.0 # la tasa de aprendizaje
before = mean_loss(net, cases, labels)
for k in range(9):
new = get(net, k) - rate * get(mean, k)
print(f"{NAMES[k]:<8}{get(net, k):8.4f} → {new:.4f}")
set_param(net, k, new)
after = mean_loss(net, cases, labels)
print(f"error medio: {before:.4f} → {after:.4f}")
Salida
w[0][0] 1.5000 → 1.5147
w[0][1] 1.0000 → 1.0210
w[1][0] 2.5000 → 2.4863
w[1][1] 2.0000 → 1.9926
b[0] -0.5000 → -0.4947
b[1] -3.0000 → -3.0367
v[0] 1.5000 → 1.4987
v[1] -1.5000 → -1.5266
c -0.3000 → -0.3285
error medio: 0.2236 → 0.2218

El error medio baja de 0,2236 a 0,2218. Poco, pero en la dirección correcta. Entrenar es este mismo paso repetido miles de veces: pasada hacia adelante, pasada hacia atrás, paso. El ciclo completo y cómo baja el error está en la próxima lección.

Pruébalo

Completa

¿Cuál es el paso para cada número de la red?

for k in range(9):
    set_param(net, k, get(net, k)  rate * get(g, k))

La idea

Cuándo perjudica la retropropagación

  • La pendiente se desvanece. La pendiente de la sigmoide p·(1 − p) es como máximo 0,25. A través de diez capas con sigmoide los factores dan como máximo 0,25¹⁰, alrededor de 0,000001. Las primeras capas casi no aprenden. Por eso las capas ocultas de hoy usan ReLU: su pendiente es 1 para todo z positivo.
  • La pendiente explota. Si los pesos son grandes, los productos crecen a través de las capas, y el paso lanza los pesos lejos. Una tasa de aprendizaje menor y la normalización ayudan.
  • Un error en las pendientes no hace fallar el programa, hace aprender mal. La red se mueve igual, solo que más despacio o hacia algo peor. Por eso tu propia retropropagación se compara primero con el empujón, como en el programa de los nueve pesos.
  • El empujón es solo una comprobación. Es exacto, pero necesita dos pasadas por cada peso. Para aprender es demasiado lento.
  • La pendiente solo dice «dónde está abajo aquí». El paso puede ser demasiado grande y pasarse, y uno pequeño es lento. Esa es la tasa de aprendizaje del descenso del gradiente.
Más precisamente, para curiosos

La fórmula. Con L = (p − y)² y p = σ(z):

∂L/∂v₀ = ∂L/∂p · ∂p/∂z · ∂z/∂v₀ = 2(p − y) · p(1 − p) · h₀

∂ se lee «derivada parcial»: cuánto se mueve el lado izquierdo cuando mueves solo el derecho y todo lo demás se queda quieto. Para la capa oculta la cadena es dos eslabones más larga: ∂z/∂h₀ = v₀ y ∂h₀/∂z₀ = h₀(1 − h₀), luego ∂z₀/∂w₀ᵢ = xᵢ.

Por qué la pendiente de la sigmoide es p(1 − p). Si σ(z) = 1 / (1 + e^(−z)), su derivada es σ(z)·(1 − σ(z)). Es cómodo: el valor p ya está calculado en la pasada hacia adelante, y la pendiente sale sin un exp nuevo.

El empujón, con más precisión. El programa mueve el peso hacia arriba y hacia abajo (una diferencia central). El error de esta estimación es del orden de h², con h = 0,0001 alrededor de 0,00000001. Si mueves solo hacia arriba, el error es del orden de h y los números coinciden en menos decimales.

En las bibliotecas reales. Nadie escribe backward a mano para cada red. PyTorch y similares registran cada operación de la pasada hacia adelante y luego recorren ese registro hacia atrás por sí mismos (diferenciación automática): en PyTorch es loss.backward(). La regla de dentro es la misma regla de la cadena.

Entrenar redes grandes (con tarjetas gráficas y muchas máquinas) y ponerlas en servicio es infraestructura. Eso está en el curso ZeroToOps.

Ejercicio

Retropropagación para una neurona

Resuélvelo en tu editor.

El pago de la lección de la neurona: 230 €, una cuenta nueva, una tarjeta extranjera, pesos 0,9, 1,4 y 1,8 y un sesgo de −4. El pago resultó ser fraude: y = 1. La neurona dijo 0,7807. Calcula con la regla de la cadena la pendiente del error (p − y)² para cada peso y para el sesgo: dz = 2·(p − y)·p·(1 − p), la pendiente de w[i] es dz × x[i], y la de b es dz. Comprueba los pesos con el empujón (± 0,0001).

Salida
p = 0.7807, error = 0.0481
w[0]: backprop -0.1727, empujón -0.1727
w[1]: backprop -0.0751, empujón -0.0751
w[2]: backprop -0.0751, empujón -0.0751
b: backprop -0.0751

Las pendientes son negativas: pesos mayores dan una p mayor, más cerca de 1, y el error baja. El peso del monto recibe la mayor parte del error, porque el monto es la entrada más grande (2,3).

Más tareas

  • Un paso. Da un paso con una tasa de aprendizaje de 0,5 e imprime la nueva p. Debería estar más cerca de 1.
  • Otro pago. Haz lo mismo con el pago de 40 €, que está bien (y = 0). ¿Qué pendientes se vuelven cero, y por qué?

Test

Ponte a prueba

1. ¿Qué te dice la pendiente del error sobre un peso?
2. ¿Por qué retropropagación y no empujar cada peso?
3. La parte de la neurona oculta j es dOut × v[j] × h[j] × (1 − h[j]). ¿Por qué está ahí v[j]?
4. La entrada x[i] es 0. ¿Cuál es la pendiente del peso w[j][i]?
5. La retropropagación está escrita con un error, y la red igual aprende un poco. ¿Cómo lo detectas?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·