64 Redes Neuronales para Clasificación
Las redes neuronales artificiales son modelos computacionales inspirados en la estructura del sistema nervioso biológico. Desde el perceptrón de Rosenblatt en 1958 (Rosenblatt 1958) hasta las arquitecturas profundas que dominan el estado del arte actual (LeCun et al. 2015), el esqueleto matemático fundamental permanece invariante: una composición de transformaciones lineales seguidas de no linealidades, aplicadas de manera jerárquica sobre los datos de entrada. Esta arquitectura modular permite que el modelo aprenda representaciones cada vez más abstractas de los datos, pasando de características de bajo nivel (como bordes en una imagen) hasta conceptos de alto nivel (como la clase de un objeto).
El interés renovado por las redes neuronales en la última década no es accidental. Se sustenta en tres pilares convergentes: la disponibilidad de grandes volúmenes de datos etiquetados, la capacidad de cómputo paralelo ofrecida por las unidades de procesamiento gráfico (GPU), y avances teóricos y prácticos en el entrenamiento de redes profundas. El teorema de aproximación universal (Hornik et al. 1989) formaliza por que estas arquitecturas son tan poderosas: una red de una sola capa oculta con suficientes unidades puede aproximar cualquier función continua sobre un conjunto compacto con precisión arbitraria. Este capitulo desarrolla la teoría desde el perceptrón hasta las redes convolucionales, construyendo cada componente desde primeros principios con implementaciones en Python puro y visualizaciones interactivas.
1 El Perceptrón
1.1 Modelo matemático
El perceptrón es la unidad de cómputo más elemental de las redes neuronales. Recibe \(d\) entradas escalares \(x_1, \ldots, x_d\) y computa una combinación lineal ponderada seguida de una función de activación binaria. Formalmente, dados un vector de pesos \(\mathbf{w} \in \mathbb{R}^d\) y un sesgo \(b \in \mathbb{R}\), el perceptrón calcula:
\[z = \mathbf{w}^\top \mathbf{x} + b = \sum_{i=1}^d w_i x_i + b \tag{1}\]
\[\hat{y} = \text{sign}(z) \in \{-1, +1\} \tag{2}\]
La interpretación geométrica es inmediata: el conjunto \(\{\mathbf{x} : \mathbf{w}^\top \mathbf{x} + b = 0\}\) define un hiperplano en \(\mathbb{R}^d\) que divide el espacio en dos semiespacios. Los puntos con \(\mathbf{w}^\top \mathbf{x} + b > 0\) se clasifican como clase \(+1\) y los puntos con \(\mathbf{w}^\top \mathbf{x} + b < 0\) como clase \(-1\). El vector \(\mathbf{w}\) es el vector normal al hiperplano, y su magnitud \(\|\mathbf{w}\|\) determina la escala del margen de decisión. El sesgo \(b\) desplaza el hiperplano respecto al origen, permitiendo que la frontera de decisión no pase necesariamente por el punto cero.
Esta representación es exactamente la misma que la de las Máquinas de Soporte Vectorial, con la diferencia fundamental de que el perceptrón no busca el hiperplano de margen máximo: simplemente busca cualquier hiperplano que clasifique correctamente todos los ejemplos de entrenamiento. Esta distinción es crucial y explica por que el perceptrón puede encontrar diferentes soluciones dependiendo del orden en que se presenten los datos, mientras que la SVM produce una solución única y óptima en el sentido del margen.
1.2 Regla de aprendizaje del perceptrón
El algoritmo de entrenamiento del perceptrón es sorprendentemente simple. Se procesan los ejemplos de entrenamiento de forma iterativa y, cuando el modelo comete un error de clasificación en el ejemplo \((\mathbf{x}^{(i)}, y^{(i)})\), se actualiza el vector de pesos en la dirección que corrige ese error:
\[\mathbf{w} \leftarrow \mathbf{w} + \eta \, y^{(i)} \mathbf{x}^{(i)}, \quad b \leftarrow b + \eta \, y^{(i)} \tag{3}\]
donde \(\eta > 0\) es la tasa de aprendizaje. La lógica de esta regla es directa: si se predijo \(\hat{y} = -1\) cuando el verdadero valor es \(y^{(i)} = +1\) (falso negativo), entonces \(\mathbf{w}\) se desplaza en la dirección de \(\mathbf{x}^{(i)}\), lo que aumenta \(\mathbf{w}^\top \mathbf{x}^{(i)}\) y acerca la predicción al valor correcto. Si se predijo \(\hat{y} = +1\) cuando el verdadero es \(y^{(i)} = -1\) (falso positivo), el desplazamiento es en la dirección opuesta a \(\mathbf{x}^{(i)}\).
El teorema de convergencia del perceptrón establece que si los datos son linealmente separables con margen geométrico \(\gamma > 0\) (es decir, existe un hiperplano que clasifica todos los puntos correctamente con distancia mínima \(\gamma\) a cualquier punto), entonces el algoritmo converge en a lo sumo \(R^2/\gamma^2\) pasos, donde \(R = \max_i \|\mathbf{x}^{(i)}\|\) es el radio de los datos (Rosenblatt 1958). Si los datos no son linealmente separables, el algoritmo no converge: oscila indefinidamente. Este es el límite fundamental del perceptrón simple que motivo el desarrollo de las redes multicapa.
Mostrar código
import numpy as np
import plotly.graph_objects as go
rng = np.random.default_rng(7)
# Generate linearly separable 2D data
n_per_class = 20
X_neg = rng.multivariate_normal([-2.0, -1.5], [[0.6, 0.1], [0.1, 0.6]], n_per_class)
X_pos = rng.multivariate_normal([2.0, 1.5], [[0.6, 0.1], [0.1, 0.6]], n_per_class)
X = np.vstack([X_neg, X_pos])
y = np.array([-1] * n_per_class + [1] * n_per_class)
# Perceptron training from scratch - record history
w = np.zeros(2)
b = 0.0
eta = 0.3
max_iters = 200
history = [(w.copy(), b, 0)]
converged = False
iteration = 0
while not converged and iteration < max_iters:
errors = 0
for i in range(len(y)):
z = np.dot(w, X[i]) + b
y_hat = 1 if z >= 0 else -1
if y_hat != y[i]:
w = w + eta * y[i] * X[i]
b = b + eta * y[i]
errors += 1
iteration += 1
history.append((w.copy(), b, errors))
if errors == 0:
converged = True
# Build animated figure
x_range = np.array([-5.0, 5.0])
frames = []
for t, (wt, bt, errs) in enumerate(history):
scatter_neg = go.Scatter(
x=X_neg[:, 0], y=X_neg[:, 1],
mode="markers",
marker=dict(color="#1f77b4", size=8, symbol="circle"),
name="Clase -1",
showlegend=(t == 0)
)
scatter_pos = go.Scatter(
x=X_pos[:, 0], y=X_pos[:, 1],
mode="markers",
marker=dict(color="#ff7f0e", size=8, symbol="diamond"),
name="Clase +1",
showlegend=(t == 0)
)
# Decision boundary: w[0]*x + w[1]*y + b = 0 => y = -(w[0]*x + b) / w[1]
if abs(wt[1]) > 1e-10:
y_line = -(wt[0] * x_range + bt) / wt[1]
line_trace = go.Scatter(
x=x_range, y=y_line,
mode="lines",
line=dict(color="#2ca02c", width=2.5),
name="Frontera",
showlegend=(t == 0)
)
else:
line_trace = go.Scatter(x=[], y=[], mode="lines", showlegend=False)
frames.append(go.Frame(
data=[scatter_neg, scatter_pos, line_trace],
name=str(t),
layout=go.Layout(title_text=f"Iteracion {t} | Errores: {errs}")
))
# Initial state
w0, b0, e0 = history[0]
init_neg = go.Scatter(x=X_neg[:, 0], y=X_neg[:, 1], mode="markers",
marker=dict(color="#1f77b4", size=8, symbol="circle"), name="Clase -1")
init_pos = go.Scatter(x=X_pos[:, 0], y=X_pos[:, 1], mode="markers",
marker=dict(color="#ff7f0e", size=8, symbol="diamond"), name="Clase +1")
init_line = go.Scatter(x=[], y=[], mode="lines",
line=dict(color="#2ca02c", width=2.5), name="Frontera")
steps = []
for t in range(len(history)):
steps.append(dict(
method="animate",
args=[[str(t)], dict(mode="immediate", frame=dict(duration=300, redraw=True),
transition=dict(duration=0))],
label=str(t)
))
sliders = [dict(
active=0,
steps=steps,
x=0.0, y=0.0,
len=1.0,
currentvalue=dict(prefix="Iteracion: ", font=dict(size=13)),
pad=dict(t=50)
)]
fig = go.Figure(
data=[init_neg, init_pos, init_line],
frames=frames,
layout=go.Layout(
title="Aprendizaje del Perceptron",
xaxis=dict(range=[-5, 5], title="x1"),
yaxis=dict(range=[-5, 5], title="x2"),
sliders=sliders,
updatemenus=[dict(
type="buttons", showactive=False,
y=1.15, x=0.5, xanchor="center",
buttons=[
dict(label="Reproducir", method="animate",
args=[None, dict(frame=dict(duration=400, redraw=True),
fromcurrent=True, mode="immediate")]),
dict(label="Pausar", method="animate",
args=[[None], dict(frame=dict(duration=0, redraw=False),
mode="immediate")])
]
)],
width=680, height=520,
legend=dict(x=0.01, y=0.99)
)
)
fig.show()1.3 La limitación del perceptrón: el problema XOR
Aunque el perceptrón es elegante en su simplicidad, tiene una limitación fundamental: solo puede separar clases que son linealmente separables. En 1969, Minsky y Papert demostraron en su libro Perceptrons que la función lógica XOR (o exclusivo) no puede ser implementada por un perceptrón simple. Esta constatación genero un período de pesimismo en la investigación sobre redes neuronales conocido como el “invierno del IA”, hasta que el desarrollo del algoritmo de retropropagación en los anos ochenta revivio el campo al demostrar que las redes multicapa podían aprender representaciones no lineales.
Mostrar código
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
# Logic gate data
X_logic = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=float)
y_and = np.array([-1, -1, -1, 1])
y_or = np.array([-1, 1, 1, 1])
y_xor = np.array([-1, 1, 1, -1])
colors_map = {-1: "#1f77b4", 1: "#ff7f0e"}
symbols_map = {-1: "circle", 1: "diamond"}
fig = make_subplots(rows=2, cols=2,
subplot_titles=["AND (separable)", "OR (separable)",
"XOR (NO separable)", "XOR resuelto por MLP"])
def add_gate_scatter(fig, X, y, row, col, show_legend=False):
for cls in [-1, 1]:
mask = y == cls
fig.add_trace(go.Scatter(
x=X[mask, 0], y=X[mask, 1],
mode="markers",
marker=dict(color=colors_map[cls], size=14,
symbol=symbols_map[cls],
line=dict(width=1.5, color="black")),
name=f"Clase {'+1' if cls==1 else '-1'}",
showlegend=show_legend and row == 1 and col == 1,
legendgroup=str(cls)
), row=row, col=col)
add_gate_scatter(fig, X_logic, y_and, 1, 1, show_legend=True)
add_gate_scatter(fig, X_logic, y_or, 1, 2)
add_gate_scatter(fig, X_logic, y_xor, 2, 1)
add_gate_scatter(fig, X_logic, y_xor, 2, 2)
# AND separating line: x1 + x2 = 1.5
xv = np.linspace(-0.3, 1.3, 50)
fig.add_trace(go.Scatter(x=xv, y=1.5 - xv, mode="lines",
line=dict(color="#2ca02c", width=2), showlegend=False), row=1, col=1)
# OR separating line: x1 + x2 = 0.5
fig.add_trace(go.Scatter(x=xv, y=0.5 - xv, mode="lines",
line=dict(color="#2ca02c", width=2), showlegend=False), row=1, col=2)
# XOR annotation
fig.add_annotation(text="No separable linealmente", x=0.5, y=0.5,
xref="x3", yref="y3",
font=dict(size=12, color="red"),
showarrow=False, bgcolor="rgba(255,200,200,0.7)",
bordercolor="red", borderwidth=1)
# XOR solved with MLP: simple 2-layer from scratch
# Hand-tuned weights that solve XOR
# h1 = relu(x1 - x2 - 0.5), h2 = relu(-x1 + x2 - 0.5), out = sigmoid(h1 + h2 - 0.5)
xx, yy = np.meshgrid(np.linspace(-0.3, 1.3, 60), np.linspace(-0.3, 1.3, 60))
grid = np.c_[xx.ravel(), yy.ravel()]
W1 = np.array([[1.0, -1.0], [-1.0, 1.0]])
b1 = np.array([-0.5, -0.5])
W2 = np.array([[1.0], [1.0]])
b2 = np.array([-0.5])
H = np.maximum(0, grid @ W1.T + b1)
out = 1.0 / (1.0 + np.exp(-(H @ W2 + b2).ravel()))
Z = out.reshape(xx.shape)
fig.add_trace(go.Contour(
x=np.linspace(-0.3, 1.3, 60),
y=np.linspace(-0.3, 1.3, 60),
z=Z,
colorscale=[[0, "rgba(31,119,180,0.25)"], [0.5, "rgba(255,255,255,0.1)"],
[1, "rgba(255,127,14,0.25)"]],
contours=dict(start=0.5, end=0.5, size=0, coloring="fill",
showlabels=True, labelfont=dict(size=11, color="white")),
showscale=False, showlegend=False, line=dict(width=2, color="white")
), row=2, col=2)
for row, col in [(1, 1), (1, 2), (2, 1), (2, 2)]:
fig.update_xaxes(range=[-0.3, 1.3], tickvals=[0, 1], row=row, col=col)
fig.update_yaxes(range=[-0.3, 1.3], tickvals=[0, 1], row=row, col=col)
fig.update_layout(height=540, width=700, title_text="Compuertas logicas y separabilidad lineal",
legend=dict(x=1.02, y=0.9))
fig.show()2 Funciones de Activación
Las funciones de activación son el ingrediente esencial que dota a las redes neuronales de su capacidad expresiva. Sin ellas, cualquier composición de capas lineales colapsaría a una única transformación lineal: si \(f(\mathbf{x}) = \mathbf{W}_2(\mathbf{W}_1 \mathbf{x} + \mathbf{b}_1) + \mathbf{b}_2\), esto es simplemente \((\mathbf{W}_2\mathbf{W}_1)\mathbf{x} + \mathbf{W}_2\mathbf{b}_1 + \mathbf{b}_2\), una transformación lineal con una sola capa equivalente. La no linealidad introducida por la función de activación permite que las capas aprendan representaciones jerárquicas genuinamente no lineales.
La elección de la función de activación afecta tanto la expresividad del modelo como la estabilidad del entrenamiento. Las funciones clásicas como la sigmoide y la tangente hiperbólica fueron populares en las primeras decadas, pero sufren del problema del gradiente desvaneciente en redes profundas. Las funciones rectificadoras (ReLU y sus variantes) han dominado la práctica moderna precisamente por mitigar este problema. Las cinco funciones principales se definen y comparan a continuación.
La sigmoide \(\sigma(z) = \frac{1}{1+e^{-z}}\) mapea cualquier valor real al intervalo \((0, 1)\) y se usa típicamente en la capa de salida para clasificación binaria. Su derivada es \(\sigma'(z) = \sigma(z)(1-\sigma(z)) \leq 0.25\), lo que significa que el gradiente nunca supera 0.25 en ninguna unidad. La tangente hiperbólica \(\tanh(z) = \frac{e^z - e^{-z}}{e^z + e^{-z}}\) produce salidas en \((-1, 1)\) y tiene derivada \(1-\tanh^2(z) \leq 1\), con gradientes maximos en \(z=0\). La ReLU \(\text{ReLU}(z) = \max(0,z)\) tiene derivada 1 para \(z > 0\) y 0 para \(z < 0\), lo que elimina el problema de gradientes exponencialmente pequeños en la parte positiva. La Leaky ReLU \(\text{LReLU}(z) = \max(\alpha z, z)\) con \(\alpha = 0.1\) resuelve el problema de las neuronas muertas (unidades que quedan atascadas con \(z < 0\) y nunca se actualizan). La Softplus \(\text{softplus}(z) = \ln(1+e^z)\) es una aproximación suave y diferenciable en todas partes de la ReLU.
El problema del gradiente desvaneciente ocurre cuando se apilan muchas capas con activaciones cuya derivada es menor que 1 en valor absoluto. Si la red tiene \(L\) capas y cada una tiene derivada de activación \(\delta < 1\), el gradiente que llega a las capas iniciales escala como \(\delta^L\), que decrece exponencialmente con la profundidad. Para sigmoid, \(\delta \leq 0.25\), por lo que en una red de 10 capas el gradiente puede reducirse en un factor de \(0.25^{10} \approx 10^{-6}\). La ReLU, al tener derivada 1 para entradas positivas, elimina este factor multiplicativo en las unidades activas.
Mostrar código
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
z = np.linspace(-4, 4, 300)
# Define activations and derivatives (pure numpy, no scipy)
def sigmoid(z):
return 1.0 / (1.0 + np.exp(-np.clip(z, -500, 500)))
def tanh_fn(z):
return np.tanh(z)
def relu(z):
return np.maximum(0, z)
def leaky_relu(z, alpha=0.1):
return np.where(z >= 0, z, alpha * z)
def softplus(z):
return np.log1p(np.exp(np.clip(z, -500, 20)))
def sigmoid_grad(z):
s = sigmoid(z)
return s * (1 - s)
def tanh_grad(z):
return 1 - np.tanh(z)**2
def relu_grad(z):
return (z > 0).astype(float)
def leaky_relu_grad(z, alpha=0.1):
return np.where(z >= 0, 1.0, alpha)
def softplus_grad(z):
return sigmoid(z)
names = ["Sigmoid", "Tanh", "ReLU", "Leaky ReLU", "Softplus"]
funcs = [sigmoid, tanh_fn, relu, leaky_relu, softplus]
grads = [sigmoid_grad, tanh_grad, relu_grad, leaky_relu_grad, softplus_grad]
colors = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd"]
fig = make_subplots(rows=2, cols=5,
subplot_titles=[n for n in names] + [f"d/dz {n}" for n in names],
vertical_spacing=0.18, horizontal_spacing=0.08)
for col, (name, fn, gn, color) in enumerate(zip(names, funcs, grads, colors), start=1):
# Function row
fig.add_trace(go.Scatter(
x=z, y=fn(z), mode="lines",
line=dict(color=color, width=2.5),
name=name, showlegend=False
), row=1, col=col)
fig.add_hline(y=0, line=dict(color="gray", width=0.8, dash="dot"), row=1, col=col)
fig.add_vline(x=0, line=dict(color="gray", width=0.8, dash="dot"), row=1, col=col)
# Derivative row
fig.add_trace(go.Scatter(
x=z, y=gn(z), mode="lines",
line=dict(color=color, width=2.5, dash="dash"),
name=f"d {name}", showlegend=False
), row=2, col=col)
fig.add_hline(y=0, line=dict(color="gray", width=0.8, dash="dot"), row=2, col=col)
fig.add_vline(x=0, line=dict(color="gray", width=0.8, dash="dot"), row=2, col=col)
fig.update_layout(height=480, width=950,
title_text="Funciones de activacion y sus derivadas")
fig.update_xaxes(title_text="z", range=[-4, 4])
fig.show()3 Red Neuronal Multicapa (MLP)
3.1 Arquitectura
Una red neuronal multicapa (Multilayer Perceptrón, MLP) es una composición de \(L\) capas de transformaciones. Cada capa \(\ell \in \{1, \ldots, L\}\) tiene \(n_\ell\) unidades (neuronas). La capa \(0\) corresponde a la entrada con \(n_0 = d\) unidades. Los parámetros de la capa \(\ell\) son la matriz de pesos \(\mathbf{W}^{(\ell)} \in \mathbb{R}^{n_{\ell-1} \times n_\ell}\) y el vector de sesgos \(\mathbf{b}^{(\ell)} \in \mathbb{R}^{n_\ell}\). La capa \(L\) es la capa de salida y las capas \(1, \ldots, L-1\) son capas ocultas. El número total de parámetros es \(\sum_{\ell=1}^L (n_{\ell-1} \cdot n_\ell + n_\ell)\), que puede escalar rápidamente con la profundidad y el ancho de la red.
La elección de la arquitectura (número de capas y unidades por capa) es uno de los hiperparámetros más importantes del diseño de una red. Redes más profundas pueden aprender representaciones más complejas, pero son más difíciles de entrenar y más propensas al sobreajuste con conjuntos de datos pequeños. Redes más anchas tienen mayor capacidad memorística pero son menos eficientes en términos de parámetros por unidad de complejidad. En la práctica, la arquitectura se elige mediante validación cruzada o mediante heurística basada en el dominio del problema.
3.2 Propagación hacia adelante
Dada una matriz de datos \(\mathbf{X} \in \mathbb{R}^{m \times n_0}\) con \(m\) ejemplos, la propagación hacia adelante (forward pass) calcula la predicción de la red aplicando las transformaciones de cada capa en orden. Se define \(\mathbf{A}^{(0)} = \mathbf{X}\) y para cada capa \(\ell = 1, \ldots, L\):
\[\mathbf{z}^{(\ell)} = \mathbf{A}^{(\ell-1)} \mathbf{W}^{(\ell)} + \mathbf{b}^{(\ell)} \tag{4}\]
\[\mathbf{A}^{(\ell)} = g^{(\ell)}(\mathbf{z}^{(\ell)}) \tag{5}\]
donde \(g^{(\ell)}\) es la función de activación de la capa \(\ell\). Para las capas ocultas se usa típicamente ReLU o tanh; para la capa de salida en clasificación binaria se usa sigmoide \(\sigma\) (que produce probabilidades en \((0,1)\)) y para clasificación multiclase se usa softmax \(\text{softmax}(\mathbf{z})_k = e^{z_k}/\sum_j e^{z_j}\). La predicción final es \(\hat{\mathbf{Y}} = \mathbf{A}^{(L)}\).
3.3 Teorema de aproximación universal
El resultado fundamental que justifica el poder de las redes neuronales fue demostrado independientemente por Cybenko (1989) para activaciones sigmoideas (Cybenko 1989) y por Hornik, Stinchcombe y White para una clase más amplia de activaciones no polinomiales (Hornik et al. 1989):
Teorema (Aproximación Universal). Sea \(g\) una función de activación continua y no polinomial. Para cualquier función continua \(f: [0,1]^d \to \mathbb{R}\) y cualquier \(\varepsilon > 0\), existe una red neuronal de una sola capa oculta con suficientes unidades que aproxima \(f\) con error máximo \(\varepsilon\) en todo su dominio.
Este teorema es de existencia, no constructivo: garantiza que la red puede aproximar cualquier función, pero no dice como encontrar los pesos correctos. Tampoco dice cuantas unidades se necesitan (que puede ser exponencialmente grande), ni garantiza que el algoritmo de entrenamiento las encuentre. La profundidad importa precisamente porque redes profundas pueden representar ciertas funciones con exponencialmente menos unidades que redes superficiales, una propiedad conocida como ventaja de profundidad.
Mostrar código
import numpy as np
import plotly.graph_objects as go
layer_sizes = [4, 5, 4, 3]
layer_labels = ["Entrada (4)", "Oculta 1 (5)", "Oculta 2 (4)", "Salida (3)"]
layer_colors = ["#1f77b4", "#2ca02c", "#2ca02c", "#ff7f0e"]
n_layers = len(layer_sizes)
# Compute neuron positions
x_positions = np.linspace(0.1, 0.9, n_layers)
neuron_coords = []
for l, n in enumerate(layer_sizes):
ys = np.linspace(0.1, 0.9, n)
neuron_coords.append(list(zip([x_positions[l]] * n, ys)))
fig = go.Figure()
# Draw connections (lines between layers)
for l in range(n_layers - 1):
for (x0, y0) in neuron_coords[l]:
for (x1, y1) in neuron_coords[l + 1]:
fig.add_trace(go.Scatter(
x=[x0, x1], y=[y0, y1],
mode="lines",
line=dict(color="rgba(150,150,150,0.25)", width=0.8),
showlegend=False, hoverinfo="skip"
))
# Draw neurons
for l, (coords, color, label) in enumerate(zip(neuron_coords, layer_colors, layer_labels)):
xs = [c[0] for c in coords]
ys = [c[1] for c in coords]
fig.add_trace(go.Scatter(
x=xs, y=ys,
mode="markers",
marker=dict(color=color, size=28, line=dict(width=2, color="white"),
symbol="circle"),
name=label,
hovertemplate=f"{label}<extra></extra>"
))
# Weight matrix dimension annotations between layers
weight_dims = [
("W¹: 4x5", 0.31, 0.96),
("W²: 5x4", 0.53, 0.96),
("W³: 4x3", 0.73, 0.96),
]
for text, xpos, ypos in weight_dims:
fig.add_annotation(
x=xpos, y=ypos, text=f"<b>{text}</b>",
xref="paper", yref="paper",
showarrow=False,
font=dict(size=12, color="#555555"),
bgcolor="rgba(240,240,240,0.85)",
bordercolor="#aaaaaa", borderwidth=1
)
# Layer labels at bottom
for l, (xp, label) in enumerate(zip(x_positions, layer_labels)):
fig.add_annotation(
x=xp, y=-0.08, text=f"<b>{label}</b>",
xref="paper", yref="paper",
showarrow=False,
font=dict(size=12, color=layer_colors[l])
)
fig.update_layout(
title="Arquitectura MLP: 4 - 5 - 4 - 3",
xaxis=dict(visible=False, range=[-0.05, 1.05]),
yaxis=dict(visible=False, range=[-0.15, 1.1]),
height=480, width=680,
showlegend=True,
legend=dict(x=1.02, y=0.5),
plot_bgcolor="white"
)
fig.show()3.4 Ejemplo a mano: una red que resuelve XOR
Una capa oculta basta para resolver el XOR que el perceptrón no puede. Con activaciones sigmoide y pesos elegidos a mano, la neurona \(h_1\) calcula un OR, la \(h_2\) un NAND y la salida un AND de ambas:
\[ h_1 = \sigma(20x_1 + 20x_2 - 10), \qquad h_2 = \sigma(-20x_1 - 20x_2 + 30), \qquad \hat{y} = \sigma(20h_1 + 20h_2 - 30). \]
| \(x_1\) | \(x_2\) | \(h_1\) (OR) | \(h_2\) (NAND) | \(\hat{y}\) | XOR |
|---|---|---|---|---|---|
| 0 | 0 | 0.00 | 1.00 | 0.00 | 0 |
| 0 | 1 | 1.00 | 1.00 | 1.00 | 1 |
| 1 | 0 | 1.00 | 1.00 | 1.00 | 1 |
| 1 | 1 | 1.00 | 0.00 | 0.00 | 0 |
La capa oculta cambia la representación: en el espacio \((h_1, h_2)\) los cuatro puntos son \((0, 1)\), \((1, 1)\), \((1, 1)\) y \((1, 0)\), y ahí sí una recta separa las clases. Esa es la idea central de las redes profundas: cada capa transforma los datos hasta que el problema se vuelve linealmente separable para la última.
3.5 Ejemplo a mano: un paso de retropropagación
Una red mínima con una entrada, una neurona oculta y una salida, ambas sigmoides: \(h = \sigma(w_1 x + b_1)\) y \(\hat{y} = \sigma(w_2 h + b_2)\). Con \(x = 1\), \(y = 1\), \(w_1 = 0.5\), \(w_2 = 1\) y sesgos en cero:
Hacia adelante. \(z_1 = 0.5\), \(h = \sigma(0.5) = 0.6225\); \(z_2 = 0.6225\), \(\hat{y} = \sigma(0.6225) = 0.6508\). La pérdida es \(-\ln 0.6508 = 0.4296\).
Hacia atrás. Con entropía cruzada y salida sigmoide, el error de la salida es simplemente \(\delta_2 = \hat{y} - y = -0.3492\). De ahí:
\[ \frac{\partial \mathcal{L}}{\partial w_2} = \delta_2\, h = -0.2174, \qquad \frac{\partial \mathcal{L}}{\partial b_2} = \delta_2 = -0.3492. \]
El error se propaga a la capa oculta multiplicando por el peso que la conecta y por la derivada de su activación, \(\sigma'(z_1) = h(1 - h) = 0.2350\):
\[ \delta_1 = \delta_2\, w_2\, h(1 - h) = -0.3492 \cdot 1 \cdot 0.2350 = -0.0821, \qquad \frac{\partial \mathcal{L}}{\partial w_1} = \delta_1\, x = -0.0821. \]
Actualización con \(\eta = 1\): \(w_2 = 1.217\), \(b_2 = 0.349\), \(w_1 = 0.582\), \(b_1 = 0.082\). La nueva predicción es \(\hat{y} = 0.760\) y la pérdida baja de 0.430 a 0.274. Observa que el gradiente de la capa oculta es unas cuatro veces menor que el de la salida: el factor \(h(1 - h) \le 0.25\) de la sigmoide lo encoge en cada capa, el origen del gradiente desvaneciente que se discute a continuación.
4 Retropropagación del Error
4.1 Función de pérdida
El entrenamiento de la red se formula como la minimización de una función de pérdida \(\mathcal{L}\) que mide la discrepancia entre las predicciones del modelo y los valores reales. Para clasificación binaria, la pérdida de entropía cruzada (cross-entropy) es la elección estándar, derivada del principio de máxima verosimilitud bajo el modelo de Bernoulli:
\[\mathcal{L} = -\frac{1}{m} \sum_{i=1}^m \left[ y^{(i)} \log \hat{y}^{(i)} + (1-y^{(i)}) \log(1-\hat{y}^{(i)}) \right] \tag{6}\]
donde \(\hat{y}^{(i)} = \sigma(z^{(i)}_L)\) es la probabilidad predicha para el ejemplo \(i\). Esta pérdida es convexa como función de la capa de salida (aunque no convexa en los pesos de todas las capas), y tiene la propiedad de que su gradiente respecto a la entrada de la capa de salida es simplemente \(\hat{y} - y\), lo que simplifica las derivaciones del gradiente.
Para minimizar \(\mathcal{L}\), se usa descenso de gradiente, que requiere calcular \(\nabla_{\mathbf{W}^{(\ell)}} \mathcal{L}\) para cada capa \(\ell\). El algoritmo de retropropagación (Rumelhart et al. 1986) computa estos gradientes de forma eficiente mediante la regla de la cadena, propagando el error desde la capa de salida hacia las capas iniciales. Su complejidad computacional es proporcional a la del paso hacia adelante, lo que lo hace tractable incluso para redes con millones de parámetros.
4.2 Regla de la cadena — derivación capa a capa
Definimos la señal de error en la capa \(\ell\) como \(\boldsymbol{\delta}^{(\ell)} = \frac{\partial \mathcal{L}}{\partial \mathbf{z}^{(\ell)}}\). Esta cantidad captura cuanto contribuye la activación pre-lineal de la capa \(\ell\) a la pérdida total. La recurrencia de retropropagación se establece de la siguiente manera. Para la capa de salida \(L\) con sigmoide y pérdida cross-entropy:
\[\boldsymbol{\delta}^{(L)} = \hat{\mathbf{Y}} - \mathbf{Y} \tag{7}\]
Para las capas ocultas \(\ell < L\), aplicando la regla de la cadena en forma matricial:
donde \(\odot\) denota el producto elemento a elemento (producto de Hadamard) y \(g'^{(\ell)}\) es la derivada de la función de activación de la capa \(\ell\). Los gradientes de los parámetros son:
\[\nabla_{\mathbf{W}^{(\ell)}} \mathcal{L} = \frac{1}{m} \mathbf{A}^{(\ell-1)\top} \boldsymbol{\delta}^{(\ell)}, \quad \nabla_{\mathbf{b}^{(\ell)}} \mathcal{L} = \frac{1}{m} \sum_{i=1}^m \boldsymbol{\delta}^{(\ell)}_i \tag{9}\]
Esta derivación revela por que la profundidad es un desafio: el gradiente de la pérdida respecto a los pesos de la primera capa es el producto de \(L\) matrices jacobianas \(g'^{(\ell)}\). Si cada una tiene norma espectral menor que 1 (como ocurre con sigmoid o tanh en la mayor parte de su dominio), el gradiente decrece exponencialmente, impidiendo el aprendizaje en las capas profundas.
Mostrar código
import numpy as np
import plotly.graph_objects as go
rng = np.random.default_rng(0)
n_layers = 6
layer_size = 64
n_samples = 100
# Simulate forward + backward pass for sigmoid and ReLU
def simulate_grad_magnitudes(activation, seed=0):
rng = np.random.default_rng(seed)
# Initialize weights with small scale
weights = [rng.normal(0, 0.5, (layer_size, layer_size)) for _ in range(n_layers)]
biases = [np.zeros(layer_size) for _ in range(n_layers)]
X = rng.normal(0, 1, (n_samples, layer_size))
# Forward pass - store pre-activations
zs = []
A = X
for W, b in zip(weights, biases):
Z = A @ W + b
zs.append(Z)
if activation == "sigmoid":
A = 1.0 / (1.0 + np.exp(-np.clip(Z, -500, 500)))
else:
A = np.maximum(0, Z)
# Backward pass - track gradient magnitudes
grad_mags = []
delta = rng.normal(0, 1, (n_samples, layer_size)) # simulated output gradient
for l in range(n_layers - 1, -1, -1):
Z = zs[l]
if activation == "sigmoid":
s = 1.0 / (1.0 + np.exp(-np.clip(Z, -500, 500)))
act_grad = s * (1 - s) # max 0.25
else:
act_grad = (Z > 0).astype(float)
delta = delta * act_grad
grad_mags.append(float(np.mean(np.abs(delta))))
delta = delta @ weights[l].T
grad_mags.reverse()
return grad_mags
sig_mags = simulate_grad_magnitudes("sigmoid")
relu_mags = simulate_grad_magnitudes("relu")
layer_indices = list(range(1, n_layers + 1))
fig = go.Figure()
fig.add_trace(go.Scatter(
x=layer_indices, y=sig_mags,
mode="lines+markers",
name="Sigmoid",
line=dict(color="#1f77b4", width=2.5),
marker=dict(size=9, color="#1f77b4")
))
fig.add_trace(go.Scatter(
x=layer_indices, y=relu_mags,
mode="lines+markers",
name="ReLU",
line=dict(color="#ff7f0e", width=2.5, dash="dash"),
marker=dict(size=9, color="#ff7f0e", symbol="diamond")
))
fig.update_layout(
title="Gradiente desvaneciente: Sigmoid vs ReLU",
xaxis=dict(title="Indice de capa (desde la salida)", tickvals=layer_indices),
yaxis=dict(title="Magnitud media del gradiente", type="log"),
legend=dict(x=0.02, y=0.98),
height=420, width=660
)
fig.show()Mostrar código
import numpy as np
import plotly.graph_objects as go
from sklearn.datasets import make_classification
rng = np.random.default_rng(42)
X_cls, y_cls = make_classification(n_samples=80, n_features=2, n_redundant=0,
n_informative=2, random_state=42)
# Fix all other parameters, vary w1 and w2
def bce_loss(w1, w2, X, y, w_fixed=0.0, b_fixed=0.0):
eps = 1e-12
z = X[:, 0] * w1 + X[:, 1] * w2 + b_fixed
yhat = 1.0 / (1.0 + np.exp(-np.clip(z, -500, 500)))
yhat = np.clip(yhat, eps, 1 - eps)
return -np.mean(y * np.log(yhat) + (1 - y) * np.log(1 - yhat))
w1_range = np.linspace(-3, 3, 60)
w2_range = np.linspace(-3, 3, 60)
W1, W2 = np.meshgrid(w1_range, w2_range)
Z_loss = np.array([[bce_loss(w1, w2, X_cls, y_cls) for w1 in w1_range] for w2 in w2_range])
# Find approximate minimum
min_idx = np.unravel_index(np.argmin(Z_loss), Z_loss.shape)
w1_min = w1_range[min_idx[1]]
w2_min = w2_range[min_idx[0]]
loss_min = Z_loss[min_idx]
fig = go.Figure()
fig.add_trace(go.Surface(
x=W1, y=W2, z=Z_loss,
colorscale="Viridis",
contours=dict(z=dict(show=True, usecolormap=True, highlightcolor="white",
project=dict(z=True))),
opacity=0.88, showscale=True,
colorbar=dict(title="BCE Loss", len=0.6)
))
fig.add_trace(go.Scatter3d(
x=[w1_min], y=[w2_min], z=[loss_min],
mode="markers",
marker=dict(size=8, color="red", symbol="circle"),
name="Minimo"
))
fig.update_layout(
title="Superficie de perdida: entropia cruzada binaria",
scene=dict(
xaxis_title="w1",
yaxis_title="w2",
zaxis_title="BCE Loss",
camera=dict(eye=dict(x=1.6, y=-1.6, z=1.0))
),
height=520, width=680,
legend=dict(x=0.02, y=0.98)
)
fig.show()5 Optimización
5.1 Descenso de gradiente estocástico (SGD)
El descenso de gradiente estocástico (Stochastic Gradient Descent, SGD) es el algoritmo base de optimización para el entrenamiento de redes neuronales. En lugar de calcular el gradiente exacto sobre todo el conjunto de entrenamiento (lo que sería computacionalmente prohibitivo para conjuntos grandes), se calcula el gradiente sobre un mini-lote aleatorio de \(B\) ejemplos:
\[\mathbf{W} \leftarrow \mathbf{W} - \eta \nabla_\mathbf{W} \mathcal{L}_{\text{mini-lote}} \tag{10}\]
La tasa de aprendizaje \(\eta\) controla el tamaño de cada paso en el espacio de parámetros. Si es demasiado grande, el algoritmo puede divergir u oscilar alrededor del mínimo; si es demasiado pequeña, el entrenamiento es lento. El tamaño del mini-lote \(B\) introduce un trade-off entre precisión del gradiente (lotes grandes) y eficiencia computacional y regularización implícita (lotes pequeños). El ruido del gradiente introducido por el muestreo aleatorio actua como regularizador: impide que el optimizador converja a minimos excesivamente agudos del paisaje de pérdida, lo que favorece soluciones con mejor generalización.
5.2 Momentum
El SGD con momentum introduce una variable de velocidad \(\mathbf{v}\) que acumula gradientes pasados, similar a un objeto con inercia que acelera en direcciones de descenso consistente y amortigua las oscilaciones:
\[\mathbf{v} \leftarrow \beta \mathbf{v} + (1-\beta) \nabla \mathcal{L} \tag{11}\] \[\mathbf{W} \leftarrow \mathbf{W} - \eta \mathbf{v} \tag{12}\]
Con \(\beta\) típicamente en \([0.85, 0.99]\), el momentum efectivo que llega al parámetro es la suma geometricamente ponderada de gradientes pasados, lo que suaviza la trayectoria de optimización y permite tasas de aprendizaje más grandes. En paisajes con curvatura muy anisotrópica (donde algunas dimensiones tienen gradientes mucho mayores que otras), el momentum reduce las oscilaciones en las dimensiones de alta curvatura y acelera el progreso en las de baja curvatura.
5.3 Adam
El optimizador Adam (Adaptive Moment Estimation) (Kingma y Ba 2015) combina el momentum con la adaptación de la tasa de aprendizaje por coordenada, usando estimaciones de primer y segundo momento del gradiente:
\[\mathbf{m} \leftarrow \beta_1 \mathbf{m} + (1-\beta_1) \nabla \mathcal{L} \tag{13}\] \[\mathbf{v} \leftarrow \beta_2 \mathbf{v} + (1-\beta_2) (\nabla \mathcal{L})^2 \tag{14}\] \[\hat{\mathbf{m}} = \mathbf{m}/(1-\beta_1^t), \quad \hat{\mathbf{v}} = \mathbf{v}/(1-\beta_2^t) \tag{15}\] \[\mathbf{W} \leftarrow \mathbf{W} - \eta \hat{\mathbf{m}} / (\sqrt{\hat{\mathbf{v}}} + \epsilon) \tag{16}\]
Los términos de corrección de sesgo \((1-\beta_k^t)\) compensan el hecho de que al inicio del entrenamiento \(\mathbf{m}\) y \(\mathbf{v}\) están inicializados en cero y subestiman los momentos verdaderos. Los valores típicos son \(\beta_1 = 0.9\), \(\beta_2 = 0.999\) y \(\epsilon = 10^{-8}\). La división por \(\sqrt{\hat{\mathbf{v}}} + \epsilon\) normaliza el gradiente por su magnitud típica en cada coordenada, lo que hace al optimizador invariante a la escala del gradiente y permite usar la misma tasa de aprendizaje para todos los parámetros.
Mostrar código
import numpy as np
import plotly.graph_objects as go
# Beale function: minimum at (3, 0.5)
def beale(x, y):
t1 = (1.5 - x + x * y)**2
t2 = (2.25 - x + x * y**2)**2
t3 = (2.625 - x + x * y**3)**2
return t1 + t2 + t3
def beale_grad(x, y):
# Partial derivatives
f1 = 1.5 - x + x * y
f2 = 2.25 - x + x * y**2
f3 = 2.625 - x + x * y**3
df1_dx = -1 + y
df1_dy = x
df2_dx = -1 + y**2
df2_dy = 2 * x * y
df3_dx = -1 + y**3
df3_dy = 3 * x * y**2
gx = 2 * f1 * df1_dx + 2 * f2 * df2_dx + 2 * f3 * df3_dx
gy = 2 * f1 * df1_dy + 2 * f2 * df2_dy + 2 * f3 * df3_dy
return np.array([gx, gy])
# Starting point
x0 = np.array([-1.5, -0.5])
n_steps = 200
def run_sgd(x0, lr=0.002, n_steps=200):
x = x0.copy().astype(float)
path = [x.copy()]
for _ in range(n_steps):
g = beale_grad(x[0], x[1])
x = x - lr * g
x = np.clip(x, -4.5, 4.5)
path.append(x.copy())
return np.array(path)
def run_momentum(x0, lr=0.002, beta=0.9, n_steps=200):
x = x0.copy().astype(float)
v = np.zeros(2)
path = [x.copy()]
for _ in range(n_steps):
g = beale_grad(x[0], x[1])
v = beta * v + (1 - beta) * g
x = x - lr * v
x = np.clip(x, -4.5, 4.5)
path.append(x.copy())
return np.array(path)
def run_adam(x0, lr=0.05, beta1=0.9, beta2=0.999, eps=1e-8, n_steps=200):
x = x0.copy().astype(float)
m = np.zeros(2)
v = np.zeros(2)
path = [x.copy()]
for t in range(1, n_steps + 1):
g = beale_grad(x[0], x[1])
m = beta1 * m + (1 - beta1) * g
v = beta2 * v + (1 - beta2) * g**2
m_hat = m / (1 - beta1**t)
v_hat = v / (1 - beta2**t)
x = x - lr * m_hat / (np.sqrt(v_hat) + eps)
x = np.clip(x, -4.5, 4.5)
path.append(x.copy())
return np.array(path)
path_sgd = run_sgd(x0)
path_mom = run_momentum(x0)
path_adam = run_adam(x0)
# Contour grid
xg = np.linspace(-4.5, 4.5, 200)
yg = np.linspace(-4.5, 4.5, 200)
XG, YG = np.meshgrid(xg, yg)
ZG = beale(XG, YG)
ZG_log = np.log1p(ZG)
fig = go.Figure()
fig.add_trace(go.Contour(
x=xg, y=yg, z=ZG_log,
colorscale="Blues",
contours=dict(coloring="heatmap", showlabels=False),
showscale=False, opacity=0.7
))
colors = {"SGD": "#d62728", "Momentum": "#ff7f0e", "Adam": "#2ca02c"}
paths = {"SGD": path_sgd, "Momentum": path_mom, "Adam": path_adam}
for name, path in paths.items():
fig.add_trace(go.Scatter(
x=path[:, 0], y=path[:, 1],
mode="lines+markers",
name=name,
line=dict(color=colors[name], width=2),
marker=dict(size=3, color=colors[name])
))
fig.add_trace(go.Scatter(
x=[path[0, 0]], y=[path[0, 1]],
mode="markers",
marker=dict(size=10, color=colors[name], symbol="star"),
showlegend=False
))
# Mark global minimum
fig.add_trace(go.Scatter(
x=[3.0], y=[0.5],
mode="markers",
marker=dict(size=14, color="yellow", symbol="star", line=dict(width=1.5, color="black")),
name="Minimo (3, 0.5)"
))
fig.update_layout(
title="Trayectorias de optimizacion sobre la funcion de Beale",
xaxis=dict(title="w1", range=[-4.5, 4.5]),
yaxis=dict(title="w2", range=[-4.5, 4.5]),
height=500, width=640,
legend=dict(x=0.01, y=0.99)
)
fig.show()7 Implementación desde Cero
Para consolidar la comprensión matemática de los algoritmos descritos, se presenta a continuación una implementación completa de un MLP en Python puro con numpy, sin utilizar ninguna biblioteca de aprendizaje automático para el entrenamiento. La implementación sigue exactamente las ecuaciones de propagación hacia adelante (Ec. 64.4, Ec. 64.5) y retropropagación (Ec. 64.7, Ec. 64.8, Ec. 64.9). La inicialización de pesos usa la inicialización de He, que escala los pesos por \(\sqrt{2/n_{\ell-1}}\) para capas ReLU, lo que asegura que la varianza de las activaciones se preserve a través de las capas en el paso hacia adelante.
Esta implementación sirve como puente entre la teoría matemática y las bibliotecas de alto nivel como PyTorch o TensorFlow. En la práctica, esas bibliotecas ofrecen diferenciación automática, ejecución en GPU y múltiples optimizadores, pero la estructura lógica es idéntica a la presentada aquí. Entender la implementación desde cero es fundamental para diagnosticar problemas de entrenamiento, interpretar el comportamiento de la red y adaptar el modelo a casos de uso no estándar.
Mostrar código
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from sklearn.datasets import make_moons
# MLP implementation
class MLP:
def __init__(self, layer_dims, lr=0.01, seed=42):
rng = np.random.default_rng(seed)
self.lr = lr
self.params = {}
self.L = len(layer_dims) - 1
for i in range(1, self.L + 1):
fan_in = layer_dims[i-1]
self.params[f"W{i}"] = rng.normal(0, np.sqrt(2.0/fan_in),
(layer_dims[i-1], layer_dims[i]))
self.params[f"b{i}"] = np.zeros((1, layer_dims[i]))
def relu(self, z):
return np.maximum(0, z)
def relu_grad(self, z):
return (z > 0).astype(float)
def sigmoid(self, z):
return 1.0 / (1.0 + np.exp(-np.clip(z, -500, 500)))
def forward(self, X):
self.cache = {"A0": X}
A = X
for i in range(1, self.L + 1):
Z = A @ self.params[f"W{i}"] + self.params[f"b{i}"]
self.cache[f"Z{i}"] = Z
A = self.relu(Z) if i < self.L else self.sigmoid(Z)
self.cache[f"A{i}"] = A
return A
def backward(self, y):
m = y.shape[0]
grads = {}
dA = self.cache[f"A{self.L}"] - y.reshape(-1, 1)
for i in range(self.L, 0, -1):
dZ = dA if i == self.L else dA * self.relu_grad(self.cache[f"Z{i}"])
grads[f"W{i}"] = self.cache[f"A{i-1}"].T @ dZ / m
grads[f"b{i}"] = dZ.mean(axis=0, keepdims=True)
dA = dZ @ self.params[f"W{i}"].T
return grads
def step(self, grads):
for i in range(1, self.L + 1):
self.params[f"W{i}"] -= self.lr * grads[f"W{i}"]
self.params[f"b{i}"] -= self.lr * grads[f"b{i}"]
def loss(self, yhat, y):
eps = 1e-12
yhat = np.clip(yhat, eps, 1 - eps)
return -np.mean(y * np.log(yhat.ravel()) + (1 - y) * np.log(1 - yhat.ravel()))
def predict(self, X):
return (self.forward(X) > 0.5).ravel().astype(int)
# Dataset
X_moons, y_moons = make_moons(n_samples=400, noise=0.2, random_state=42)
# Train
mlp = MLP([2, 16, 16, 1], lr=0.08, seed=42)
losses = []
accuracies = []
n_epochs = 300
for epoch in range(n_epochs):
yhat = mlp.forward(X_moons)
l = mlp.loss(yhat, y_moons)
losses.append(l)
acc = float(np.mean(mlp.predict(X_moons) == y_moons))
accuracies.append(acc)
grads = mlp.backward(y_moons)
mlp.step(grads)
# Store trained mlp for next figure
trained_mlp = mlp
fig = make_subplots(rows=1, cols=2,
subplot_titles=["Perdida de entrenamiento", "Precision de entrenamiento"])
fig.add_trace(go.Scatter(
x=list(range(n_epochs)), y=losses,
mode="lines", name="BCE Loss",
line=dict(color="#1f77b4", width=2.5)
), row=1, col=1)
fig.add_trace(go.Scatter(
x=list(range(n_epochs)), y=accuracies,
mode="lines", name="Accuracy",
line=dict(color="#ff7f0e", width=2.5)
), row=1, col=2)
fig.update_xaxes(title_text="Epoca")
fig.update_yaxes(title_text="BCE Loss", row=1, col=1)
fig.update_yaxes(title_text="Precision", range=[0, 1.05], row=1, col=2)
fig.update_layout(height=380, width=720, showlegend=False,
title_text="Curvas de aprendizaje: MLP en make_moons")
fig.show()make_moons (n=400, noise=0.2) con arquitectura [2, 16, 16, 1] y 300 épocas. El panel izquierdo muestra la pérdida de entropía cruzada y el panel derecho la precisión de clasificación. Ambas curvas muestran convergencia estable sin oscilaciones.
Mostrar código
import numpy as np
import plotly.graph_objects as go
# Use trained_mlp from previous cell
h = 0.03
x_min, x_max = X_moons[:, 0].min() - 0.4, X_moons[:, 0].max() + 0.4
y_min, y_max = X_moons[:, 1].min() - 0.4, X_moons[:, 1].max() + 0.4
xx, yy = np.meshgrid(np.arange(x_min, x_max, h), np.arange(y_min, y_max, h))
grid_points = np.c_[xx.ravel(), yy.ravel()]
probs = trained_mlp.forward(grid_points).reshape(xx.shape)
final_acc = float(np.mean(trained_mlp.predict(X_moons) == y_moons))
fig = go.Figure()
fig.add_trace(go.Contour(
x=np.arange(x_min, x_max, h),
y=np.arange(y_min, y_max, h),
z=probs,
colorscale=[[0, "#aec7e8"], [0.5, "#f7f7f7"], [1, "#ffbb78"]],
showscale=True,
colorbar=dict(title="P(clase=1)", len=0.7),
contours=dict(
start=0.5, end=0.5, size=0,
coloring="fill",
showlabels=False
),
line=dict(color="white", width=2.5),
opacity=0.85
))
colors_cls = {0: "#1f77b4", 1: "#ff7f0e"}
labels_cls = {0: "Clase 0", 1: "Clase 1"}
for cls in [0, 1]:
mask = y_moons == cls
fig.add_trace(go.Scatter(
x=X_moons[mask, 0], y=X_moons[mask, 1],
mode="markers",
marker=dict(color=colors_cls[cls], size=7,
line=dict(width=1, color="black"),
symbol="circle" if cls == 0 else "diamond"),
name=labels_cls[cls]
))
fig.update_layout(
title=f"Frontera de decision del MLP | Precision final: {final_acc:.1%}",
xaxis_title="x1",
yaxis_title="x2",
height=480, width=620,
legend=dict(x=0.01, y=0.99)
)
fig.show()make_moons. Los colores de fondo muestran la probabilidad predicha por la red para cada región del espacio de características. La linea blanca marca la isocurva de probabilidad 0.5 (frontera de decisión). El modelo captura la forma no lineal de las dos lunas con alta precisión.
8 Redes Convolucionales (CNN)
8.1 Motivación
Las redes completamente conectadas tratan cada pixel de una imagen como una entrada independiente, ignorando por completo la estructura espacial. Para una imagen en escala de grises de \(32 \times 32\) píxeles, la capa de entrada tiene 1024 unidades. Si la primera capa oculta tiene 512 unidades, solo esta capa requiere \(1024 \times 512 = 524{,}288\) parámetros. Para imágenes en color de \(256 \times 256\) píxeles, una sola capa con 512 unidades necesitaría más de 100 millones de parámetros. Además, si la imagen se traslada un pixel a la derecha, la red la ve como una entrada completamente diferente: no tiene ninguna noción de invarianza a la traslación, que es una propiedad fundamental de las imágenes naturales.
Las Redes Convolucionales (CNN) resuelven estos problemas mediante tres principios fundamentales (LeCun et al. 1998). El primero es la conectividad local: cada neurona de una capa convolucional se conecta solo a una región pequeña de la capa anterior (el campo receptivo), reduciendo drasticamente el número de conexiones. El segundo es el compartimiento de pesos: el mismo filtro (kernel) se aplica en todas las posiciones espaciales de la imagen, por lo que los parámetros del filtro se comparten entre todas las posiciones. El tercero es la equivarianza a la traslación: si el objeto se mueve en la imagen, la representación aprendida se mueve de manera correspondiente, lo que permite al modelo reconocer el mismo patrón independientemente de donde aparezca.
8.2 La operación de convolución
La operación fundamental de las CNN es la correlación cruzada 2D entre una imagen de entrada \(I\) y un filtro \(K\) de tamaño \(k \times k\). Para cada posición \((i, j)\) en la salida:
\[(I \star K)[i,j] = \sum_{u=0}^{k-1} \sum_{v=0}^{k-1} I[i+u, j+v] \cdot K[u,v] \tag{17}\]
Si la entrada tiene dimensiones \(H \times W\) y el filtro es \(k \times k\), la salida tiene dimensiones \((H-k+1) \times (W-k+1)\) (sin relleno). Es importante notar que en la literatura de deep learning se llama “convolución” a lo que tecnicamente es correlación cruzada: la verdadera convolución matemática voltea el kernel antes de aplicarlo, pero dado que los pesos se aprenden durante el entrenamiento, esta distinción no tiene consecuencias prácticas (Goodfellow et al. 2016).
Mostrar código
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
rng = np.random.default_rng(7)
input_img = rng.integers(0, 16, (7, 7)).astype(float)
# Sobel-like horizontal edge detection kernel
kernel = np.array([
[-1, -2, -1],
[ 0, 0, 0],
[ 1, 2, 1]
], dtype=float)
# Manual convolution (no scipy)
H, W = input_img.shape
kH, kW = kernel.shape
out_H = H - kH + 1
out_W = W - kW + 1
output = np.zeros((out_H, out_W))
for i in range(out_H):
for j in range(out_W):
output[i, j] = np.sum(input_img[i:i+kH, j:j+kW] * kernel)
def make_heatmap_annotations(matrix):
annotations = []
for i in range(matrix.shape[0]):
for j in range(matrix.shape[1]):
annotations.append(dict(
x=j, y=i,
text=f"{matrix[i, j]:.0f}",
showarrow=False,
font=dict(size=11, color="black"),
xanchor="center", yanchor="middle"
))
return annotations
fig = make_subplots(rows=1, cols=3,
subplot_titles=["Entrada (7x7)", "Kernel Sobel (3x3)", "Salida (5x5)"],
horizontal_spacing=0.12)
fig.add_trace(go.Heatmap(
z=input_img[::-1], colorscale="Blues",
showscale=False, xgap=1, ygap=1
), row=1, col=1)
fig.add_trace(go.Heatmap(
z=kernel[::-1], colorscale="RdBu",
showscale=False, xgap=2, ygap=2
), row=1, col=2)
fig.add_trace(go.Heatmap(
z=output[::-1], colorscale="Greens",
showscale=False, xgap=1, ygap=1
), row=1, col=3)
# Annotations for each panel
for j, (matrix, xref, yref) in enumerate(zip(
[input_img, kernel, output],
["x1", "x2", "x3"],
["y1", "y2", "y3"]
), start=1):
for row_i in range(matrix.shape[0]):
for col_i in range(matrix.shape[1]):
val = matrix[matrix.shape[0]-1-row_i, col_i]
fig.add_annotation(
x=col_i, y=row_i,
text=f"{val:.0f}",
xref=xref, yref=yref,
showarrow=False,
font=dict(size=10, color="black"),
xanchor="center", yanchor="middle"
)
fig.update_layout(height=320, width=700,
title_text="Convolucion 2D: deteccion de bordes")
fig.update_xaxes(showticklabels=False)
fig.update_yaxes(showticklabels=False)
fig.show()8.3 Capas de pooling
Las capas de pooling reducen las dimensiones espaciales de los mapas de características, introduciendo invarianza local a pequeñas traslaciones y reduciendo el número de parámetros en las capas subsiguientes. El max-pooling sobre una ventana de \(p \times p\) retiene el valor máximo de cada ventana, capturando la presencia del patrón más activado en cada región. El average-pooling calcula el promedio, produciendo una representación más suave. Para una entrada de tamaño \(H \times W\), el max-pooling con ventana \(p \times p\) y stride \(p\) produce una salida de tamaño \(\lfloor H/p \rfloor \times \lfloor W/p \rfloor\).
La operación de max-pooling tiene una derivada simple: en el paso hacia atras, el gradiente se propaga solo a la posición que fue el máximo en la ventana (las demas reciben gradiente cero). Esto es eficiente computacionalmente y favorece la especialización de las neuronas: cada unidad del mapa de características aprende a detectar un patrón específico en alguna región del campo receptivo.
8.4 Arquitectura CNN clásica: LeNet-5
La arquitectura LeNet-5 (LeCun et al. 1998) establece el patrón básico de las CNN modernas: bloques de convolución seguidos de pooling, finalizados por capas completamente conectadas para la clasificación. El patrón general es:
\[\text{Conv} \to \text{Pool} \to \text{Conv} \to \text{Pool} \to \text{FC} \to \text{FC} \to \text{Softmax}\]
Las capas convolucionales aprenden filtros que detectan características locales (bordes, texturas, formas) mientras que las capas completamente conectadas integran estas características espaciales para la decisión final. Las arquitecturas modernas como VGG, ResNet e Inception siguen este mismo principio, pero con mayor profundidad, conexiones residuales y bloques de atención.
Mostrar código
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
# Synthetic circle image
size = 28
img = np.zeros((size, size), dtype=float)
cx, cy, r = 13, 13, 9
for i in range(size):
for j in range(size):
if (i - cy)**2 + (j - cx)**2 <= r**2:
img[i, j] = 1.0
# Hand-crafted kernels
kernels = {
"Borde horizontal": np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=float),
"Borde vertical": np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=float),
"Diagonal": np.array([[2, 1, -1], [1, 0, -1], [-1, -1, -2]], dtype=float),
"Blob (Lap.)": np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=float),
}
# Manual convolution
def convolve2d_manual(image, kernel):
H, W = image.shape
kH, kW = kernel.shape
out = np.zeros((H - kH + 1, W - kW + 1))
for i in range(out.shape[0]):
for j in range(out.shape[1]):
out[i, j] = np.sum(image[i:i+kH, j:j+kW] * kernel)
return out
feature_maps = {name: convolve2d_manual(img, k) for name, k in kernels.items()}
titles = ["Original"] + list(feature_maps.keys())
fig = make_subplots(rows=1, cols=5, subplot_titles=titles, horizontal_spacing=0.05)
fig.add_trace(go.Heatmap(
z=img[::-1], colorscale="Gray",
showscale=False, xgap=0, ygap=0
), row=1, col=1)
for col, (name, fmap) in enumerate(feature_maps.items(), start=2):
fig.add_trace(go.Heatmap(
z=fmap[::-1], colorscale="RdBu",
showscale=(col == 5), xgap=0, ygap=0,
colorbar=dict(len=0.7, x=1.01) if col == 5 else None
), row=1, col=col)
fig.update_layout(height=250, width=950,
title_text="Mapas de caracteristicas convolucionales")
fig.update_xaxes(showticklabels=False)
fig.update_yaxes(showticklabels=False)
fig.show()9 Regularización en Redes Neuronales
Las redes neuronales con muchos parámetros tienen alta capacidad de memorizar el conjunto de entrenamiento, lo que puede llevar al sobreajuste: el modelo obtiene baja pérdida en entrenamiento pero generaliza pobremente a datos nuevos. La regularización comprende un conjunto de técnicas que reducen la brecha entre el error de entrenamiento y el error de generalización, ya sea penalizando la complejidad del modelo, perturbando el proceso de entrenamiento o limitando la co-adaptacion de las neuronas.
9.1 Regularización L2 (weight decay)
La regularización L2 anade un término de penalización proporcional a la norma cuadrada de los pesos a la función de pérdida:
\[\mathcal{L}_{\text{reg}} = \mathcal{L}_{\text{datos}} + \frac{\lambda}{2} \|\mathbf{W}\|_F^2 \tag{18}\]
donde \(\|\mathbf{W}\|_F^2 = \sum_{i,j} W_{ij}^2\) es la norma de Frobenius y \(\lambda > 0\) es el coeficiente de regularización. El efecto sobre la regla de actualización es una contracción multiplicativa de los pesos en cada paso:
\[\mathbf{W} \leftarrow (1 - \eta\lambda)\mathbf{W} - \eta \nabla_\mathbf{W} \mathcal{L}_{\text{datos}} \tag{19}\]
El factor \((1 - \eta\lambda) < 1\) “decae” los pesos hacia cero en cada iteración, lo que da nombre al método (weight decay). Esta penalización favorece soluciones con pesos pequeños, que tienden a ser más suaves y generalizan mejor. La magnitud de \(\lambda\) controla el trade-off entre ajuste a los datos y suavidad del modelo.
9.2 Dropout
El Dropout (Srivastava et al. 2014) es una técnica de regularización específica de las redes neuronales. Durante el entrenamiento, en cada paso hacia adelante, cada neurona se desactiva de forma aleatoria con probabilidad \(1-p\) (o equivalentemente, se mantiene activa con probabilidad \(p\), típicamente \(p \in [0.5, 0.9]\)). Las neuronas desactivadas no contribuyen a la salida ni reciben actualizaciones de gradiente en ese paso. Durante la inferencia, todas las neuronas están activas, pero sus salidas se escalan por \(p\) (o alternativamente, se dividen por \(p\) durante el entrenamiento, técnica conocida como inverted dropout).
El Dropout puede interpretarse como el entrenamiento implícito de un ensamble exponencialmente grande de sub-redes. Con \(n\) neuronas en total, existen \(2^n\) posibles sub-redes, y en cada paso se entrena una de ellas. En inferencia, la red completa actua como un promedio aproximado de todas estas sub-redes, lo que reduce la varianza del predictor de manera análoga al promediado de ensambles. Esta interpretación de ensamble implícito explica por que el Dropout mejora la generalización de forma tan sistemática.
Mostrar código
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
rng = np.random.default_rng(3)
layers = [3, 4, 4, 2]
x_positions = [0.1, 0.37, 0.63, 0.9]
def make_neuron_coords(layers, x_positions):
coords = []
for l, (n, xp) in enumerate(zip(layers, x_positions)):
ys = np.linspace(0.15, 0.85, n)
coords.append([(xp, float(y)) for y in ys])
return coords
coords = make_neuron_coords(layers, x_positions)
# Dropout mask: 40% of hidden neurons dropped
drop_mask = {}
for l in [1, 2]: # hidden layers
drop_mask[l] = rng.random(len(coords[l])) > 0.4
def draw_network(fig, coords, drop_mask_active, col, title):
layer_colors_full = ["#1f77b4", "#2ca02c", "#2ca02c", "#ff7f0e"]
dropped_color = "#cccccc"
# Connections
for l in range(len(coords) - 1):
for ni, (x0, y0) in enumerate(coords[l]):
for nj, (x1, y1) in enumerate(coords[l+1]):
src_active = (l not in drop_mask_active) or drop_mask_active.get(l, {ni: True})[ni] if isinstance(drop_mask_active.get(l), dict) else True
# Simplified: check if either neuron is dropped
src_dropped = drop_mask_active and l in drop_mask_active and not drop_mask_active[l][ni]
tgt_dropped = drop_mask_active and (l+1) in drop_mask_active and not drop_mask_active[l+1][nj]
is_dropped = src_dropped or tgt_dropped
line_style = dict(
color="rgba(180,180,180,0.15)" if is_dropped else "rgba(120,120,120,0.35)",
width=0.5 if is_dropped else 1.0,
dash="dash" if is_dropped else "solid"
)
fig.add_trace(go.Scatter(
x=[x0, x1], y=[y0, y1], mode="lines",
line=line_style,
showlegend=False, hoverinfo="skip",
xaxis=f"x{col}", yaxis=f"y{col}"
), row=1, col=col)
# Neurons
for l, layer_coords in enumerate(coords):
for ni, (x, y) in enumerate(layer_coords):
is_dropped = drop_mask_active and l in drop_mask_active and not drop_mask_active[l][ni]
color = dropped_color if is_dropped else layer_colors_full[l]
opacity = 0.3 if is_dropped else 1.0
fig.add_trace(go.Scatter(
x=[x], y=[y], mode="markers",
marker=dict(color=color, size=22,
line=dict(width=2, color="white"),
opacity=opacity),
showlegend=False, hoverinfo="skip",
xaxis=f"x{col}", yaxis=f"y{col}"
), row=1, col=col)
fig_do = make_subplots(rows=1, cols=2,
subplot_titles=["Entrenamiento sin Dropout",
"Entrenamiento con Dropout (p=0.6)"],
horizontal_spacing=0.15)
draw_network(fig_do, coords, {}, col=1, title="Sin Dropout")
draw_network(fig_do, coords, drop_mask, col=2, title="Con Dropout")
fig_do.update_xaxes(visible=False)
fig_do.update_yaxes(visible=False)
fig_do.update_layout(
height=380, width=700,
plot_bgcolor="white",
title_text="Esquema de Dropout"
)
fig_do.show()9.3 Batch Normalization
La Normalización por Lote (Batch Normalization) es una técnica que normaliza las pre-activaciones de cada capa dentro del mini-lote de entrenamiento, seguido de una transformación lineal con parámetros aprendibles \(\gamma\) y \(\beta\). Para cada capa \(\ell\) y mini-lote \(\mathcal{B}\) de tamaño \(m\):
\[\mu_B = \frac{1}{m}\sum_{i \in \mathcal{B}} z_i^{(\ell)}, \quad \sigma_B^2 = \frac{1}{m}\sum_{i \in \mathcal{B}} (z_i^{(\ell)} - \mu_B)^2\]
\[\hat{z}^{(\ell)} = \frac{z^{(\ell)} - \mu_B}{\sqrt{\sigma_B^2 + \epsilon}}, \quad \tilde{z}^{(\ell)} = \gamma \hat{z}^{(\ell)} + \beta \tag{20}\]
La normalización estabiliza la distribución de las activaciones a lo largo del entrenamiento (reduciendo el internal covariate shift), lo que permite tasas de aprendizaje más altas y hace al modelo menos sensible a la inicialización de pesos. Los parámetros \(\gamma\) y \(\beta\) son aprendibles y permiten al modelo recuperar la distribución óptima si la normalización no es beneficiosa en alguna capa.
Mostrar código
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from sklearn.datasets import make_moons
X_r, y_r = make_moons(n_samples=400, noise=0.2, random_state=42)
# 80/20 train/val split
perm = np.random.default_rng(0).permutation(len(y_r))
split = int(0.8 * len(y_r))
train_idx, val_idx = perm[:split], perm[split:]
X_tr, y_tr = X_r[train_idx], y_r[train_idx]
X_val, y_val = X_r[val_idx], y_r[val_idx]
class MLPReg:
def __init__(self, layer_dims, lr=0.05, l2=0.0, dropout_keep=1.0, seed=42):
rng = np.random.default_rng(seed)
self.lr = lr
self.l2 = l2
self.dropout_keep = dropout_keep
self.params = {}
self.L = len(layer_dims) - 1
self._rng = rng
for i in range(1, self.L + 1):
fan_in = layer_dims[i-1]
self.params[f"W{i}"] = rng.normal(0, np.sqrt(2.0/fan_in),
(layer_dims[i-1], layer_dims[i]))
self.params[f"b{i}"] = np.zeros((1, layer_dims[i]))
def _sigmoid(self, z):
return 1.0 / (1.0 + np.exp(-np.clip(z, -500, 500)))
def _relu(self, z):
return np.maximum(0, z)
def _relu_grad(self, z):
return (z > 0).astype(float)
def forward(self, X, training=True):
self.cache = {"A0": X}
self.masks = {}
A = X
for i in range(1, self.L + 1):
Z = A @ self.params[f"W{i}"] + self.params[f"b{i}"]
self.cache[f"Z{i}"] = Z
if i < self.L:
A = self._relu(Z)
if training and self.dropout_keep < 1.0:
mask = (self._rng.random(A.shape) < self.dropout_keep).astype(float)
A = A * mask / self.dropout_keep
self.masks[i] = mask
else:
A = self._sigmoid(Z)
self.cache[f"A{i}"] = A
return A
def backward(self, y):
m = y.shape[0]
grads = {}
dA = self.cache[f"A{self.L}"] - y.reshape(-1, 1)
for i in range(self.L, 0, -1):
if i == self.L:
dZ = dA
else:
dZ = dA * self._relu_grad(self.cache[f"Z{i}"])
if self.dropout_keep < 1.0 and i in self.masks:
dZ = dZ * self.masks[i] / self.dropout_keep
grads[f"W{i}"] = self.cache[f"A{i-1}"].T @ dZ / m + self.l2 * self.params[f"W{i}"]
grads[f"b{i}"] = dZ.mean(axis=0, keepdims=True)
dA = dZ @ self.params[f"W{i}"].T
return grads
def step(self, grads):
for i in range(1, self.L + 1):
self.params[f"W{i}"] -= self.lr * grads[f"W{i}"]
self.params[f"b{i}"] -= self.lr * grads[f"b{i}"]
def loss(self, yhat, y):
eps = 1e-12
yhat = np.clip(yhat, eps, 1 - eps)
return -np.mean(y * np.log(yhat.ravel()) + (1 - y) * np.log(1 - yhat.ravel()))
n_epochs = 300
arch = [2, 32, 32, 1]
configs = [
("Sin regularizacion", dict(lr=0.05, l2=0.0, dropout_keep=1.0)),
("L2 lambda=0.01", dict(lr=0.05, l2=0.01, dropout_keep=1.0)),
("Dropout p=0.7", dict(lr=0.05, l2=0.0, dropout_keep=0.7)),
]
results = {}
for name, kwargs in configs:
model = MLPReg(arch, seed=42, **kwargs)
tr_losses, val_losses = [], []
for ep in range(n_epochs):
yhat_tr = model.forward(X_tr, training=True)
grads = model.backward(y_tr)
model.step(grads)
tr_losses.append(model.loss(yhat_tr, y_tr))
yhat_val = model.forward(X_val, training=False)
val_losses.append(model.loss(yhat_val, y_val))
results[name] = (tr_losses, val_losses)
colors_reg = {"Sin regularizacion": "#1f77b4",
"L2 lambda=0.01": "#ff7f0e",
"Dropout p=0.7": "#2ca02c"}
fig = make_subplots(rows=1, cols=3,
subplot_titles=list(results.keys()),
shared_yaxes=True)
for col, (name, (tr_l, val_l)) in enumerate(results.items(), start=1):
color = colors_reg[name]
epochs = list(range(n_epochs))
fig.add_trace(go.Scatter(
x=epochs, y=tr_l, mode="lines", name="Entrenamiento",
line=dict(color=color, width=2.5),
showlegend=(col == 1)
), row=1, col=col)
fig.add_trace(go.Scatter(
x=epochs, y=val_l, mode="lines", name="Validacion",
line=dict(color=color, width=2.5, dash="dash"),
showlegend=(col == 1)
), row=1, col=col)
fig.update_xaxes(title_text="Epoca")
fig.update_yaxes(title_text="BCE Loss", col=1)
fig.update_layout(height=380, width=850,
title_text="Efecto de la regularizacion en la brecha entrenamiento-validacion",
legend=dict(x=0.01, y=0.99))
fig.show()make_moons: sin regularización, con regularización L2 (lambda=0.01) y con Dropout (p=0.7). Las versiones regularizadas muestran una menor brecha entre la pérdida de entrenamiento y validación, lo que indica mejor generalización.
10 Más alla: Tipos de Redes
10.1 Redes Recurrentes (RNN/LSTM)
Las redes completamente conectadas y las CNN asumen que las entradas son independientes entre si. Muchos problemas prácticos, sin embargo, involucran datos secuenciales donde el orden temporal es fundamental: series de tiempo, texto natural, señales de audio, trayectorias. Las Redes Neuronales Recurrentes (RNN) abordan este problema manteniendo un estado oculto \(\mathbf{h}_t\) que actua como memoria de la historia de la secuencia:
\[\mathbf{h}_t = \tanh(\mathbf{W}_h \mathbf{h}_{t-1} + \mathbf{W}_x \mathbf{x}_t + \mathbf{b})\]
El estado oculto \(\mathbf{h}_t\) encapsula información relevante de todos los pasos anteriores \(\mathbf{x}_1, \ldots, \mathbf{x}_t\), permitiendo que la red haga predicciones que dependen del contexto histórico. Sin embargo, las RNN simples sufren de gradiente desvaneciente a través del tiempo: para secuencias largas, los gradientes que fluyen desde el tiempo \(T\) hasta el tiempo \(1\) son el producto de \(T\) matrices jacobianas, que pueden ser exponencialmente pequeñas.
Las redes LSTM (Long Short-Term Memory) (Hochreiter y Schmidhuber 1997) resuelven este problema mediante el mecanismo de compuertas: una celda de memoria \(\mathbf{c}_t\) con flujo de gradiente regulado por compuertas de olvido (\(\mathbf{f}_t\)), entrada (\(\mathbf{i}_t\)) y salida (\(\mathbf{o}_t\)). La compuerta de olvido decide que información de la celda anterior se descarta, la compuerta de entrada decide que nueva información se escribe, y la compuerta de salida determina que parte de la celda se expone como estado oculto. Este mecanismo permite que los gradientes fluyan sin atenuación a través de cientos de pasos de tiempo, haciendo posible el aprendizaje de dependencias de largo alcance.
10.2 Mecanismo de Atención y Transformers
El mecanismo de atención (Vaswani et al. 2017) revoluciono el procesamiento de secuencias al permitir que cada posición de la secuencia de salida consulte directamente cualquier posición de la secuencia de entrada, sin importar la distancia entre ellas. La atención escalada de producto punto (Scaled Dot-Product Attention) se define como:
\[\text{Attention}(\mathbf{Q},\mathbf{K},\mathbf{V}) = \text{softmax}\!\left(\frac{\mathbf{Q}\mathbf{K}^\top}{\sqrt{d_k}}\right)\mathbf{V}\]
donde \(\mathbf{Q}\) (queries), \(\mathbf{K}\) (keys) y \(\mathbf{V}\) (values) son proyecciones lineales de la secuencia de entrada, y \(d_k\) es la dimensión de las claves (el factor \(\sqrt{d_k}\) previene que los productos internos se vuelvan muy grandes, lo que saturaría el softmax). La atención asigna a cada posición un peso que refleja su relevancia para la posición de consulta, permitiendo que el modelo capture dependencias directas sin importar la distancia.
Los Transformers, introducidos en 2017, reemplazaron a las RNN como arquitectura dominante en procesamiento de lenguaje natural y, más recientemente, en visión computacional (Visión Transformers, ViT) y otros dominios. La arquitectura consiste en bloques de atención multi-cabeza (multi-head attention) combinados con redes de retroalimentación posición a posición y conexiones residuales, permitiendo el entrenamiento paralelo eficiente y la captura de dependencias de largo alcance sin el cuello de botella secuencial de las RNN.
10.3 Aprendizaje por Transferencia
El aprendizaje por transferencia (Transfer Learning) es el paradigma dominante del aprendizaje profundo moderno. En lugar de entrenar una red desde cero sobre el conjunto de datos objetivo (lo que requeriría millones de ejemplos y días de cómputo), se parte de un modelo pre-entrenado en un conjunto de datos masivo (como ImageNet para visión o Common Crawl para lenguaje) y se ajusta (fine-tuning) sobre los datos del problema objetivo.
El fundamento teórico es que las representaciones aprendidas en dominios de gran escala son transferibles: los filtros de bordes, texturas y formas aprendidos sobre ImageNet son útiles para clasificar imágenes médicas; las representaciones lingüísticas de BERT son útiles para análisis de sentimiento en dominios específicos. El ajuste puede ser total (se actualizan todos los pesos del modelo) o parcial (se congelan las capas iniciales y solo se actualizan las capas finales), dependiendo del tamaño del conjunto de datos objetivo y su similitud con el conjunto de pre-entrenamiento. Este paradigma ha democratizado el aprendizaje profundo, permitiendo obtener resultados de estado del arte con cientos (no millones) de ejemplos etiquetados.
11 Resumen
Este capitulo ha recorrido el desarrollo de las redes neuronales desde sus fundamentos más elementales hasta las arquitecturas modernas. El perceptrón de Rosenblatt establece el esqueleto matemático: sumas ponderadas seguidas de no linealidades, entrenadas mediante una regla de actualización basada en errores. La extensión a redes multicapa, habilitada por el algoritmo de retropropagación, permite aprender representaciones jerárquicas que resuelven problemas no lineales arbitrariamente complejos, respaldado por el teorema de aproximación universal. Las funciones de activación ReLU y la inicialización cuidadosa de pesos hacen posible el entrenamiento de redes profundas sin sufrir el colapso del gradiente. Los optimizadores modernos como Adam aceleran la convergencia adaptando la tasa de aprendizaje a la geometría local del paisaje de pérdida. Las redes convolucionales explotan la estructura espacial de las imágenes mediante conectividad local y compartimiento de pesos, aprendiendo detectores de características jerárquicos que escalan desde píxeles hasta objetos. Finalmente, técnicas de regularización como L2 y Dropout permiten que redes con millones de parámetros generalicen más alla del conjunto de entrenamiento.
La implementación desde cero presentada en la Sección 6 demuestra que toda esta teoría se traduce en unas pocas decenas de lineas de álgebra lineal en numpy, consolidando la comprensión profunda de los mecanismos subyacentes que es necesaria para aplicar, diagnosticar y extender estas arquitecturas en contextos reales.