0%
Deep Learning y redes neuronales

Deep Learning

Entender qué ocurre realmente dentro de una red.

10-15 min

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ónSalidaUso habitualPunto de atención
ReLU max(0, z)0 o zcapas ocultas, por defectouna neurona bloqueada en 0 ya no se corrige
Sigmoideentre 0 y 1salida de una clasificación binariapendiente casi nula en los extremos
tanhentre −1 y 1capas ocultas, redes pequeñasmisma 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 que LinearRegression habí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 @ W1 en la ida se convierte en X.T @ d1 en 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ónSituación en los años 90Situación en los años 2010
Datos anotadosunos pocos miles de ejemplosImageNet: alrededor de 1,2 millones de imágenes de entrenamiento, 1000 categorías
Cálculoprocesadores de propósito generaltarjetas gráficas, masivamente paralelas
Recetas de entrenamientosaturación de las sigmoides, inicializaciones azarosasReLU, 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

¡Enlace copiado!