7 - Las redes neuronales como aproximadores de funciones¶
A Marta las metáforas biológicas le dejaban con la sensación de que faltaba algo, y en sus notas (sección 7.6) describe las redes neuronales como lo que son matemáticamente: aproximadores de funciones que combinan piezas sencillas, las funciones base. El teorema de aproximación universal dice que una red con una sola capa oculta suficientemente ancha puede aproximar cualquier función continua en un intervalo acotado tanto como queramos. En este cuaderno vemos por qué, con neuronas ReLU:
- Importar las bibliotecas.
- La pieza básica: una ReLU como una regla con bisagra.
- Sumar bisagras: aproximaciones lineales a trozos.
- Dejar que una red elija dónde poner las bisagras.
- Por qué ayuda la profundidad.
Paso 1: Importar las bibliotecas¶
import numpy as np
import matplotlib.pyplot as plt
np.set_printoptions(precision=3, suppress=True)
def relu(z):
return np.maximum(0, z)
Paso 2: Una ReLU como una regla con bisagra¶
Una ReLU desplazada y escalada, $c \cdot \text{ReLU}(x - b)$, es lo que Marta llama una regla con bisagra: está plana sobre el eje hasta el punto $x = b$ (el pliegue, o kink), y a partir de ahí es una recta de pendiente $c$.
x = np.linspace(-3, 3, 400)
plt.figure(figsize=(8, 4))
for c, b in [(1.0, -2.0), (0.5, 0.0), (-1.5, 1.0), (2.0, 2.0)]:
plt.plot(x, c * relu(x - b), label=f'{c} * ReLU(x - ({b}))')
plt.axhline(0, color='gray', linewidth=0.5)
plt.title('ReLU desplazadas y escaladas: cada una empieza a doblarse en su propio pliegue')
plt.legend()
plt.grid(True)
plt.show()
Paso 3: Sumar bisagras¶
Sumar varias bisagras, $\hat{f}(x) = \sum_i c_i \,\text{ReLU}(x - b_i)$ (más una recta), da una curva continua y lineal a trozos: entre dos pliegues consecutivos la pendiente es constante, y en cada pliegue $b_i$ cambia exactamente en $c_i$. Con más pliegues tenemos más segmentos, más cortos, y podemos dibujar una curva suave tan de cerca como queramos.
Lo probamos con una función objetivo con curvas, $f(x) = \sin(2x) + 0{,}3x^2$. Colocamos los pliegues equiespaciados y calculamos los mejores coeficientes $c_i$ por mínimos cuadrados (el mismo método que la regresión lineal del capítulo 2):
target = np.sin(2 * x) + 0.3 * x ** 2
def fit_hinges(n_kinks):
kinks = np.linspace(-3, 3, n_kinks + 2)[1:-1]
A = np.c_[np.ones_like(x), x, relu(x[:, None] - kinks[None, :])] # una recta + una columna por bisagra
coef, *_ = np.linalg.lstsq(A, target, rcond=None)
return kinks, coef, A @ coef
fig, axes = plt.subplots(1, 4, figsize=(18, 4))
for ax, n in zip(axes, [3, 6, 12, 24]):
kinks, coef, approx = fit_hinges(n)
rmse = np.sqrt(np.mean((approx - target) ** 2))
print(f"{n:>2} bisagras: raíz del error cuadrático medio {rmse:.4f}, mayor error {np.max(np.abs(approx - target)):.4f}")
ax.plot(x, target, 'k', linewidth=2, label='objetivo f(x)')
ax.plot(x, approx, 'r--', label='suma de bisagras')
ax.plot(kinks, np.interp(kinks, x, approx), 'ro', markersize=4)
ax.set_title(f'{n} bisagras: error {rmse:.3f}')
ax.legend(fontsize=8)
plt.show()
kinks, coef, approx = fit_hinges(6)
slope = np.diff(approx) / np.diff(x)
jumps = [slope[np.searchsorted(x, b) + 2] - slope[np.searchsorted(x, b) - 2] for b in kinks]
print(f"\nCon 6 bisagras, cambio de pendiente en cada pliegue: {np.round(jumps, 3)}")
print(f"Coeficientes c_i de las bisagras: {np.round(coef[2:], 3)}")
3 bisagras: raíz del error cuadrático medio 0.5228, mayor error 1.1246 6 bisagras: raíz del error cuadrático medio 0.1022, mayor error 0.2371 12 bisagras: raíz del error cuadrático medio 0.0254, mayor error 0.0750 24 bisagras: raíz del error cuadrático medio 0.0065, mayor error 0.0210
Con 6 bisagras, cambio de pendiente en cada pliegue: [-2.3 2.259 3.069 -2.04 -1.231 3.333] Coeficientes c_i de las bisagras: [-2.3 2.259 3.069 -2.04 -1.231 3.333]
Cada vez que duplicamos el número de bisagras, el error se divide por unas cuatro: de 0,52 con 3 bisagras a 0,0065 con 24, donde la aproximación ya es difícil de distinguir de la curva. Y la pendiente de la aproximación cambia en cada pliegue exactamente en el coeficiente $c_i$ de su bisagra, como dice la fórmula de Marta.
Paso 4: Dejar que una red elija las bisagras¶
Una red neuronal con una capa oculta de neuronas ReLU calcula exactamente este tipo de suma: cada neurona oculta $\text{ReLU}(w_i x + b_i)$ es una bisagra con su pliegue en $x = -b_i / w_i$, y la capa de salida las suma con pesos $a_i$. La diferencia es que la red también aprende dónde poner los pliegues. Entrenamos una red con 12 neuronas ocultas, minimizando el error cuadrático medio con el optimizador Adam (descenso del gradiente con un paso adaptativo), desde 6 comienzos aleatorios distintos:
def train_network(seed, hidden=12, learning_rate=0.01, steps=20000):
rng = np.random.default_rng(seed)
w = rng.choice([-1, 1], hidden) * rng.uniform(0.5, 1.5, hidden)
params = [w, -w * rng.uniform(-3, 3, hidden), rng.normal(0, 0.1, hidden), np.zeros(1)] # pliegues repartidos al empezar
m, v = [np.zeros_like(p) for p in params], [np.zeros_like(p) for p in params]
for t in range(1, steps + 1):
z = x[:, None] * params[0] + params[1]
h = relu(z) # capa oculta: una bisagra por neurona
error = h @ params[2] + params[3][0] - target
d_out = 2 * error / len(x)
d_hidden = d_out[:, None] * params[2] * (z > 0) # retropropagación a través de la ReLU
grads = [(d_hidden * x[:, None]).sum(axis=0), d_hidden.sum(axis=0), h.T @ d_out, np.array([d_out.sum()])]
for i, g in enumerate(grads): # actualización de Adam
m[i] = 0.9 * m[i] + 0.1 * g
v[i] = 0.999 * v[i] + 0.001 * g ** 2
params[i] -= learning_rate * (m[i] / (1 - 0.9 ** t)) / (np.sqrt(v[i] / (1 - 0.999 ** t)) + 1e-8)
return np.sqrt(np.mean(error ** 2)), params
runs = [train_network(seed) for seed in range(6)]
for seed, (rmse, _) in enumerate(runs):
print(f"Comienzo {seed}: raíz del error cuadrático medio {rmse:.4f}")
best_rmse, (w, b, a, c) = min(runs, key=lambda r: r[0])
learned_kinks = np.sort((-b / w)[(-b / w > -3) & (-b / w < 3)])
print(f"\nMejor red: error {best_rmse:.4f}, pliegues dentro de [-3, 3]: {learned_kinks}")
approx = relu(x[:, None] * w + b) @ a + c[0]
plt.figure(figsize=(9, 4))
plt.plot(x, target, 'k', linewidth=2, label='objetivo f(x)')
plt.plot(x, approx, 'r--', label='red entrenada (12 neuronas)')
for k in learned_kinks:
plt.axvline(k, color='red', alpha=0.25)
plt.title('La red entrenada coloca sus pliegues donde la curva se dobla')
plt.legend()
plt.show()
Comienzo 0: raíz del error cuadrático medio 0.0505 Comienzo 1: raíz del error cuadrático medio 0.0358 Comienzo 2: raíz del error cuadrático medio 0.0438 Comienzo 3: raíz del error cuadrático medio 0.1602 Comienzo 4: raíz del error cuadrático medio 0.0333 Comienzo 5: raíz del error cuadrático medio 0.0425 Mejor red: error 0.0333, pliegues dentro de [-3, 3]: [-2.276 -1.118 -0.826 -0.771 -0.758 -0.715 -0.417 0.567 0.995 2.028 2.495]
Cinco de los seis comienzos llegan a un error de entre 0,033 y 0,051, y uno se atasca en 0,160. La mejor red no reparte sus pliegues por igual: los concentra cerca de los puntos donde el objetivo se dobla más, alrededor de ±0,8 y ±2,4. Aun así, la mejor (0,033) no mejora nuestras 12 bisagras equiespaciadas ajustadas por mínimos cuadrados (0,025). Como advierte Marta, el teorema garantiza que existen buenos pesos, no que el entrenamiento los vaya a encontrar.
Paso 5: Por qué ayuda la profundidad¶
Marta añade que, aunque en teoría basta una capa, en la práctica las redes profundas son mucho más eficientes: cada capa transforma la salida de la anterior. Un ejemplo clásico lo hace concreto. Una capa de solo 2 neuronas ReLU puede construir una "tienda" en el intervalo $[0, 1]$, $t(x) = 2\,\text{ReLU}(x) - 4\,\text{ReLU}(x - 0{,}5)$, que sube y después baja. Apilar la misma capa $L$ veces pliega el intervalo una y otra vez. Contamos los trozos lineales del resultado:
def tent(u):
return 2 * relu(u) - 4 * relu(u - 0.5) # una capa de 2 neuronas ReLU
u = np.linspace(0, 1, 2 ** 12 + 1) # los pliegues caen justo en puntos de la malla
fig, axes = plt.subplots(1, 4, figsize=(18, 3.5))
y = u
for depth in range(1, 9):
y = tent(y)
slopes = np.round(np.diff(y) / np.diff(u), 3)
pieces = 1 + np.count_nonzero(np.diff(slopes))
print(f"{depth} capa(s) de 2 neuronas = {2 * depth:>2} neuronas: {pieces:>3} trozos lineales")
if depth in (1, 2, 3, 5):
ax = axes[[1, 2, 3, 5].index(depth)]
ax.plot(u, y)
ax.set_title(f'{depth} capa(s): {pieces} trozos')
plt.show()
1 capa(s) de 2 neuronas = 2 neuronas: 2 trozos lineales 2 capa(s) de 2 neuronas = 4 neuronas: 4 trozos lineales 3 capa(s) de 2 neuronas = 6 neuronas: 8 trozos lineales 4 capa(s) de 2 neuronas = 8 neuronas: 16 trozos lineales 5 capa(s) de 2 neuronas = 10 neuronas: 32 trozos lineales 6 capa(s) de 2 neuronas = 12 neuronas: 64 trozos lineales 7 capa(s) de 2 neuronas = 14 neuronas: 128 trozos lineales 8 capa(s) de 2 neuronas = 16 neuronas: 256 trozos lineales
Cada capa nueva de solo 2 neuronas duplica el número de trozos lineales: 16 neuronas en 8 capas producen 256 trozos. Una red con una sola capa oculta tiene como mucho un pliegue por neurona, así que necesitaría unas 255 neuronas para construir la misma forma. Esa es la idea de Marta: componer transformaciones sencillas, una detrás de otra, es mucho más eficiente que colocarlas una al lado de otra.
Conclusión¶
- Una neurona ReLU es una regla con bisagra, y una suma de ellas es una curva continua lineal a trozos que puede seguir cualquier función continua tan de cerca como queramos, si añadimos suficientes bisagras. Esa es la idea detrás del teorema de aproximación universal.
- El teorema garantiza que una buena aproximación existe, pero no que el entrenamiento la vaya a encontrar: uno de nuestros comienzos se atascó con un resultado claramente peor.
- Una red entrenada aprende a la vez dónde colocar sus pliegues y cuánto dobla la curva cada uno, y los coloca donde la función cambia más.
- La profundidad compone transformaciones: con pocas neuronas por capa, cada capa nueva puede multiplicar el número de trozos lineales. Es una de las razones por las que las redes profundas captan patrones complejos con menos parámetros que las poco profundas.
- Los modelos de lenguaje son, en el fondo, enormes aproximadores de funciones de este tipo: convierten una secuencia de tokens en las probabilidades del siguiente.