Deep Learning
Entender qué ocurre realmente dentro de una red.
Deep Learning y redes neuronales
La expresión «red neuronal» puede resultar intimidante, y el vocabulario biológico no ayuda: sugiere una maquinaria misteriosa, vagamente cerebral, fuera del alcance de quien no tenga un doctorado en matemáticas. La realidad es más modesta y mucho más interesante. Una red neuronal es un apilamiento de multiplicaciones, sumas y una función sencilla aplicada al resultado. Nada más.
Lo que merece la pena entender no es, pues, la fórmula de una neurona —cabe en una línea—, sino dos cosas menos evidentes. ¿Por qué hay que apilar varias capas, cuando una sola bastaría si el mundo fuera simple? Y, ¿cómo puede una máquina encontrar por sí sola millones de parámetros sin que nadie le indique la dirección?
La respuesta a la segunda pregunta se llama retropropagación, y suele presentarse o bien como una obviedad, o bien como un muro de ecuaciones. Vamos a tomar una tercera vía: escribirla entera en unas treinta líneas de NumPy, sobre un problema que podremos verificar a simple vista, y observar cómo el error disminuye paso a paso.
Al final de esta lección habrá construido una red que aprende, sin biblioteca de aprendizaje profundo, y sabrá exactamente qué hace cada línea.
Una neurona: una suma ponderada y una decisión
Una neurona artificial recibe varios números de entrada, multiplica cada uno por un peso, suma el conjunto, añade un sesgo (bias) y hace pasar el resultado por una función. Eso es todo.
import numpy as np
def neurone(entrees, poids, biais):
somme = float(np.dot(entrees, poids) + biais)
return 1 if somme > 0 else 0 # activación: simple umbral
# Una neurona que calcula el AND lógico.
poids = np.array([1.0, 1.0])
biais = -1.5
for x in ([0, 0], [0, 1], [1, 0], [1, 1]):
print(x, neurone(np.array(x, dtype=float), poids, biais))
# [0, 0] 0
# [0, 1] 0
# [1, 0] 0
# [1, 1] 1
La aritmética se comprueba mentalmente: con dos entradas a 1, la suma vale 1 + 1 - 1,5 = 0,5, positiva, así que la salida es 1. Con una sola entrada a 1, la suma vale 1 - 1,5 = -0,5, negativa, luego 0. El sesgo desempeña exactamente el papel de un umbral de activación: hay que superar 1,5 para activar la neurona.
Este dispositivo es el perceptrón, propuesto por Frank Rosenblatt en 1957. Aquí los pesos se han fijado a mano; el aprendizaje consistirá en que sea la máquina quien los encuentre.
Por qué apilar capas
Una sola neurona traza una frontera recta en el espacio de entradas. Mientras las clases sean separables mediante una recta, basta. El problema es que un caso tan elemental como el O exclusivo (XOR) no lo es.
x2
│
1 │ ●(0,1) ○(1,1) ● = clase 1
│ ○ = clase 0
│
0 │ ○(0,0) ●(1,0)
└──────────────────────────── x1
0 1
Ninguna recta puede dejar los dos ● de un lado
y los dos ○ del otro.
No es una intuición, se puede medir:
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.neural_network import MLPClassifier
X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
y = np.array([0, 1, 1, 0]) # O exclusivo (XOR)
lineaire = LogisticRegression().fit(X, y)
print(lineaire.score(X, y), lineaire.predict(X)) # 0.5 [0 0 0 0]
reseau = MLPClassifier(hidden_layer_sizes=(4,), activation="tanh",
max_iter=5000, random_state=1).fit(X, y)
print(reseau.score(X, y), reseau.predict(X)) # 1.0 [0 1 1 0]
El modelo lineal obtiene 0,5 —la puntuación del azar en un problema equilibrado— y predice la misma clase para los cuatro puntos: no ha podido hacer nada. La red con una capa oculta de cuatro neuronas obtiene 1,0.
El mecanismo es el siguiente: la capa oculta transforma el espacio de entrada. En el nuevo espacio que produce, los cuatro puntos pasan a ser separables mediante una recta, y a la capa de salida solo le queda trazar esa recta. Apilar capas es encadenar cambios de representación hasta que el problema se vuelve sencillo.
Esta red solo tiene 17 parámetros: 2 × 4 pesos de entrada, 4 sesgos ocultos, 4 × 1 pesos de salida, 1 sesgo de salida. Una red moderna aplica el mismo principio con varios órdenes de magnitud más.
Las funciones de activación
El umbral del perceptrón tiene un defecto fatal para el aprendizaje: su pendiente es nula en todas partes, salvo en un punto donde es infinita. Es imposible extraer de ahí una dirección de corrección. Por eso se sustituye por funciones suaves.
import numpy as np
z = np.array([-2.0, -0.5, 0.0, 0.5, 2.0])
print(np.maximum(0, z)) # [0. 0. 0. 0.5 2. ] ReLU
print((1 / (1 + np.exp(-z))).round(3)) # [0.119 0.378 0.5 0.622 0.881] sigmoide
print(np.tanh(z).round(3)) # [-0.964 -0.462 0. 0.462 0.964] tanh
| Función | Salida | Uso habitual | Punto de atención |
|---|---|---|---|
ReLU max(0, z) | 0 o z | capas ocultas, por defecto | una neurona bloqueada en 0 ya no se corrige |
| Sigmoide | entre 0 y 1 | salida de una clasificación binaria | pendiente casi nula en los extremos |
| tanh | entre −1 y 1 | capas ocultas, redes pequeñas | misma saturación que la sigmoide |
El papel de estas funciones se entiende a menudo mal. No están ahí para «normalizar»: están para introducir no linealidad. Sin ellas, apilar diez capas de multiplicaciones y sumas produciría… una multiplicación y una suma. La composición de funciones lineales es lineal, de modo que una red sin activación, sea cual sea su profundidad, seguiría igual de impotente ante el XOR que la regresión logística anterior.
El descenso de gradiente: corregir en la dirección correcta
Queda la cuestión central: ¿cómo encontrar los pesos? La idea es medir el error, calcular en qué sentido debe moverse cada parámetro para reducirlo y dar un pequeño paso en ese sentido. Y volver a empezar.
Retomemos los alquileres de la lección anterior, pero sin LinearRegression: esta vez escribimos nosotros el aprendizaje.
import numpy as np
x = np.array([30.0, 45.0, 60.0, 75.0, 90.0])
y = np.array([620.0, 780.0, 1010.0, 1150.0, 1400.0])
# Escalado de las entradas: indispensable, veremos por qué justo después.
xn = (x - x.mean()) / x.std()
a, b = 0.0, 0.0
pas = 0.1
for etape in range(1, 201):
prediction = a * xn + b
erreur = prediction - y
perte = (erreur ** 2).mean() # error cuadrático medio
# Derivadas de la pérdida respecto a a y b.
a -= pas * 2 * (erreur * xn).mean()
b -= pas * 2 * erreur.mean()
if etape in (1, 10, 50, 200):
print(etape, round(perte, 1), round(a, 2), round(b, 2))
# 1 1059080.0 54.59 198.4
# 10 19587.4 243.64 885.48
# 50 518.0 272.94 991.99
# 200 518.0 272.94 992.0
La pérdida pasa de más de un millón a 518 y se estabiliza ahí. Dos comprobaciones muestran que el resultado no es aproximado:
- El sesgo converge hacia 992,0, que es exactamente la media de los cinco alquileres:
(620 + 780 + 1010 + 1150 + 1400) / 5 = 992. - La pendiente converge hacia 272,94 en el espacio escalado. Al devolverla a metros cuadrados, se divide por la desviación típica de las superficies, es decir, 21,213:
272,94 / 21,213 ≈ 12,86€ por m². Es el coeficiente queLinearRegressionhabía encontrado en la lección anterior, obtenido aquí mediante tanteo guiado.
El escalado no es cosmético. Con las superficies sin procesar y el mismo paso de 0,1, el descenso diverge: la pérdida pasa de 1,06 × 10⁶ a 6,9 × 10¹¹ ya en la segunda iteración, y después explota. El gradiente es proporcional a la magnitud de las entradas; unas entradas en torno a 60 producen pasos gigantescos que sobrepasan el mínimo en lugar de acercarse a él. Es la primera causa de fracaso de un aprendizaje que «no converge».
pérdida
│╲
│ ╲ paso demasiado pequeño: correcto pero lento
│ ╲___
│ ╲___
│ ╲________
└────────────────────► iteraciones
pérdida
│ ╱╲ ╱╲
│ ╱ ╲ ╱ ╲ paso demasiado grande: oscila o explota
│ ╱ ╲╱ ╲
└────────────────────► iteraciones
La retropropagación, sin formalismo
En un modelo con dos parámetros, las derivadas se escriben a mano. En una red, un peso de la primera capa influye en la salida a través de todas las capas siguientes. La retropropagación es la manera de organizar ese cálculo: se propaga el error de la salida hacia la entrada, capa por capa, reutilizando en cada paso lo que ya se ha calculado.
Aquí tiene una red completa —dos entradas, cuatro neuronas ocultas, una salida— escrita sin ninguna biblioteca de aprendizaje profundo.
import numpy as np
rng = np.random.default_rng(1)
X = np.array([[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]])
y = np.array([[0.0], [1.0], [1.0], [0.0]]) # O exclusivo (XOR)
# Inicialización aleatoria: dos pesos idénticos recibirían siempre
# la misma corrección y seguirían siendo idénticos para siempre.
W1 = rng.normal(0, 1, (2, 4))
b1 = np.zeros((1, 4))
W2 = rng.normal(0, 1, (4, 1))
b2 = np.zeros((1, 1))
def sigmoide(z):
return 1 / (1 + np.exp(-z))
for etape in range(1, 5001):
# --- Pasada hacia delante: de la entrada a la predicción ---
z1 = X @ W1 + b1
a1 = np.tanh(z1)
z2 = a1 @ W2 + b2
a2 = sigmoide(z2)
perte = float(np.mean((a2 - y) ** 2))
# --- Pasada hacia atrás: del error a los pesos ---
# d2: sensibilidad de la pérdida respecto a z2. El factor a2*(1-a2) es la
# derivada de la sigmoide; traduce «cuánto se mueve la salida
# si z2 se mueve un poco».
d2 = 2 * (a2 - y) / len(X) * a2 * (1 - a2)
dW2 = a1.T @ d2
db2 = d2.sum(axis=0, keepdims=True)
# Subimos una capa: el error se redistribuye a las neuronas
# ocultas proporcionalmente a su peso W2, y luego se multiplica por
# la derivada de tanh, que vale 1 - tanh².
d1 = (d2 @ W2.T) * (1 - a1 ** 2)
dW1 = X.T @ d1
db1 = d1.sum(axis=0, keepdims=True)
# --- Actualización ---
for param, gradient in ((W1, dW1), (b1, db1), (W2, dW2), (b2, db2)):
param -= 0.5 * gradient
if etape in (1, 100, 1000, 5000):
print(etape, round(perte, 4))
# 1 0.24
# 100 0.1182
# 1000 0.0018
# 5000 0.0003
print(a2.round(3).ravel()) # [0.007 0.983 0.983 0.021]
La red parte de un error de 0,24 y desciende hasta 0,0003. Las cuatro salidas finales —0,007 / 0,983 / 0,983 / 0,021— deben compararse con los objetivos 0 / 1 / 1 / 0. El XOR queda resuelto con un programa cuya lógica de aprendizaje completa cabe en ocho líneas.
Merece la pena quedarse con tres observaciones:
- La pasada hacia atrás es el espejo de la pasada hacia delante.
X @ W1en la ida se convierte enX.T @ d1en la vuelta. No es una coincidencia: es la regla de la cadena aplicada a un producto matricial. - Ninguna capa sabe lo que detecta. La capa oculta ha construido una representación útil de los cuatro puntos, pero nadie le ha dicho cuál. Es lo que se conoce como aprendizaje de representaciones, y es la principal aportación del aprendizaje profundo.
- Nada de esto depende del número de capas. Con diez capas, se repite el mismo bloque diez veces. Esa uniformidad es la que hizo posibles las redes profundas, y bibliotecas como PyTorch o TensorFlow no hacen otra cosa que derivar automáticamente estas pasadas hacia atrás.
Lo que hizo posible el aprendizaje profundo
La retropropagación se popularizó en 1986 gracias a Rumelhart, Hinton y Williams. El auge del aprendizaje profundo data de la década de 2010. Este desfase de veinticinco años no se debe a un descubrimiento teórico que faltara, sino a tres condiciones materiales que no estaban reunidas.
| Condición | Situación en los años 90 | Situación en los años 2010 |
|---|---|---|
| Datos anotados | unos pocos miles de ejemplos | ImageNet: alrededor de 1,2 millones de imágenes de entrenamiento, 1000 categorías |
| Cálculo | procesadores de propósito general | tarjetas gráficas, masivamente paralelas |
| Recetas de entrenamiento | saturación de las sigmoides, inicializaciones azarosas | ReLU, inicializaciones adecuadas, normalizaciones, regularización |
El multiplicador es brutal. Cada iteración de nuestro bucle procesa 4 ejemplos y actualiza 17 parámetros. El entrenamiento de una red de visión habitual procesa millones de imágenes y actualiza decenas de millones de parámetros, decenas de veces. La teoría es la misma; lo que cambia de naturaleza es la escala, y con ella el coste, cuyas consecuencias evaluaremos en la última lección.
Conclusiones
- Una neurona es una suma ponderada seguida de una función. El sesgo hace las veces de umbral, y la aritmética se comprueba a mano con el AND lógico.
- Una sola capa solo traza una frontera recta: fracasa en el XOR con una puntuación de 0,5. Apilar capas sirve para transformar el espacio hasta que el problema se vuelve linealmente separable.
- Las funciones de activación existen para introducir no linealidad. Sin ellas, una red de diez capas equivale a una sola capa, sea cual sea su tamaño.
- El descenso de gradiente mide el error, calcula la dirección de corrección y da un pequeño paso. El paso y la escala de las entradas son críticos: los mismos datos sin escalar hacen divergir el mismo bucle.
- La retropropagación no es más que la regla de la cadena organizada de la salida hacia la entrada. Cabe en pocas líneas, es idéntica sea cual sea el número de capas, y eso es lo que la hace industrializable.
Comentarios
Los comentarios funcionan con GitHub Discussions
Inicia sesión con GitHub para participar en la discusión