36  Redes Neuronales para Clasificacion

Autor/a

Diego Villalba

Fecha de publicación

19 de mayo de 2026

Las redes neuronales artificiales son modelos computacionales inspirados en la estructura del sistema nervioso biologico. Desde el perceptron de Rosenblatt en 1958 (Rosenblatt 1958) hasta las arquitecturas profundas que dominan el estado del arte actual (LeCun, Bengio, y Hinton 2015), el esqueleto matematico fundamental permanece invariante: una composicion de transformaciones lineales seguidas de no linealidades, aplicadas de manera jerarquica sobre los datos de entrada. Esta arquitectura modular permite que el modelo aprenda representaciones cada vez mas abstractas de los datos, pasando de caracteristicas de bajo nivel (como bordes en una imagen) hasta conceptos de alto nivel (como la clase de un objeto).

El interes renovado por las redes neuronales en la ultima decada no es accidental. Se sustenta en tres pilares convergentes: la disponibilidad de grandes volumenes de datos etiquetados, la capacidad de computo paralelo ofrecida por las unidades de procesamiento grafico (GPU), y avances teoricos y practicos en el entrenamiento de redes profundas. El teorema de aproximacion universal (Hornik, Stinchcombe, y White 1989) formaliza por que estas arquitecturas son tan poderosas: una red de una sola capa oculta con suficientes unidades puede aproximar cualquier funcion continua sobre un conjunto compacto con precision arbitraria. Este capitulo desarrolla la teoria desde el perceptron hasta las redes convolucionales, construyendo cada componente desde primeros principios con implementaciones en Python puro y visualizaciones interactivas.

1 El Perceptron

1.1 Modelo matematico

El perceptron es la unidad de computo mas elemental de las redes neuronales. Recibe \(d\) entradas escalares \(x_1, \ldots, x_d\) y computa una combinacion lineal ponderada seguida de una funcion de activacion binaria. Formalmente, dados un vector de pesos \(\mathbf{w} \in \mathbb{R}^d\) y un sesgo \(b \in \mathbb{R}\), el perceptron 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 interpretacion geometrica 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 decision. El sesgo \(b\) desplaza el hiperplano respecto al origen, permitiendo que la frontera de decision no pase necesariamente por el punto cero.

Esta representacion es exactamente la misma que la de las Maquinas de Soporte Vectorial, con la diferencia fundamental de que el perceptron no busca el hiperplano de margen maximo: simplemente busca cualquier hiperplano que clasifique correctamente todos los ejemplos de entrenamiento. Esta distincion es crucial y explica por que el perceptron puede encontrar diferentes soluciones dependiendo del orden en que se presenten los datos, mientras que la SVM produce una solucion unica y optima en el sentido del margen.

1.2 Regla de aprendizaje del perceptron

El algoritmo de entrenamiento del perceptron es sorprendentemente simple. Se procesan los ejemplos de entrenamiento de forma iterativa y, cuando el modelo comete un error de clasificacion en el ejemplo \((\mathbf{x}^{(i)}, y^{(i)})\), se actualiza el vector de pesos en la direccion 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 logica 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 direccion de \(\mathbf{x}^{(i)}\), lo que aumenta \(\mathbf{w}^\top \mathbf{x}^{(i)}\) y acerca la prediccion al valor correcto. Si se predijo \(\hat{y} = +1\) cuando el verdadero es \(y^{(i)} = -1\) (falso positivo), el desplazamiento es en la direccion opuesta a \(\mathbf{x}^{(i)}\).

