En los capítulos anteriores construimos modelos cada vez más poderosos. Pasamos de un árbol individual (alta varianza) a Random Forests (promedio de árboles) y a Gradient Boosting (corrección secuencial de errores). Estos métodos dominan los datos tabulares estructurados y siguen siendo la primera línea en la industria.
Sin embargo, hay categorías de problemas donde estos modelos tienen limitaciones estructurales:
Imágenes: ¿cómo le dices a un árbol de decisión que la posición relativa de los píxeles importa?
Texto: ¿cómo capturas que “banco” significa cosas distintas en “banco de peces” y “banco financiero”?
Series de tiempo largas: ¿cómo modelas dependencias que ocurren con cientos de pasos de distancia?
¿Qué significa “deep learning”?
Deep learning es simplemente el nombre popular para las redes neuronales con muchas capas (profundas). La profundidad le da al modelo la capacidad de aprender representaciones jerárquicas: primero bordes, luego formas, luego objetos. No es una tecnología diferente; es la misma arquitectura que estudiaremos aquí, escalada.
El Perceptrón: la neurona artificial
Todo empieza con la unidad más pequeña: el perceptrón. La idea es una analogía biológica: así como una neurona recibe señales eléctricas de otras neuronas, las procesa y decide si “dispara” o no, el perceptrón recibe números, los combina y produce una salida.
Anatomía del perceptrón
Un perceptrón recibe varias entradas \(x_1, x_2, \ldots, x_n\), le asigna un peso\(w_i\) a cada una, suma todo (más un término independiente llamado sesgo o bias\(b\)), y pasa el resultado por una función de activación:
La intuición es directa: los pesos reflejan qué tan importante es cada señal de entrada. Si el peso de \(x_1\) es alto, esa variable tiene mucha influencia en la salida. El sesgo le permite al modelo desplazarse (como el intercepto en regresión lineal). La función de activación convierte esa suma en algo útil: una probabilidad, un valor continuo, o una señal on/off.
Figura 9.1: Anatomía de un perceptrón: entradas ponderadas, suma con sesgo, y función de activación que produce la salida.
Funciones de activación
La función de activación es el ingrediente que le da poder a las redes neuronales. Sin ella, apilar capas sería inútil: la suma de transformaciones lineales sigue siendo lineal. La activación introduce no linealidad, que es lo que permite a la red aprender patrones complejos.
Las tres más comunes en la práctica:
Código
import numpy as npimport matplotlib.pyplot as pltz = np.linspace(-4, 4, 300)sigmoid =1/ (1+ np.exp(-z))relu = np.maximum(0, z)tanh_v = np.tanh(z)fig, axes = plt.subplots(1, 3, figsize=(12, 4))configs = [ (sigmoid, 'Sigmoid', '#4C72B0','Aplasta todo a [0,1]\nÚtil en salida (probabilidad)'), (relu, 'ReLU', '#DD8452','Cero si negativo, lineal si positivo\nMás usada en capas ocultas'), (tanh_v, 'Tanh', '#55A868','Aplasta a [-1, 1]\nAlternativa centrada a Sigmoid'),]for ax, (y, nombre, color, desc) inzip(axes, configs): ax.plot(z, y, color=color, lw=2.5) ax.axhline(0, color='gray', lw=0.8, ls='--') ax.axvline(0, color='gray', lw=0.8, ls='--') ax.set_title(nombre, fontsize=13, color=color, fontweight='bold') ax.set_xlabel('z (entrada)', fontsize=10) ax.set_ylabel('f(z)', fontsize=10) ax.text(0.05, 0.05, desc, transform=ax.transAxes, fontsize=8.5, va='bottom', color='#444444', bbox=dict(boxstyle='round,pad=0.3', facecolor='#f8f8f8', alpha=0.8)) ax.grid(True, alpha=0.3) ax.set_ylim(-1.2, 1.2) if nombre =='Tanh'elseNoneplt.tight_layout()plt.show()
Figura 9.2: Las tres funciones de activación más usadas. ReLU es la predeterminada en capas ocultas; Sigmoid se usa en la capa de salida para clasificación binaria; Softmax para clasificación multiclase.
La red feedforward (MLP)
Un solo perceptrón tiene capacidad limitada. La magia ocurre cuando conectamos muchos en capas: esto es un Perceptrón Multicapa (Multi-Layer Perceptron, MLP), también llamado red feedforward porque la información fluye siempre hacia adelante, sin ciclos.
Arquitectura en capas
Una red MLP tiene tres tipos de capas:
Capa
Descripción
Capa de entrada
Recibe las variables del problema (\(x_1, \ldots, x_n\)). No hay cálculo aquí, solo se pasan los datos.
Capas ocultas
Una o más capas de perceptrones que aprenden representaciones intermedias. La profundidad y el ancho son hiperparámetros de diseño.
Capa de salida
Produce la predicción final. Para clasificación binaria: 1 neurona con Sigmoid. Para regresión: 1 neurona lineal.
Código
import numpy as npimport matplotlib.pyplot as pltimport matplotlib.patches as mpatchesdef dibujar_red(capas, colores, etiquetas_capa, ax): max_n =max(capas) posiciones = []for c_idx, n inenumerate(capas): xs = np.full(n, c_idx *2.5) ys = np.linspace(-(n -1) /2, (n -1) /2, n) * (max_n /max(n, 1)) *0.9 posiciones.append(list(zip(xs, ys)))# Conexionesfor c inrange(len(capas) -1):for (x1, y1) in posiciones[c]:for (x2, y2) in posiciones[c +1]: ax.plot([x1, x2], [y1, y2], color='#cccccc', lw=0.8, zorder=1)# Nodosfor c_idx, (pos_capa, color) inenumerate(zip(posiciones, colores)):for i, (x, y) inenumerate(pos_capa): circ = plt.Circle((x, y), 0.3, color=color, zorder=3, ec='white', lw=1.5) ax.add_patch(circ)# Etiquetas de capafor c_idx, (pos_capa, etiq) inenumerate(zip(posiciones, etiquetas_capa)): x_c = pos_capa[0][0] y_top =max(p[1] for p in pos_capa) +0.65 ax.text(x_c, y_top, etiq, ha='center', va='bottom', fontsize=10, fontweight='bold', color='#333333')# Etiqueta número de neuronasfor c_idx, (pos_capa, n) inenumerate(zip(posiciones, capas)): x_c = pos_capa[0][0] y_bot =min(p[1] for p in pos_capa) -0.65 ax.text(x_c, y_bot, f'{n} neurona{"s"if n >1else""}', ha='center', va='top', fontsize=8.5, color='#666666')fig, ax = plt.subplots(figsize=(11, 6))ax.set_aspect('equal')ax.axis('off')capas = [3, 4, 3, 1]colores = ['#55A868', '#4C72B0', '#4C72B0', '#DD8452']etiquetas = ['Entrada', 'Oculta 1', 'Oculta 2', 'Salida']dibujar_red(capas, colores, etiquetas, ax)ax.set_xlim(-0.8, 8.3)ax.set_ylim(-3.2, 3.2)leyenda = [ mpatches.Patch(color='#55A868', label='Capa de entrada'), mpatches.Patch(color='#4C72B0', label='Capas ocultas'), mpatches.Patch(color='#DD8452', label='Capa de salida'),]ax.legend(handles=leyenda, loc='lower right', fontsize=9)ax.set_title('Arquitectura de una Red Feedforward (MLP)', fontsize=13, pad=12)plt.tight_layout()plt.show()
Figura 9.3: Arquitectura de una red MLP con 3 entradas, dos capas ocultas (4 y 3 neuronas) y 1 salida. Cada línea es una conexión con su propio peso.
¿Cuántas capas y cuántas neuronas?
No hay una respuesta única. Es un hiperparámetro que se ajusta con validación cruzada. Algunas heurísticas prácticas:
Pocas neuronas: el modelo puede tener sesgo (underfitting), no captura la complejidad.
Muchas neuronas: puede sobreajustar (overfitting) y tarda mucho en entrenar.
Para imágenes o texto: arquitecturas especializadas (CNN, Transformers) funcionan mucho mejor que un MLP genérico.
Redes neuronales como aproximadores universales
¿Por qué funcionan las redes neuronales? ¿Qué garantía teórica tenemos de que pueden resolver problemas complejos? La respuesta está en uno de los resultados más importantes de la teoría del aprendizaje automático.
Teorema de Aproximación Universal
Una red feedforward con al menos una capa oculta, un número suficiente de neuronas, y una función de activación no lineal puede aproximar cualquier función continua definida en un conjunto compacto de \(\mathbb{R}^n\), con cualquier precisión deseada.
Formalmente: para toda función continua \(f: [0,1]^n \to \mathbb{R}\) y todo \(\varepsilon > 0\), existe una red neuronal \(\hat{f}\) tal que \(\sup_x |f(x) - \hat{f}(x)| < \varepsilon\).
La implicación práctica es poderosa: no necesitas saber de antemano qué forma matemática tiene el patrón que buscas. La red neuronal es una “navaja suiza” funcional: dado suficientes neuronas y datos, puede representarlo.
Intuición: construir funciones con neuronas
La clave está en cómo la combinación de muchas funciones simples no lineales puede construir formas arbitrariamente complejas. Cada neurona con activación sigmoid, por ejemplo, produce una curva en forma de escalón suave. Sumando muchas de estas curvas escaladas y desplazadas, podemos aproximar cualquier función.
Código
import numpy as npimport matplotlib.pyplot as pltfrom sklearn.neural_network import MLPRegressornp.random.seed(0)# Función objetivo con varias oscilaciones — difícil de capturar con pocas neuronasdef f_objetivo(x):return np.sin(2* np.pi * x) +0.5* np.cos(6* np.pi * x)X_train = np.linspace(0, 1, 200).reshape(-1, 1)y_train = f_objetivo(X_train.ravel()) + np.random.normal(0, 0.1, 200)X_plot = np.linspace(0, 1, 500).reshape(-1, 1)y_real = f_objetivo(X_plot.ravel())n_neuronas = [2, 8, 64]colores = ['#DD8452', '#4C72B0', '#55A868']fig, axes = plt.subplots(1, 3, figsize=(13, 4), sharey=True)for ax, n, color inzip(axes, n_neuronas, colores):# lbfgs: optimizador cuasi-Newton, converge al mejor mínimo alcanzable# para cada capacidad — hace visible la diferencia entre 2, 8 y 64 neuronas modelo = MLPRegressor( hidden_layer_sizes=(n,), activation='tanh', solver='lbfgs', max_iter=10000, random_state=0, ) modelo.fit(X_train, y_train) y_pred = modelo.predict(X_plot) ax.scatter(X_train, y_train, s=10, color='gray', alpha=0.4, zorder=2, label='Observaciones') ax.plot(X_plot, y_real, 'k--', lw=2, label='Función real', zorder=3) ax.plot(X_plot, y_pred, color=color, lw=2.5, label=f'Red ({n} neuronas)', zorder=4) ax.fill_between(X_plot.ravel(), y_real, y_pred, alpha=0.15, color=color) mse = np.mean((y_real - y_pred)**2) ax.set_title(f'{n} neurona{"s"if n >1else""} oculta{"s"if n >1else""}\nMSE = {mse:.4f}', fontsize=11, color=color, fontweight='bold') ax.set_xlabel('x', fontsize=10) ax.set_ylabel('f(x)', fontsize=10) ax.legend(fontsize=8.5) ax.grid(True, alpha=0.3)plt.suptitle('Aproximación Universal: más neuronas → mejor aproximación', fontsize=13, y=1.02)plt.tight_layout()plt.show()
Figura 9.4: Demostración del teorema de aproximación universal: una red MLP con una capa oculta aproxima funciones cada vez mejor al aumentar el número de neuronas.
Las neuronas aprenden “piezas” de la función
Una forma de entender por qué funciona: cada neurona en la capa oculta aprende a “activarse” en una región diferente del espacio de entrada. La capa de salida luego combina linealmente estas activaciones parciales. Es como describir una función complicada como suma de funciones base más simples —igual que las series de Fourier descomponen señales en senos y cosenos.
Figura 9.5: Cada neurona oculta aprende una respuesta local (arriba). La capa de salida combina estas respuestas para aproximar la función objetivo (abajo).
El teorema dice “puede”, no “aprenderá automáticamente”
El teorema de aproximación universal garantiza la existencia de una red que aproxima la función, pero no garantiza que el proceso de entrenamiento la encuentre. En la práctica:
Con pocos datos, la red puede sobreajustar aunque tenga capacidad suficiente.
Con un learning rate mal elegido, puede converger a un mínimo local pobre.
Con muy pocas neuronas, no tiene capacidad suficiente.
El teorema es la justificación teórica de por qué tiene sentido usar redes neuronales. El arte del entrenamiento es cómo guiar al optimizador para que encuentre esa solución.
¿Cómo aprende la red?
Aquí está el corazón de todo. Aprender, para una red neuronal, significa encontrar los valores de los pesos \(w\) y sesgos \(b\) que hacen que las predicciones sean lo más cercanas posible a los valores reales. El proceso tiene tres etapas que se repiten cíclicamente.
1. Forward propagation: generar una predicción
La propagación hacia adelante es el recorrido que hacen los datos desde la entrada hasta la salida. Es el cálculo de la predicción dado un conjunto de pesos.
donde \(\mathbf{a}^{[l]}\) son las activaciones de esa capa, \(W^{[l]}\) son los pesos, y \(f\) es la función de activación.
En lenguaje de negocio: tomas un cliente con sus características, las pasas por todas las capas multiplicando por pesos, aplicas activaciones, y al final obtienes un número (por ejemplo, probabilidad de compra). Esto es predicción.
2. Calcular el error: la función de pérdida
Una vez que la red produce una predicción \(\hat{y}\), se compara con el valor real \(y\) mediante una función de pérdida (loss function). Es la misma idea que el error cuadrático en regresión o la entropía cruzada en clasificación.
\[\mathcal{L}(\hat{y}, y) = \text{qué tan mal está la predicción}\]
El objetivo del entrenamiento es minimizar esta pérdida. Pero con millones de parámetros, ¿cómo sabemos en qué dirección moverlos?
3. Backpropagation: propagar el error hacia atrás
La retropropagación (backpropagation) es el algoritmo que calcula, para cada peso en la red, cuánto “culpa” tiene en el error final. Formalmente, calcula el gradiente de la pérdida respecto a cada parámetro usando la regla de la cadena del cálculo diferencial.
La intuición: si la predicción fue mala, ¿qué peso contribuyó más al error? ¿En qué dirección debemos ajustarlo para reducir el error la próxima vez?
Código
import numpy as npimport matplotlib.pyplot as pltimport matplotlib.patches as mpatchesfig, ax = plt.subplots(figsize=(11, 4.5))ax.set_xlim(0, 11)ax.set_ylim(0, 5)ax.axis('off')# Bloques de capasbloques = [ (1.0, 'Entrada\n(datos)', '#55A868'), (3.5, 'Capa\nOculta 1', '#4C72B0'), (6.0, 'Capa\nOculta 2', '#4C72B0'), (8.5, 'Salida\n(ŷ)', '#DD8452'), (10.5, 'Pérdida\n𝓛', '#c0392b'),]for x, etiq, color in bloques: rect = mpatches.FancyBboxPatch((x -0.7, 1.6), 1.4, 1.8, boxstyle='round,pad=0.1', linewidth=1.5, edgecolor='white', facecolor=color, zorder=3, alpha=0.9) ax.add_patch(rect) ax.text(x, 2.5, etiq, ha='center', va='center', fontsize=9.5, color='white', fontweight='bold', zorder=4)# Flechas forward (izquierda a derecha)xs = [b[0] for b in bloques]for i inrange(len(xs) -1): ax.annotate('', xy=(xs[i+1] -0.72, 2.85), xytext=(xs[i] +0.72, 2.85), arrowprops=dict(arrowstyle='->', color='#2ecc71', lw=2.0))# Flechas backward (derecha a izquierda)for i inrange(len(xs) -1, 0, -1): ax.annotate('', xy=(xs[i-1] +0.72, 2.15), xytext=(xs[i] -0.72, 2.15), arrowprops=dict(arrowstyle='->', color='#e74c3c', lw=2.0))# Leyenda tipo textoax.text(5.5, 4.3, '→ Forward propagation (predicción)', ha='center', fontsize=10, color='#2ecc71', fontweight='bold')ax.text(5.5, 3.85, '← Backpropagation (gradiente del error)', ha='center', fontsize=10, color='#e74c3c', fontweight='bold')ax.set_title('Forward Pass y Backpropagation', fontsize=13, pad=8)plt.tight_layout()plt.show()
Figura 9.6: Intuición de backpropagation: el error se propaga hacia atrás, capa por capa, asignando responsabilidad a cada peso.
4. Gradient Descent: actualizar los pesos
Con los gradientes calculados, el algoritmo descenso por gradiente (gradient descent) ajusta cada peso en la dirección que reduce la pérdida:
\[w \leftarrow w - \eta \cdot \frac{\partial \mathcal{L}}{\partial w}\]
donde \(\eta\) (eta) es el learning rate, que controla qué tan grandes son los pasos. Es el mismo hiperparámetro que vimos en boosting.
La analogía clásica: imagina que estás en una montaña con los ojos vendados y quieres llegar al valle (el mínimo de la pérdida). El gradiente te dice la dirección de la pendiente más pronunciada, y das un paso en sentido contrario. El learning rate determina qué tan largo es cada paso.
Código
import numpy as npimport matplotlib.pyplot as pltdef perdida(w):return (w -2)**2+0.5* np.sin(4* w) +0.3w_vals = np.linspace(-1, 5, 400)fig, axes = plt.subplots(1, 3, figsize=(12, 5))configs_gd = [ ('Learning rate apropiado\n(η = 0.3)', 0.3, '#4C72B0'), ('Learning rate muy grande\n(η = 1.2)', 1.2, '#DD8452'), ('Learning rate muy pequeño\n(η = 0.03)', 0.03, '#55A868'),]for ax, (titulo, lr, color) inzip(axes, configs_gd): ax.plot(w_vals, perdida(w_vals), 'k-', lw=2, zorder=2) w =-0.5 trayectoria_w = [w] trayectoria_l = [perdida(w)]for _ inrange(12): grad =2* (w -2) +2* np.cos(4* w) w = w - lr * grad w = np.clip(w, -1, 5) trayectoria_w.append(w) trayectoria_l.append(perdida(w)) ax.plot(trayectoria_w, trayectoria_l, 'o-', color=color, markersize=6, lw=1.5, zorder=3, label='Pasos GD') ax.plot(trayectoria_w[0], trayectoria_l[0], 's', color='black', markersize=10, zorder=4, label='Inicio') ax.plot(trayectoria_w[-1], trayectoria_l[-1], '*', color='red', markersize=14, zorder=4, label='Final') ax.set_title(titulo, fontsize=10.5) ax.set_xlabel('Peso w', fontsize=10) ax.set_ylabel('Pérdida 𝓛(w)', fontsize=10) ax.legend(fontsize=8.5) ax.grid(True, alpha=0.3)plt.suptitle('Efecto del Learning Rate en el Descenso por Gradiente', fontsize=13, y=1.01)plt.tight_layout()plt.show()
Figura 9.7: Descenso por gradiente: la bola ‘rueda’ hacia el mínimo de la función de pérdida dando pasos proporcionales al gradiente. Un learning rate demasiado grande salta sobre el mínimo; demasiado pequeño converge lentamente.
Mini-batches: un truco práctico
En la práctica, no se calcula el gradiente sobre todos los datos a la vez (demasiado lento) ni sobre un solo ejemplo (demasiado ruidoso). Se usa un mini-batch: un subconjunto aleatorio de 32, 64 o 128 ejemplos. Cada actualización de pesos es una estimación rápida y razonablemente buena del gradiente real. Una pasada completa sobre todos los mini-batches se llama época (epoch).
El ciclo completo de entrenamiento
El entrenamiento de una red neuronal repite el siguiente ciclo por cada época:
Forward pass: pasar los datos por la red y obtener predicciones.
Calcular pérdida: comparar predicciones con valores reales.
Backward pass (backprop): calcular gradientes de la pérdida respecto a cada peso.
Actualizar pesos: dar un paso de gradient descent.
Figura 9.8: Entrenamiento de un MLP en datos de clasificación binaria. Izquierda: curva de pérdida en entrenamiento y validación (el modelo está aprendiendo). Derecha: frontera de decisión final del modelo.
Evolución de la frontera de decisión durante el entrenamiento
Una forma muy intuitiva de ver qué hace la red mientras aprende es observar cómo cambia su frontera de decisión a lo largo de las épocas:
Figura 9.9: La red empieza con una frontera casi aleatoria (época 1) y la refina progresivamente hasta separar bien las dos clases.
Arquitecturas comunes para detección de patrones
El MLP es la arquitectura más general, pero la práctica ha demostrado que diseñar la arquitectura según la estructura del problema produce modelos mucho más eficientes. Dos arquitecturas especializadas dominan el aprendizaje no supervisado y el procesamiento de imágenes.
Autoencoders
Un autoencoder es una red entrenada para reconstruir su propia entrada. El truco está en forzar que la información pase por un cuello de botella (espacio latente) de menor dimensión que la entrada. Para reconstruir bien la salida, la red debe aprender una representación comprimida que capture lo esencial.
Parte
Función
Encoder
Comprime la entrada hacia el espacio latente
Espacio latente
Representación compacta aprendida
Decoder
Reconstruye la entrada desde el espacio latente
La pérdida es el error de reconstrucción — qué tan diferente es la salida de la entrada. No se necesitan etiquetas: es aprendizaje no supervisado.
Figura 9.10: Arquitectura de un autoencoder: el encoder comprime la entrada hasta el cuello de botella y el decoder la reconstruye. El cuello de botella fuerza a la red a aprender solo lo esencial.
Figura 9.11: Autoencoder entrenado sobre dígitos escritos a mano (8×8 píxeles). Izquierda: comparación entre originales y sus reconstrucciones. Centro: espacio latente 2D — cada punto es un dígito comprimido a dos números. Derecha: error de reconstrucción por clase.
Usos principales de los autoencoders
Reducción de dimensionalidad no lineal: el espacio latente es como un PCA no lineal — captura relaciones curvilíneas que PCA no puede.
Detección de anomalías: si un dato tiene error de reconstrucción muy alto, es probable que sea inusual; el modelo no “sabe” cómo comprimirlo porque no lo vio en entrenamiento.
Denoising: entrenando con entrada ruidosa y salida limpia, la red aprende a filtrar el ruido.
Redes Convolucionales (CNN)
Un MLP trata cada píxel de una imagen como una variable independiente. El problema: ignora completamente la estructura espacial. Si desplazas un objeto 10 píxeles a la derecha, el MLP lo ve como una imagen completamente diferente.
Las Redes Convolucionales (Convolutional Neural Networks, CNN) resuelven esto con dos ideas:
Conectividad local: cada neurona solo ve una pequeña región de la imagen (su campo receptivo), no todos los píxeles.
Compartición de pesos: el mismo filtro se aplica en todas las posiciones de la imagen. Un filtro que detecta bordes lo hace en cualquier lugar, no solo en una esquina fija.
La operación de convolución
Un filtro (kernel) es una pequeña matriz de pesos que se desliza sobre la imagen. En cada posición realiza una multiplicación elemento a elemento y suma el resultado: eso produce un mapa de características (feature map).
Código
import numpy as npimport matplotlib.pyplot as pltfrom scipy.signal import convolve2dfrom sklearn.datasets import load_digitsdigits = load_digits()img = digits.images[0].astype(float) # dígito '0', 8×8filtros = {'Bordes\nhorizontales': np.array([[-1,-1,-1],[0,0,0],[1,1,1]], dtype=float),'Bordes\nverticales': np.array([[-1,0,1],[-1,0,1],[-1,0,1]], dtype=float),'Realce': np.array([[0,-1,0],[-1,5,-1],[0,-1,0]], dtype=float),}fig, axes = plt.subplots(2, 4, figsize=(13, 5))axes[0, 0].imshow(img, cmap='gray_r')axes[0, 0].set_title('Imagen original\n(8×8 píxeles)', fontsize=10)axes[0, 0].axis('off')axes[1, 0].text(0.5, 0.5, 'Imagen\nde entrada', ha='center', va='center', fontsize=10, transform=axes[1, 0].transAxes, color='#444')axes[1, 0].axis('off')for i, (nombre, filtro) inenumerate(filtros.items()): fm = convolve2d(img, filtro, mode='same') axes[0, i+1].imshow(fm, cmap='RdBu_r') axes[0, i+1].set_title(f'Mapa de características\n{nombre}', fontsize=9.5) axes[0, i+1].axis('off') axes[1, i+1].imshow(filtro, cmap='RdBu_r', vmin=-2, vmax=2) axes[1, i+1].set_title('Filtro 3×3', fontsize=9.5)for ii inrange(3):for jj inrange(3): val =int(filtro[ii, jj]) axes[1, i+1].text(jj, ii, f'{val:+d}', ha='center', va='center', fontsize=12, fontweight='bold', color='white'ifabs(val) >1else'black') axes[1, i+1].axis('off')plt.suptitle('Convolución: el mismo filtro se desliza sobre toda la imagen', fontsize=12, y=1.01)plt.tight_layout()plt.show()
Figura 9.12: Tres filtros 3×3 aplicados a un dígito escrito a mano. Cada filtro detecta un tipo distinto de patrón: bordes horizontales, verticales o texturas. En una CNN, estos pesos se aprenden automáticamente.
Pooling: reducir sin perder lo importante
Después de cada convolución, el mapa de características puede ser grande. El pooling lo reduce tomando un resumen de cada región pequeña. El más común es el max pooling: de cada ventana de 2×2 píxeles, conserva solo el valor máximo.
Código
import numpy as npimport matplotlib.pyplot as pltnp.random.seed(3)entrada = np.array([ [1, 3, 2, 4], [5, 6, 1, 2], [3, 2, 7, 8], [1, 4, 6, 3],], dtype=float)# Max pooling 2x2salida = np.array([ [max(entrada[r:r+2, c:c+2].ravel()) for c inrange(0, 4, 2)]for r inrange(0, 4, 2)], dtype=float)fig, axes = plt.subplots(1, 3, figsize=(10, 4), gridspec_kw={'width_ratios': [4, 1, 2]})# Mapa de entrada con cuadrantes coloreadosax = axes[0]colores_region = ['#d5e8f5', '#fde8d5', '#d5f5e3', '#f5d5e8']regiones = [(0,0),(0,2),(2,0),(2,2)]for k, (r0, c0) inenumerate(regiones):for dr inrange(2):for dc inrange(2): ax.add_patch(plt.Rectangle((c0+dc-0.5, r0+dr-0.5), 1, 1, facecolor=colores_region[k], edgecolor='white', lw=2, zorder=1)) val =int(entrada[r0+dr, c0+dc]) es_max = (entrada[r0+dr, c0+dc] == salida[r0//2, c0//2]) ax.text(c0+dc, r0+dr, str(val), ha='center', va='center', fontsize=14, fontweight='bold'if es_max else'normal', color='#c0392b'if es_max else'#444', zorder=3)ax.set_xlim(-0.5, 3.5)ax.set_ylim(3.5, -0.5)ax.set_xticks([])ax.set_yticks([])ax.set_title('Entrada (4×4)', fontsize=11)for spine in ax.spines.values(): spine.set_visible(False)# Flechaax = axes[1]ax.axis('off')ax.annotate('', xy=(0.9, 0.5), xytext=(0.1, 0.5), xycoords='axes fraction', arrowprops=dict(arrowstyle='->', color='#555', lw=2.5))ax.text(0.5, 0.65, 'Max\nPooling\n2×2', ha='center', va='bottom', fontsize=9, transform=ax.transAxes, color='#555')# Mapa de salidaax = axes[2]for k, (r0, c0) inenumerate(regiones): r_out, c_out = r0//2, c0//2 ax.add_patch(plt.Rectangle((c_out-0.5, r_out-0.5), 1, 1, facecolor=colores_region[k], edgecolor='white', lw=2, zorder=1)) ax.text(c_out, r_out, str(int(salida[r_out, c_out])), ha='center', va='center', fontsize=14, fontweight='bold', color='#c0392b', zorder=3)ax.set_xlim(-0.5, 1.5)ax.set_ylim(1.5, -0.5)ax.set_xticks([])ax.set_yticks([])ax.set_title('Salida (2×2)\nvalor máximo de cada región', fontsize=11)for spine in ax.spines.values(): spine.set_visible(False)plt.suptitle('Max Pooling: reducción de dimensión preservando lo más activo', fontsize=12, y=1.02)plt.tight_layout()plt.show()
Figura 9.13: Max pooling 2×2 con stride 2: cada región de 2×2 se reduce a su valor máximo, reduciendo el mapa a la mitad en cada dimensión. Esto hace la representación más compacta e invariante a pequeñas traslaciones.
El pooling tiene dos efectos: reduce el tamaño del mapa (menos cómputo y parámetros en capas siguientes) y aporta invarianza a pequeñas traslaciones — si el patrón se mueve un píxel, el máximo de la región probablemente no cambia.
Arquitectura de una CNN
Una CNN típica apila bloques de convolución + ReLU + pooling que van extrayendo características cada vez más abstractas, seguidos de capas densas para la clasificación final.
Código
import numpy as npimport matplotlib.pyplot as pltimport matplotlib.patches as mpatchesfig, ax = plt.subplots(figsize=(13, 5))ax.set_xlim(0, 13)ax.set_ylim(0, 6)ax.axis('off')bloques = [ (0.9, 3.2, 2.4, '#55A868', 'Entrada\n28×28×1'), (3.0, 2.8, 2.0, '#4C72B0', 'Conv + ReLU\n26×26×32'), (5.0, 2.2, 1.5, '#4C72B0', 'Max Pooling\n13×13×32'), (6.9, 1.8, 1.2, '#9b59b6', 'Conv + ReLU\n11×11×64'), (8.6, 1.4, 0.9, '#9b59b6', 'Max Pooling\n5×5×64'), (10.1, 1.1, 0.7, '#c0392b', 'Flatten\n1600'), (11.5, 1.0, 0.6, '#DD8452', 'Dense\nSoftmax\n10 clases'),]for x, w, h, color, label in bloques: rect = mpatches.FancyBboxPatch((x - w/2, 3- h/2), w, h, boxstyle='round,pad=0.07', facecolor=color, edgecolor='white', linewidth=1.5, zorder=3, alpha=0.9) ax.add_patch(rect) ax.text(x, 3, label, ha='center', va='center', fontsize=8.5, color='white', fontweight='bold', zorder=4)xs = [b[0] for b in bloques]ws = [b[1] for b in bloques]for i inrange(len(xs) -1): ax.annotate('', xy=(xs[i+1] - ws[i+1]/2, 3), xytext=(xs[i] + ws[i]/2, 3), arrowprops=dict(arrowstyle='->', color='#555555', lw=1.6))ax.annotate('', xy=(9.3, 0.8), xytext=(1.8, 0.8), arrowprops=dict(arrowstyle='<->', color='#4C72B0', lw=1.5))ax.text(5.5, 0.4, 'Extracción de características (convoluciones + pooling)', ha='center', fontsize=9.5, color='#4C72B0', fontweight='bold')ax.annotate('', xy=(12.1, 0.8), xytext=(9.8, 0.8), arrowprops=dict(arrowstyle='<->', color='#DD8452', lw=1.5))ax.text(11.0, 0.4, 'Clasificación', ha='center', fontsize=9.5, color='#DD8452', fontweight='bold')ax.set_title('Arquitectura de una Red Convolucional (CNN)', fontsize=13, pad=8)plt.tight_layout()plt.show()
Figura 9.14: Arquitectura típica de una CNN: bloques de convolución + pooling que reducen el espacio espacial mientras aumentan el número de mapas de características, seguidos de capas densas para clasificación.
Por qué las CNN dominan en imágenes
Invarianza traslacional: el mismo filtro detecta un patrón sin importar dónde aparezca en la imagen.
Jerarquía de características: capas tempranas detectan bordes → capas medias detectan formas → capas profundas detectan objetos completos.
Eficiencia paramétrica: un filtro 3×3 tiene solo 9 pesos, sin importar el tamaño de la imagen. Un MLP equivalente sobre una imagen de 224×224 necesitaría millones de parámetros en la primera capa.
Mecanismo de Atención
Las CNN y los MLP tienen un límite: tratan todas las posiciones igual. Para texto y series de tiempo, lo que importa es que ciertas palabras o pasos temporales son más relevantes entre sí que otros. El mecanismo de atención resuelve esto: aprende a asignar pesos dinámicos a cada parte de la entrada según el contexto.
La idea central: para producir la representación de un elemento, la red pregunta (Query) a todos los demás si son relevantes. Cada elemento ofrece una llave (Key) para ser comparado y un valor (Value) con su contenido. La similitud Query–Key determina cuánto se toma de cada Value.
import numpy as npimport matplotlib.pyplot as pltimport matplotlib.patches as mpatchesnp.random.seed(0)palabras = ['El', 'banco', 'del', 'río', 'está', 'lleno']n =len(palabras)# Pesos de atención simulados: "banco" presta más atención a "río" que a "lleno"atencion = np.array([ [0.50, 0.15, 0.10, 0.10, 0.10, 0.05], [0.08, 0.30, 0.08, 0.38, 0.10, 0.06], # "banco" → mira "río" [0.10, 0.10, 0.45, 0.15, 0.10, 0.10], [0.05, 0.35, 0.10, 0.35, 0.10, 0.05], [0.10, 0.10, 0.10, 0.10, 0.45, 0.15], [0.05, 0.10, 0.10, 0.10, 0.25, 0.40],])atencion = atencion / atencion.sum(axis=1, keepdims=True)fig, axes = plt.subplots(1, 2, figsize=(13, 5))# Panel 1: mapa de calor de atenciónax = axes[0]im = ax.imshow(atencion, cmap='Blues', vmin=0, vmax=0.55)ax.set_xticks(range(n)); ax.set_xticklabels(palabras, fontsize=11)ax.set_yticks(range(n)); ax.set_yticklabels(palabras, fontsize=11)ax.set_xlabel('Keys (¿a quién miro?)', fontsize=11)ax.set_ylabel('Queries (¿quién pregunta?)', fontsize=11)ax.set_title('Mapa de pesos de atención', fontsize=12)for i inrange(n):for j inrange(n): ax.text(j, i, f'{atencion[i,j]:.2f}', ha='center', va='center', fontsize=8.5, color='white'if atencion[i,j] >0.3else'#333')plt.colorbar(im, ax=ax, shrink=0.8)# Panel 2: diagrama Q-K-V para la palabra "banco"ax = axes[1]ax.set_xlim(0, 10); ax.set_ylim(0, 7); ax.axis('off')ax.set_title('Atención para la palabra "banco"', fontsize=12)colores_kv = plt.cm.Blues(np.linspace(0.25, 0.85, n))# Query boxrect_q = mpatches.FancyBboxPatch((0.3, 3.0), 1.6, 0.8, boxstyle='round,pad=0.1', facecolor='#DD8452', edgecolor='white', lw=1.5, zorder=3)ax.add_patch(rect_q)ax.text(1.1, 3.4, 'Q: "banco"', ha='center', va='center', fontsize=10, color='white', fontweight='bold', zorder=4)for i, (pal, peso, c) inenumerate(zip(palabras, atencion[1], colores_kv)): y =6.2- i *0.95# Key box rect_k = mpatches.FancyBboxPatch((3.2, y -0.3), 1.5, 0.65, boxstyle='round,pad=0.05', facecolor=c, edgecolor='white', lw=1, zorder=3) ax.add_patch(rect_k) ax.text(3.95, y +0.02, f'K: "{pal}"', ha='center', va='center', fontsize=9, color='white'if peso >0.2else'#333', fontweight='bold', zorder=4)# Value box rect_v = mpatches.FancyBboxPatch((6.0, y -0.3), 1.5, 0.65, boxstyle='round,pad=0.05', facecolor=c, edgecolor='white', lw=1, alpha=0.7, zorder=3) ax.add_patch(rect_v) ax.text(6.75, y +0.02, f'V · {peso:.2f}', ha='center', va='center', fontsize=9, color='#333', zorder=4)# Flecha Q → K (ancho proporcional al peso) ax.annotate('', xy=(3.18, y +0.02), xytext=(1.92, 3.4), arrowprops=dict(arrowstyle='->', color='#DD8452', lw=0.8+ peso *6, alpha=0.5+ peso))ax.text(3.95, 0.4, 'Keys', ha='center', fontsize=10, color='#4C72B0', fontweight='bold')ax.text(6.75, 0.4, 'Values × peso', ha='center', fontsize=10, color='#4C72B0', fontweight='bold')# Salidarect_out = mpatches.FancyBboxPatch((8.1, 3.0), 1.5, 0.8, boxstyle='round,pad=0.1', facecolor='#55A868', edgecolor='white', lw=1.5, zorder=3)ax.add_patch(rect_out)ax.text(8.85, 3.4, 'Salida\n"banco"', ha='center', va='center', fontsize=9, color='white', fontweight='bold', zorder=4)ax.annotate('', xy=(8.08, 3.4), xytext=(7.52, 3.4), arrowprops=dict(arrowstyle='->', color='#555', lw=2.0))ax.text(7.75, 3.7, 'Σ', fontsize=14, ha='center', color='#555')plt.tight_layout()plt.show()
Figura 9.15: Mecanismo de atención: la Query de cada palabra se compara con las Keys de todas las demás. Los pesos de atención (colores) determinan cuánto de cada Value se incorpora a la representación final.
Atención Multi-Cabeza
En la práctica, no se usa una sola función de atención sino varias en paralelo (cabezas). Cada cabeza aprende a enfocarse en un tipo diferente de relación: una puede capturar dependencias sintácticas, otra semánticas, otra de posición.
Figura 9.16: Atención multi-cabeza: H cabezas atienden a distintos patrones en paralelo. Sus salidas se concatenan y proyectan. Cada cabeza puede especializarse en un tipo de relación diferente.
El Transformer
El Transformer es la arquitectura que cambió el campo: GPT, BERT, LLaMA y todos los grandes modelos de lenguaje son Transformers. Su ventaja frente a las arquitecturas recurrentes (LSTM, GRU) es que procesa toda la secuencia en paralelo usando solo atención.
Codificación posicional
La atención no tiene noción de orden: “El banco del río” y “río del banco El” producirían los mismos pesos. Para dar al modelo sentido de posición se suman embeddings posicionales a los embeddings de entrada:
import numpy as npimport matplotlib.pyplot as pltdef positional_encoding(max_len, d_model): PE = np.zeros((max_len, d_model)) pos = np.arange(max_len)[:, None] div = np.exp(np.arange(0, d_model, 2) * (-np.log(10000.0) / d_model)) PE[:, 0::2] = np.sin(pos * div) PE[:, 1::2] = np.cos(pos * div)return PEmax_len, d_model =60, 64PE = positional_encoding(max_len, d_model)fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))# Mapa de calor completoax = axes[0]im = ax.imshow(PE.T, cmap='RdBu_r', aspect='auto', vmin=-1, vmax=1)ax.set_xlabel('Posición en la secuencia', fontsize=11)ax.set_ylabel('Dimensión del embedding', fontsize=11)ax.set_title('Matriz de codificación posicional\n(64 dimensiones, 60 posiciones)', fontsize=11)plt.colorbar(im, ax=ax, shrink=0.85)# Primeras dimensiones como señalesax = axes[1]dims = [0, 1, 4, 5, 16, 17]colores = plt.cm.tab10(np.linspace(0, 0.6, len(dims)))posiciones = np.arange(max_len)for dim, color inzip(dims, colores): tipo ='sin'if dim %2==0else'cos' ax.plot(posiciones, PE[:, dim], color=color, lw=2, label=f'dim {dim} ({tipo})')ax.set_xlabel('Posición en la secuencia', fontsize=11)ax.set_ylabel('Valor PE', fontsize=11)ax.set_title('Señales posicionales por dimensión\n(frecuencias distintas = posiciones únicas)', fontsize=11)ax.legend(fontsize=8.5, ncol=2)ax.grid(True, alpha=0.3)ax.axhline(0, color='black', lw=0.7)plt.tight_layout()plt.show()
Figura 9.17: Codificación posicional sinusoidal: cada posición en la secuencia recibe un vector único formado por senos y cosenos de distintas frecuencias. El modelo puede inferir posición absoluta y distancia relativa entre tokens.
Figura 9.18: Arquitectura completa del Transformer: bloque encoder (izquierda) y bloque decoder (derecha). El encoder procesa la entrada completa en paralelo; el decoder genera la salida token a token usando la representación del encoder vía cross-attention.
Encoder-only vs Decoder-only
En la práctica, la mayoría de los modelos modernos usan solo una parte del Transformer original:
Figura 9.19: Tres familias de Transformers según cómo usan la arquitectura original. Encoder-only (BERT): entiende texto. Decoder-only (GPT): genera texto. Encoder-decoder (T5, mBART): traduce o resume.
¿Por qué el Transformer dominó todo?
Tres ventajas clave sobre las redes recurrentes (LSTM):
Paralelismo total: procesa todos los tokens a la vez, no uno a uno. Permite escalar a miles de millones de parámetros.
Contexto global de un solo paso: cualquier token puede atender directamente a cualquier otro, sin importar la distancia. Las LSTM degradan con secuencias largas.
Transferencia de aprendizaje: un Transformer pre-entrenado en texto masivo se puede fine-tunear en cualquier tarea con pocos datos.
Resumen conceptual
Concepto
Qué es
Analogía
Perceptrón
Unidad básica: combina entradas con pesos y aplica una activación
Neurona biológica
Capa oculta
Conjunto de perceptrones que aprenden representaciones intermedias
Capas de abstracción
Forward propagation
Cálculo de la predicción pasando datos hacia adelante
Responder un examen
Función de pérdida
Mide qué tan mala es la predicción
Calificación del examen
Backpropagation
Calcula qué tan “culpable” es cada peso del error
Revisar qué falló y por qué
Gradient descent
Ajusta los pesos para reducir el error
Estudiar los temas donde fallaste
Learning rate
Controla el tamaño del ajuste
Qué tan agresivamente cambias tu estrategia
Época
Una pasada completa sobre todos los datos de entrenamiento
Un ciclo de estudio
Atención
Mecanismo que aprende qué partes de la entrada son relevantes entre sí
Subrayar las palabras clave al leer
Query / Key / Value
Triplete que implementa la atención: pregunta, comparador y contenido
Búsqueda en un índice
Multi-cabeza
Varias funciones de atención en paralelo, cada una especializada
Varios lectores con distintos criterios
Transformer
Arquitectura basada solo en atención, sin recurrencia
Red paralela que “lee” toda la secuencia a la vez
Codificación posicional
Vector que indica la posición de cada token en la secuencia
Numeración de páginas en un libro
¿Cuándo usar redes neuronales en proyectos de negocio?
Las redes neuronales brillan cuando:
Los datos no son tabulares: imágenes, audio, texto, series de tiempo largas.
Tienes muchos datos: necesitan miles de ejemplos para generalizar bien.
Puedes permitirte tiempo de entrenamiento: son más lentas que boosting.
Para la mayoría de los proyectos de negocio con datos tabulares y cientos/miles de registros, XGBoost o LightGBM seguirán siendo tu primera opción. Las redes neuronales son la herramienta correcta cuando la complejidad del problema y la escala de los datos lo justifican.