De la neurona a la red: perceptrón y MLP

Tema 16 · Fase 4, Redes neuronales · Toda la ruta

Los 12 bloques de código de la teoría, en el mismo orden. Cópialos y córrelos en tu Python o en Colab; en la ficha Teoría está la explicación de cada uno.

Una neurona

import numpy as np
import matplotlib.pyplot as plt

def escalon(z):
    """1 si z >= 0, y 0 si no."""
    return np.where(z >= 0, 1, 0)

def sigmoide(z):
    """La del tema 8: aplasta cualquier número a un valor entre 0 y 1."""
    return 1 / (1 + np.exp(-z))

def neurona(x, w, b, activacion):
    """Suma ponderada más sesgo, pasada por una activación."""
    z = np.dot(w, x) + b
    return activacion(z)

x = np.array([2.0, -1.0])     # dos entradas
w = np.array([0.5, 1.5])      # un peso por entrada
b = 0.25                      # el sesgo

print("z =", np.dot(w, x) + b)
print("Con escalón: ", neurona(x, w, b, escalon))
print("Con sigmoide:", neurona(x, w, b, sigmoide).round(3))

El perceptrón

rng = np.random.default_rng(42)

# 50 puntos alrededor de cada centro (datos sintéticos)
X_sep = np.vstack([rng.normal([-1, -1], 0.6, size=(50, 2)),
                   rng.normal([1.2, 1.0], 0.6, size=(50, 2))])
y_sep = np.array([0] * 50 + [1] * 50)
orden = rng.permutation(100)          # mezclamos las dos clases
X_sep, y_sep = X_sep[orden], y_sep[orden]

def entrenar_perceptron(X, y, epocas=15, tasa=0.1):
    """La regla del perceptrón. Devuelve w, b y los errores por época."""
    w = np.zeros(X.shape[1])
    b = 0.0
    errores = []
    for epoca in range(epocas):
        fallos = 0
        for xi, yi in zip(X, y):
            pred = escalon(np.dot(w, xi) + b)
            # Si acierta, yi - pred vale 0 y nada cambia
            w = w + tasa * (yi - pred) * xi
            b = b + tasa * (yi - pred)
            fallos += int(pred != yi)
        errores.append(fallos)
    return w, b, errores

w_sep, b_sep, errores_sep = entrenar_perceptron(X_sep, y_sep)
print("Errores en cada época:", errores_sep)
print("w =", w_sep.round(3), "  b =", round(b_sep, 3))
esquinas = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
clase_xor = np.array([0, 1, 1, 0])    # 1 si los interruptores difieren

cual = rng.integers(0, 4, size=100)   # a qué esquina va cada punto
X_xor = esquinas[cual] + rng.normal(0, 0.15, size=(100, 2))
y_xor = clase_xor[cual]

w_x, b_x, errores_xor = entrenar_perceptron(X_xor, y_xor)
print("Errores en cada época:", errores_xor)
def dibujar_clases(X, y, ax=None, s=30):
    """Clase 0: círculos de color C0. Clase 1: cuadrados de color C1."""
    ax = ax or plt.gca()
    ax.scatter(X[y == 0, 0], X[y == 0, 1], s=s, color="C0",
               marker="o", label="Clase 0")
    ax.scatter(X[y == 1, 0], X[y == 1, 1], s=s, color="C1",
               marker="s", label="Clase 1")

def regiones(ax, predecir, x_lim, y_lim):
    """Pinta de C0 o C1, muy claro, la clase que predice cada lugar."""
    A, B = np.meshgrid(np.linspace(*x_lim, 200), np.linspace(*y_lim, 200))
    zona = predecir(np.column_stack([A.ravel(), B.ravel()]))
    ax.contourf(A, B, zona.reshape(A.shape), levels=[-0.5, 0.5, 1.5],
                colors=["C0", "C1"], alpha=0.2)

fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8))
casos = [(X_sep, y_sep, w_sep, b_sep, errores_sep, (-3, 3.2),
          "Separables"),
         (X_xor, y_xor, w_x, b_x, errores_xor, (-0.6, 1.6), "XOR")]