El teorema de convergencia del perceptron establece que si los datos son linealmente separables con margen geometrico \(\gamma > 0\) (es decir, existe un hiperplano que clasifica todos los puntos correctamente con distancia minima \(\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 limite fundamental del perceptron simple que motivo el desarrollo de las redes multicapa.

Mostrar codigo
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()
Figura 1: Convergencia del algoritmo de entrenamiento del perceptron en un conjunto de datos linealmente separable. Cada fotograma muestra el estado actual de la frontera de decision y el numero de errores restantes. El deslizador permite navegar por la trayectoria de aprendizaje.

1.3 La limitacion del perceptron: el problema XOR

Aunque el perceptron es elegante en su simplicidad, tiene una limitacion fundamental: solo puede separar clases que son linealmente separables. En 1969, Minsky y Papert demostraron en su libro Perceptrons que la funcion logica XOR (o exclusivo) no puede ser implementada por un perceptron simple. Esta constatacion genero un periodo de pesimismo en la investigacion sobre redes neuronales conocido como el “invierno del IA”, hasta que el desarrollo del algoritmo de retropropagacion en los anos ochenta revivio el campo al demostrar que las redes multicapa podian aprender representaciones no lineales.

Mostrar codigo
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()
Figura 2: Comparacion de problemas logicos para el perceptron. Las compuertas AND y OR son linealmente separables (arriba), mientras que XOR no lo es (abajo izquierda). Una red multicapa puede resolver XOR aprendiendo una frontera no lineal (abajo derecha), lo que justifica el uso de redes con capas ocultas.

2 Funciones de Activacion

Las funciones de activacion son el ingrediente esencial que dota a las redes neuronales de su capacidad expresiva. Sin ellas, cualquier composicion de capas lineales colapsaria a una unica transformacion 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 transformacion lineal con una sola capa equivalente. La no linealidad introducida por la funcion de activacion permite que las capas aprendan representaciones jerarquicas genuinamente no lineales.

La eleccion de la funcion de activacion afecta tanto la expresividad del modelo como la estabilidad del entrenamiento. Las funciones clasicas como la sigmoide y la tangente hiperbolica 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 practica moderna precisamente por mitigar este problema. Las cinco funciones principales se definen y comparan a continuacion.

La sigmoide \(\sigma(z) = \frac{1}{1+e^{-z}}\) mapea cualquier valor real al intervalo \((0, 1)\) y se usa tipicamente en la capa de salida para clasificacion 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 hiperbolica \(\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 pequenos 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 aproximacion 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 activacion \(\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 codigo
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()
Figura 3: Comparacion de cinco funciones de activacion (fila superior) y sus derivadas (fila inferior) en el rango \(z \in [-4, 4]\). Las funciones acotadas como sigmoid y tanh tienen derivadas menores a 1, lo que contribuye al gradiente desvaneciente. ReLU y sus variantes mantienen gradientes unitarios para entradas positivas.

3 Red Neuronal Multicapa (MLP)

3.1 Arquitectura

Una red neuronal multicapa (Multilayer Perceptron, MLP) es una composicion 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 parametros 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 numero total de parametros es \(\sum_{\ell=1}^L (n_{\ell-1} \cdot n_\ell + n_\ell)\), que puede escalar rapidamente con la profundidad y el ancho de la red.

La eleccion de la arquitectura (numero de capas y unidades por capa) es uno de los hiperparametros mas importantes del diseño de una red. Redes mas profundas pueden aprender representaciones mas complejas, pero son mas dificiles de entrenar y mas propensas al sobreajuste con conjuntos de datos pequenos. Redes mas anchas tienen mayor capacidad memoristica pero son menos eficientes en terminos de parametros por unidad de complejidad. En la practica, la arquitectura se elige mediante validacion cruzada o mediante heuristica basada en el dominio del problema.

3.2 Propagacion hacia adelante

Dada una matriz de datos \(\mathbf{X} \in \mathbb{R}^{m \times n_0}\) con \(m\) ejemplos, la propagacion hacia adelante (forward pass) calcula la prediccion 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 funcion de activacion de la capa \(\ell\). Para las capas ocultas se usa tipicamente ReLU o tanh; para la capa de salida en clasificacion binaria se usa sigmoide \(\sigma\) (que produce probabilidades en \((0,1)\)) y para clasificacion multiclase se usa softmax \(\text{softmax}(\mathbf{z})_k = e^{z_k}/\sum_j e^{z_j}\). La prediccion final es \(\hat{\mathbf{Y}} = \mathbf{A}^{(L)}\).

3.3 Teorema de aproximacion 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 mas amplia de activaciones no polinomiales (Hornik, Stinchcombe, y White 1989):

Teorema (Aproximacion Universal). Sea \(g\) una funcion de activacion continua y no polinomial. Para cualquier funcion 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 maximo \(\varepsilon\) en todo su dominio.

Este teorema es de existencia, no constructivo: garantiza que la red puede aproximar cualquier funcion, 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 codigo
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()
Figura 4: Diagrama de una red neuronal multicapa con arquitectura 4-5-4-3 (capa de entrada con 4 unidades, dos capas ocultas con 5 y 4 unidades, y capa de salida con 3 unidades). Las conexiones entre capas representan los pesos aprendibles. Las etiquetas muestran las dimensiones de las matrices de pesos correspondientes.

4 Retropropagacion del Error

4.1 Funcion de perdida

El entrenamiento de la red se formula como la minimizacion de una funcion de perdida \(\mathcal{L}\) que mide la discrepancia entre las predicciones del modelo y los valores reales. Para clasificacion binaria, la perdida de entropia cruzada (cross-entropy) es la eleccion estandar, derivada del principio de maxima 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 perdida es convexa como funcion 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 retropropagacion (Rumelhart, Hinton, y Williams 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 parametros.

4.2 Regla de la cadena — derivacion capa a capa

Definimos la senal de error en la capa \(\ell\) como \(\boldsymbol{\delta}^{(\ell)} = \frac{\partial \mathcal{L}}{\partial \mathbf{z}^{(\ell)}}\). Esta cantidad captura cuanto contribuye la activacion pre-lineal de la capa \(\ell\) a la perdida total. La recurrencia de retropropagacion se establece de la siguiente manera. Para la capa de salida \(L\) con sigmoide y perdida 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:

\[\boldsymbol{\delta}^{(\ell)} = \left(\boldsymbol{\delta}^{(\ell+1)} \mathbf{W}^{(\ell+1)\top}\right) \odot g'^{(\ell)}(\mathbf{z}^{(\ell)}) \tag{8}\]

donde \(\odot\) denota el producto elemento a elemento (producto de Hadamard) y \(g'^{(\ell)}\) es la derivada de la funcion de activacion de la capa \(\ell\). Los gradientes de los parametros 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 derivacion revela por que la profundidad es un desafio: el gradiente de la perdida 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 codigo
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()
Figura 5: Magnitud del gradiente (escala logaritmica) en funcion del indice de capa para una red de 6 capas con activaciones sigmoid (gradiente desvaneciente) versus ReLU (gradiente estable). La simulacion inicializa pesos aleatorios y propaga el error desde la salida. Con sigmoid, el gradiente colapsa a valores cercanos a cero en las capas iniciales.
Mostrar codigo
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()
Figura 6: Superficie 3D de la perdida de entropia cruzada como funcion de dos pesos \(w_1\) y \(w_2\) de un modelo logistico simple. El punto rojo marca el minimo. Las isolineas proyectadas en la base muestran la geometria del paisaje de optimizacion, que es convexa para este modelo de una sola capa.

5 Optimizacion

5.1 Descenso de gradiente estocastico (SGD)

El descenso de gradiente estocastico (Stochastic Gradient Descent, SGD) es el algoritmo base de optimizacion para el entrenamiento de redes neuronales. En lugar de calcular el gradiente exacto sobre todo el conjunto de entrenamiento (lo que seria 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 tamano de cada paso en el espacio de parametros. Si es demasiado grande, el algoritmo puede divergir u oscilar alrededor del minimo; si es demasiado pequena, el entrenamiento es lento. El tamano del mini-lote \(B\) introduce un trade-off entre precision del gradiente (lotes grandes) y eficiencia computacional y regularizacion implicita (lotes pequenos). El ruido del gradiente introducido por el muestreo aleatorio actua como regularizador: impide que el optimizador converja a minimos excesivamente agudos del paisaje de perdida, lo que favorece soluciones con mejor generalizacion.

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\) tipicamente en \([0.85, 0.99]\), el momentum efectivo que llega al parametro es la suma geometricamente ponderada de gradientes pasados, lo que suaviza la trayectoria de optimizacion y permite tasas de aprendizaje mas grandes. En paisajes con curvatura muy anisotropica (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 adaptacion 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 terminos de correccion de sesgo \((1-\beta_k^t)\) compensan el hecho de que al inicio del entrenamiento \(\mathbf{m}\) y \(\mathbf{v}\) estan inicializados en cero y subestiman los momentos verdaderos. Los valores tipicos son \(\beta_1 = 0.9\), \(\beta_2 = 0.999\) y \(\epsilon = 10^{-8}\). La division por \(\sqrt{\hat{\mathbf{v}}} + \epsilon\) normaliza el gradiente por su magnitud tipica 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 parametros.

Mostrar codigo
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()
Figura 7: Trayectorias de tres optimizadores (SGD, Momentum, Adam) sobre la funcion de Beale, un paisaje de optimizacion no convexo con curvaturas muy diferentes en distintas direcciones. Todos los optimizadores parten del mismo punto inicial. Adam converge mas rapidamente al minimo global en \((3, 0.5)\).

6 Implementacion desde Cero

Para consolidar la comprension matematica de los algoritmos descritos, se presenta a continuacion una implementacion completa de un MLP en Python puro con numpy, sin utilizar ninguna biblioteca de aprendizaje automatico para el entrenamiento. La implementacion sigue exactamente las ecuaciones de propagacion hacia adelante (Ec. 36.4, Ec. 36.5) y retropropagacion (Ec. 36.7, Ec. 36.8, Ec. 36.9). La inicializacion de pesos usa la inicializacion 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 traves de las capas en el paso hacia adelante.

Esta implementacion sirve como puente entre la teoria matematica y las bibliotecas de alto nivel como PyTorch o TensorFlow. En la practica, esas bibliotecas ofrecen diferenciacion automatica, ejecucion en GPU y multiples optimizadores, pero la estructura logica es identica a la presentada aqui. Entender la implementacion desde cero es fundamental para diagnosticar problemas de entrenamiento, interpretar el comportamiento de la red y adaptar el modelo a casos de uso no estandar.

Mostrar codigo
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()
Figura 8: Curvas de aprendizaje del MLP entrenado desde cero sobre el conjunto make_moons (n=400, noise=0.2) con arquitectura [2, 16, 16, 1] y 300 epocas. El panel izquierdo muestra la perdida de entropia cruzada y el panel derecho la precision de clasificacion. Ambas curvas muestran convergencia estable sin oscilaciones.
Mostrar codigo
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()
Figura 9: Frontera de decision del MLP entrenado sobre make_moons. Los colores de fondo muestran la probabilidad predicha por la red para cada region del espacio de caracteristicas. La linea blanca marca la isocurva de probabilidad 0.5 (frontera de decision). El modelo captura la forma no lineal de las dos lunas con alta precision.

7 Redes Convolucionales (CNN)

7.1 Motivacion

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\) pixeles, la capa de entrada tiene 1024 unidades. Si la primera capa oculta tiene 512 unidades, solo esta capa requiere \(1024 \times 512 = 524{,}288\) parametros. Para imagenes en color de \(256 \times 256\) pixeles, una sola capa con 512 unidades necesitaria mas de 100 millones de parametros. Ademas, si la imagen se traslada un pixel a la derecha, la red la ve como una entrada completamente diferente: no tiene ninguna nocion de invarianza a la traslacion, que es una propiedad fundamental de las imagenes 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 region pequena de la capa anterior (el campo receptivo), reduciendo drasticamente el numero 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 parametros del filtro se comparten entre todas las posiciones. El tercero es la equivarianza a la traslacion: si el objeto se mueve en la imagen, la representacion aprendida se mueve de manera correspondiente, lo que permite al modelo reconocer el mismo patron independientemente de donde aparezca.

7.2 La operacion de convolucion

La operacion fundamental de las CNN es la correlacion cruzada 2D entre una imagen de entrada \(I\) y un filtro \(K\) de tamano \(k \times k\). Para cada posicion \((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 “convolucion” a lo que tecnicamente es correlacion cruzada: la verdadera convolucion matematica voltea el kernel antes de aplicarlo, pero dado que los pesos se aprenden durante el entrenamiento, esta distincion no tiene consecuencias practicas (Goodfellow, Bengio, y Courville 2016).

Mostrar codigo
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()
Figura 10: Visualizacion de una convolucion 2D con un kernel de deteccion de bordes tipo Sobel. La imagen de entrada (7x7) se convoluciona con el kernel (3x3) para producir un mapa de caracteristicas de salida (5x5). Cada celda muestra su valor numerico. La operacion detecta gradientes horizontales en la imagen.

7.3 Capas de pooling

Las capas de pooling reducen las dimensiones espaciales de los mapas de caracteristicas, introduciendo invarianza local a pequenas traslaciones y reduciendo el numero de parametros en las capas subsiguientes. El max-pooling sobre una ventana de \(p \times p\) retiene el valor maximo de cada ventana, capturando la presencia del patron mas activado en cada region. El average-pooling calcula el promedio, produciendo una representacion mas suave. Para una entrada de tamano \(H \times W\), el max-pooling con ventana \(p \times p\) y stride \(p\) produce una salida de tamano \(\lfloor H/p \rfloor \times \lfloor W/p \rfloor\).

La operacion de max-pooling tiene una derivada simple: en el paso hacia atras, el gradiente se propaga solo a la posicion que fue el maximo en la ventana (las demas reciben gradiente cero). Esto es eficiente computacionalmente y favorece la especializacion de las neuronas: cada unidad del mapa de caracteristicas aprende a detectar un patron especifico en alguna region del campo receptivo.

7.4 Arquitectura CNN clasica: LeNet-5

La arquitectura LeNet-5 (LeCun et al. 1998) establece el patron basico de las CNN modernas: bloques de convolucion seguidos de pooling, finalizados por capas completamente conectadas para la clasificacion. El patron 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 caracteristicas locales (bordes, texturas, formas) mientras que las capas completamente conectadas integran estas caracteristicas espaciales para la decision final. Las arquitecturas modernas como VGG, ResNet e Inception siguen este mismo principio, pero con mayor profundidad, conexiones residuales y bloques de atencion.

Mostrar codigo
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()
Figura 11: Mapas de caracteristicas generados por cuatro filtros distintos aplicados a una imagen sintetica de un circulo (28x28). De izquierda a derecha: imagen original, detector de bordes horizontales (filtro Sobel horizontal), detector de bordes verticales (filtro Sobel vertical), detector diagonal y detector de blob (laplaciano). La convoluciones se implementan manualmente con bucles Python sin scipy.

8 Regularizacion en Redes Neuronales

Las redes neuronales con muchos parametros tienen alta capacidad de memorizar el conjunto de entrenamiento, lo que puede llevar al sobreajuste: el modelo obtiene baja perdida en entrenamiento pero generaliza pobremente a datos nuevos. La regularizacion comprende un conjunto de tecnicas que reducen la brecha entre el error de entrenamiento y el error de generalizacion, ya sea penalizando la complejidad del modelo, perturbando el proceso de entrenamiento o limitando la co-adaptacion de las neuronas.

8.1 Regularizacion L2 (weight decay)

La regularizacion L2 anade un termino de penalizacion proporcional a la norma cuadrada de los pesos a la funcion de perdida:

\[\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 regularizacion. El efecto sobre la regla de actualizacion es una contraccion 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 iteracion, lo que da nombre al metodo (weight decay). Esta penalizacion favorece soluciones con pesos pequenos, que tienden a ser mas suaves y generalizan mejor. La magnitud de \(\lambda\) controla el trade-off entre ajuste a los datos y suavidad del modelo.

8.2 Dropout

El Dropout (Srivastava et al. 2014) es una tecnica de regularizacion especifica 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\), tipicamente \(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 estan activas, pero sus salidas se escalan por \(p\) (o alternativamente, se dividen por \(p\) durante el entrenamiento, tecnica conocida como inverted dropout).

El Dropout puede interpretarse como el entrenamiento implicito 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 analoga al promediado de ensambles. Esta interpretacion de ensamble implicito explica por que el Dropout mejora la generalizacion de forma tan sistematica.

Mostrar codigo
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()
Figura 12: Comparacion esquematica de una red neuronal durante el entrenamiento sin Dropout (izquierda) y con Dropout con probabilidad de retencion p=0.6 (derecha). Las neuronas grises y las conexiones punteadas representan unidades desactivadas aleatoriamente en ese paso de entrenamiento. La red efectiva es una sub-red aleatoria, lo que reduce la co-adaptacion entre neuronas.

8.3 Batch Normalization

La Normalizacion por Lote (Batch Normalization) es una tecnica que normaliza las pre-activaciones de cada capa dentro del mini-lote de entrenamiento, seguido de una transformacion lineal con parametros aprendibles \(\gamma\) y \(\beta\). Para cada capa \(\ell\) y mini-lote \(\mathcal{B}\) de tamano \(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 normalizacion estabiliza la distribucion de las activaciones a lo largo del entrenamiento (reduciendo el internal covariate shift), lo que permite tasas de aprendizaje mas altas y hace al modelo menos sensible a la inicializacion de pesos. Los parametros \(\gamma\) y \(\beta\) son aprendibles y permiten al modelo recuperar la distribucion optima si la normalizacion no es beneficiosa en alguna capa.

Mostrar codigo
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()
Figura 13: Comparacion de curvas de perdida de entrenamiento y validacion para tres versiones del MLP en make_moons: sin regularizacion, con regularizacion L2 (lambda=0.01) y con Dropout (p=0.7). Las versiones regularizadas muestran una menor brecha entre la perdida de entrenamiento y validacion, lo que indica mejor generalizacion.

9 Mas alla: Tipos de Redes

9.1 Redes Recurrentes (RNN/LSTM)

Las redes completamente conectadas y las CNN asumen que las entradas son independientes entre si. Muchos problemas practicos, 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 informacion relevante de todos los pasos anteriores \(\mathbf{x}_1, \ldots, \mathbf{x}_t\), permitiendo que la red haga predicciones que dependen del contexto historico. Sin embargo, las RNN simples sufren de gradiente desvaneciente a traves 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 pequenas.

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 informacion de la celda anterior se descarta, la compuerta de entrada decide que nueva informacion 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 atenuacion a traves de cientos de pasos de tiempo, haciendo posible el aprendizaje de dependencias de largo alcance.

9.2 Mecanismo de Atencion y Transformers

El mecanismo de atencion (Vaswani et al. 2017) revoluciono el procesamiento de secuencias al permitir que cada posicion de la secuencia de salida consulte directamente cualquier posicion de la secuencia de entrada, sin importar la distancia entre ellas. La atencion 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 dimension de las claves (el factor \(\sqrt{d_k}\) previene que los productos internos se vuelvan muy grandes, lo que saturaria el softmax). La atencion asigna a cada posicion un peso que refleja su relevancia para la posicion 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, mas recientemente, en vision computacional (Vision Transformers, ViT) y otros dominios. La arquitectura consiste en bloques de atencion multi-cabeza (multi-head attention) combinados con redes de retroalimentacion posicion a posicion 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.

9.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 requeriria millones de ejemplos y dias de computo), se parte de un modelo pre-entrenado en un conjunto de datos masivo (como ImageNet para vision o Common Crawl para lenguaje) y se ajusta (fine-tuning) sobre los datos del problema objetivo.

El fundamento teorico es que las representaciones aprendidas en dominios de gran escala son transferibles: los filtros de bordes, texturas y formas aprendidos sobre ImageNet son utiles para clasificar imagenes medicas; las representaciones linguisticas de BERT son utiles para analisis de sentimiento en dominios especificos. 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 tamano 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.

10 Resumen

Este capitulo ha recorrido el desarrollo de las redes neuronales desde sus fundamentos mas elementales hasta las arquitecturas modernas. El perceptron de Rosenblatt establece el esqueleto matematico: sumas ponderadas seguidas de no linealidades, entrenadas mediante una regla de actualizacion basada en errores. La extension a redes multicapa, habilitada por el algoritmo de retropropagacion, permite aprender representaciones jerarquicas que resuelven problemas no lineales arbitrariamente complejos, respaldado por el teorema de aproximacion universal. Las funciones de activacion ReLU y la inicializacion 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 geometria local del paisaje de perdida. Las redes convolucionales explotan la estructura espacial de las imagenes mediante conectividad local y compartimiento de pesos, aprendiendo detectores de caracteristicas jerarquicos que escalan desde pixeles hasta objetos. Finalmente, tecnicas de regularizacion como L2 y Dropout permiten que redes con millones de parametros generalicen mas alla del conjunto de entrenamiento.

La implementacion desde cero presentada en la Seccion 6 demuestra que toda esta teoria se traduce en unas pocas decenas de lineas de algebra lineal en numpy, consolidando la comprension profunda de los mecanismos subyacentes que es necesaria para aplicar, diagnosticar y extender estas arquitecturas en contextos reales.

Referencias