for col, (X_c, y_c, w_c, b_c, err, lim, nombre) in enumerate(casos):
    arriba, abajo = ejes[0, col], ejes[1, col]
    regiones(arriba, lambda P: escalon(P @ w_c + b_c), lim, lim)
    dibujar_clases(X_c, y_c, ax=arriba, s=8)
    arriba.set_title(nombre)
    abajo.plot(range(1, 16), err, color="C0", marker="o", markersize=4)
    abajo.set_ylim(-3, 60)
    abajo.set_xlabel("Época")
ejes[1, 0].set_ylabel("Errores")
plt.show()

Funciones de activación

def tanh(z):
    return np.tanh(z)

def relu(z):
    """Los positivos pasan igual; los negativos se vuelven 0."""
    return np.maximum(0, z)

def d_sigmoide(z):
    return sigmoide(z) * (1 - sigmoide(z))

def d_tanh(z):
    return 1 - np.tanh(z) ** 2

def d_relu(z):
    # En z = 0 la derivada no existe; por convención se usa 0
    return np.where(z > 0, 1.0, 0.0)

activaciones = [("sigmoide", sigmoide, d_sigmoide),
                ("tanh", tanh, d_tanh),
                ("ReLU", relu, d_relu)]
for nombre, f, df in activaciones:
    print(f"{nombre:8s} f(-5) = {f(-5.0):5.2f}  f(0) = {f(0.0):.2f}"
          f"  f(5) = {f(5.0):.2f}  f'(0) = {df(0.0):.2f}"
          f"  f'(5) = {df(5.0):.4f}")
zs = np.linspace(-5, 5, 400)
fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8))
for eje, (nombre, f, df) in zip(ejes.flat, activaciones):
    eje.plot(zs, f(zs), color="C0", label="f(z)")
    eje.plot(zs, df(zs), color="C2", linestyle="--", label="derivada")
    eje.set_title(nombre)
    eje.set_ylim(-1.2, 2.2)
ejes[0, 0].scatter([0], [0.25], color="C1", s=60, zorder=3)
ejes[0, 0].legend(loc="upper left", fontsize=10)

# La derivada de la sigmoide vale como mucho 0,25: tras n capas,
# el gradiente puede quedar multiplicado por 0,25 ** n
capas = np.arange(1, 11)
ejes[1, 1].semilogy(capas, 0.25 ** capas, color="C0", marker="o",
                    markersize=4)
ejes[1, 1].set_title("0,25 elevado a n")
ejes[1, 1].set_xlabel("Capas n")
plt.show()

print(f"0.25 ** 10 = {0.25 ** 10:.1e}")

De la neurona a la red

X4 = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
y4 = np.array([0, 1, 1, 0])           # XOR

# Cada columna de W1 son los pesos de una neurona oculta
W1 = np.array([[6.0, 6.0],
               [6.0, 6.0]])
b1 = np.array([-3.0, -9.0])           # neurona 1: «O»; neurona 2: «Y»
W2 = np.array([[8.0],
               [-8.0]])               # «h1 y no h2»
b2 = np.array([-4.0])

H = sigmoide(X4 @ W1 + b1)            # (4, 2) @ (2, 2) -> (4, 2)
salida = sigmoide(H @ W2 + b2)        # (4, 2) @ (2, 1) -> (4, 1)

print("x1 x2 |   h1    h2  | salida | XOR")
for xi, hi, si, yi in zip(X4, H, salida[:, 0], y4):
    print(f" {xi[0]}  {xi[1]} | {hi[0]:.3f} {hi[1]:.3f} |"
          f"  {si:.3f} |  {yi}")
def pintar_salida(ax, A, B, P):
    """Bandas coral si P >= 0.5 y tinta si no, más fuertes lejos de 0.5."""
    cortes = [0, 0.1, 0.25, 0.4, 0.5, 0.6, 0.75, 0.9, 1]
    for bajo, alto in zip(cortes[:-1], cortes[1:]):
        color = "C1" if bajo >= 0.5 else "C0"
        lejos = max(abs(bajo - 0.5), abs(alto - 0.5))
        ax.contourf(A, B, P, levels=[bajo, alto], colors=color,
                    alpha=0.6 * lejos)

A, B = np.meshgrid(np.linspace(-0.5, 1.5, 200), np.linspace(-0.5, 1.5, 200))
plano = np.column_stack([A.ravel(), B.ravel()])
H_plano = sigmoide(plano @ W1 + b1)
sal_plano = sigmoide(H_plano @ W2 + b2)

fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8))
paneles = [(ejes[0, 0], H_plano[:, 0], "Oculta 1: O"),
           (ejes[0, 1], H_plano[:, 1], "Oculta 2: Y"),
           (ejes[1, 1], sal_plano[:, 0], "Salida: XOR")]
for eje, valores, titulo in paneles:
    pintar_salida(eje, A, B, valores.reshape(A.shape))
    dibujar_clases(X4, y4, ax=eje, s=50)
    eje.set_title(titulo)

# Lo que ve la neurona de salida: los cuatro casos en el espacio (h1, h2)
dibujar_clases(H, y4, ax=ejes[1, 0], s=50)
h = np.linspace(-0.1, 1.1, 2)
# Frontera de la salida: 8·h1 - 8·h2 - 4 = 0, o sea h2 = h1 - 0.5
ejes[1, 0].plot(h, h - 0.5, color="C0")
ejes[1, 0].set_xlim(-0.1, 1.1)
ejes[1, 0].set_ylim(-0.1, 1.1)
ejes[1, 0].set_title("Espacio oculto")
ejes[1, 0].set_xlabel("$h_1$")
ejes[1, 0].set_ylabel("$h_2$")
plt.show()
def adelante(X, capas, activacion=relu, ver=False):
    """Paso hacia adelante de un MLP. capas: una lista de pares (W, b)."""
    A = X
    for i, (W, b) in enumerate(capas):
        Z = A @ W + b
        if ver:
            print(f"Capa {i + 1}: {A.shape} @ {W.shape} + {b.shape}"
                  f" -> {Z.shape}")
        ultima = i == len(capas) - 1
        # Las capas ocultas llevan activación; la última sale tal cual
        A = Z if ultima else activacion(Z)
    return A

rng = np.random.default_rng(0)
# Una red 2-4-1 con pesos al azar: de 2 entradas a 4 ocultas, y a 1
red = [(rng.normal(size=(2, 4)), np.zeros(4)),
       (rng.normal(size=(4, 1)), np.zeros(1))]
X5 = rng.normal(size=(5, 2))          # cinco ejemplos
salida5 = adelante(X5, red, ver=True)

# La misma función, con la red de XOR y sigmoide en la salida
z_xor = adelante(X4, [(W1, b1), (W2, b2)], activacion=sigmoide)
print("XOR:", sigmoide(z_xor).ravel().round(3))
def contar_parametros(tamanos):
    """Pesos y sesgos de un MLP con capas de esos tamaños."""
    total = 0
    for a, m in zip(tamanos[:-1], tamanos[1:]):
        total += a * m + m            # matriz (a, m) y un sesgo por neurona
    return total

for tamanos in [[2, 2, 1], [2, 4, 1], [784, 128, 10]]:
    print(tamanos, "->", contar_parametros(tamanos), "parámetros")
rng = np.random.default_rng(1)
V1, c1 = rng.normal(size=(2, 4)), rng.normal(size=4)
V2, c2 = rng.normal(size=(4, 1)), rng.normal(size=1)

dos_capas = (X5 @ V1 + c1) @ V2 + c2  # dos capas, sin activación
V, c = V1 @ V2, c1 @ V2 + c2          # una sola capa equivalente
una_capa = X5 @ V + c

print("Forma de V:", V.shape)
print("¿Dan lo mismo?", np.allclose(dos_capas, una_capa))

Un adelanto: una red que aprende sola

from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.neural_network import MLPClassifier

# Las mismas lunas y la misma división del tema 8
X, y = make_moons(n_samples=200, noise=0.3, random_state=0)
X_entr, X_val, y_entr, y_val = train_test_split(
    X, y, test_size=0.3, random_state=0)

fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8),
                         sharex=True, sharey=True)
for eje, ocultas in zip(ejes.flat, [1, 3, 10, 100]):
    red_mlp = MLPClassifier(hidden_layer_sizes=(ocultas,),
                            solver="lbfgs", max_iter=2000,
                            random_state=0)
    red_mlp.fit(X_entr, y_entr)
    regiones(eje, red_mlp.predict, (-2, 3), (-1.5, 2))
    dibujar_clases(X_entr, y_entr, ax=eje, s=8)
    eje.set_title(f"Ancho {ocultas}")
    print(f"Ancho {ocultas:3d}:  aciertos al entrenar "
          f"{red_mlp.score(X_entr, y_entr):.2f}"
          f"   en validación {red_mlp.score(X_val, y_val):.2f}")
plt.show()