🏭 Caso de Uso

Construcción y entrenamiento de un MLP con NumPy

Implementación desde cero de un MLP con NumPy para resolver un problema de regresión con el dataset diabetes de sklearn: forward pass, backpropagation y actualización de pesos.

🐍 Python 📓 Jupyter Notebook

🧠 Construcción y Entrenamiento de una Red Neuronal con NumPy

Submódulo: Perceptrón Multicapa (MLP) · Fundamentos de Deep Learning

En este caso de uso construiremos una red neuronal desde cero usando únicamente NumPy, sin frameworks de deep learning. Esto nos permite entender en detalle:

  • Cómo se inicializan los pesos de una red neuronal
  • El forward pass: cómo los datos fluyen capa a capa
  • El backward pass: cómo se calculan los gradientes con la regla de la cadena
  • Cómo el descenso del gradiente actualiza los pesos para minimizar el error

Usaremos el Diabetes Dataset de scikit-learn (442 pacientes, 10 características clínicas) como problema de regresión.

1. Carga y exploración de los datos

El dataset de diabetes contiene 10 variables clínicas normalizadas (edad, sexo, IMC, presión arterial y 6 medidas séricas). La variable objetivo es una medida cuantitativa de la progresión de la enfermedad un año después.

[ ]
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.datasets import load_diabetes
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

# ── Cargar el dataset ───────────────────────────────────
diabetes = load_diabetes()
X = diabetes.data       # (442, 10)
y = diabetes.target      # (442,)

df = pd.DataFrame(X, columns=diabetes.feature_names)
df["target"] = y

print(f"Dataset: {X.shape[0]} muestras, {X.shape[1]} características")
print(f"Variable objetivo — rango: [{y.min():.0f}, {y.max():.0f}], media: {y.mean():.1f}")

1.1 Estadísticas descriptivas

[ ]
print(df.describe())
                age           sex           bmi            bp            s1  \
count  4.420000e+02  4.420000e+02  4.420000e+02  4.420000e+02  4.420000e+02   
mean  -2.511817e-19  1.230790e-17 -2.245564e-16 -4.797570e-17 -1.381499e-17   
std    4.761905e-02  4.761905e-02  4.761905e-02  4.761905e-02  4.761905e-02   
min   -1.072256e-01 -4.464164e-02 -9.027530e-02 -1.123988e-01 -1.267807e-01   
25%   -3.729927e-02 -4.464164e-02 -3.422907e-02 -3.665608e-02 -3.424784e-02   
50%    5.383060e-03 -4.464164e-02 -7.283766e-03 -5.670422e-03 -4.320866e-03   
75%    3.807591e-02  5.068012e-02  3.124802e-02  3.564379e-02  2.835801e-02   
max    1.107267e-01  5.068012e-02  1.705552e-01  1.320436e-01  1.539137e-01   

                 s2            s3            s4            s5            s6  \
count  4.420000e+02  4.420000e+02  4.420000e+02  4.420000e+02  4.420000e+02   
mean   3.918434e-17 -5.777179e-18 -9.042540e-18  9.293722e-17  1.130318e-17   
std    4.761905e-02  4.761905e-02  4.761905e-02  4.761905e-02  4.761905e-02   
min   -1.156131e-01 -1.023071e-01 -7.639450e-02 -1.260971e-01 -1.377672e-01   
25%   -3.035840e-02 -3.511716e-02 -3.949338e-02 -3.324559e-02 -3.317903e-02   
50%   -3.819065e-03 -6.584468e-03 -2.592262e-03 -1.947171e-03 -1.077698e-03   
75%    2.984439e-02  2.931150e-02  3.430886e-02  3.243232e-02  2.791705e-02   
max    1.987880e-01  1.811791e-01  1.852344e-01  1.335973e-01  1.356118e-01   

           target  
count  442.000000  
mean   152.133484  
std     77.093005  
min     25.000000  
25%     87.000000  
50%    140.500000  
75%    211.500000  
max    346.000000  

1.2 Distribución de las variables

Observamos que las características ya están centradas (preprocesadas por sklearn). La variable target tiene distribución aproximadamente normal.

[ ]
df.hist(bins=15, figsize=(15, 10))
plt.suptitle("Distribución de cada variable", fontsize=14, y=1.01)
plt.tight_layout()
plt.show()
Output

1.3 Correlación con la variable objetivo

Las variables bmi y s5 son las más correlacionadas con la progresión de la enfermedad. Esta información guía nuestra intuición sobre qué patrones debería aprender la red.

[ ]
# Mapa de calor de la matriz de correlación
corr_matrix = df.corr()
plt.figure(figsize=(12, 10))
sns.heatmap(corr_matrix, annot=True, cmap="coolwarm", fmt=".2f")
plt.title("Matriz de correlación")
plt.show()

# Top correlaciones con target
print("\nCorrelación con la variable objetivo:")
print(corr_matrix["target"].sort_values(ascending=False).to_string())
Output
target    1.000000
bmi       0.586450
s5        0.565883
bp        0.441482
s4        0.430453
s6        0.382483
s1        0.212022
age       0.187889
s2        0.174054
sex       0.043062
s3       -0.394789
Name: target, dtype: float64

2. La red neuronal desde cero con NumPy

Implementamos una red con 2 capas ocultas (32 y 16 neuronas) y activación ReLU.

Entrada (10) → Dense(32) → ReLU → Dense(16) → ReLU → Dense(1) → Salida

La clase NeuralNetwork implementa:

  • Inicialización aleatoria de pesos y biases
  • Forward pass: $z = Wx + b$, luego $a = \text{ReLU}(z)$ en cada capa
  • Backward pass: cálculo de gradientes $\frac{\partial L}{\partial W}$ y $\frac{\partial L}{\partial b}$ por regla de la cadena
  • Actualización: $W \leftarrow W - \alpha \cdot \frac{\partial L}{\partial W}$
  • Función de pérdida: MSE = $\frac{1}{2m}\sum(y - \hat{y})^2$
[ ]
# ── Funciones de activación ────────────────────────────────
def relu(x):
    """ReLU: max(0, x) — introduce no linealidad."""
    return np.maximum(0, x)

def relu_derivative(x):
    """Derivada de ReLU: 1 si x > 0, 0 en otro caso."""
    return (x > 0).astype(float)


# ── Red neuronal implementada con NumPy ──────────────────
class NeuralNetwork:
    """
    MLP de 2 capas ocultas para regresión.
    Arquitectura: input → 32 → 16 → 1
    """
    def __init__(self, input_dim, lr=0.01, seed=42):
        np.random.seed(seed)
        self.lr = lr
        
        # Inicialización aleatoria de pesos (distribución normal escalada)
        self.W1 = np.random.randn(input_dim, 32) * 0.01
        self.b1 = np.zeros((1, 32))
        self.W2 = np.random.randn(32, 16) * 0.01
        self.b2 = np.zeros((1, 16))
        self.W3 = np.random.randn(16, 1) * 0.01
        self.b3 = np.zeros((1, 1))
    
    def forward(self, X):
        """Forward pass: propaga los datos capa a capa."""
        self.z1 = X @ self.W1 + self.b1          # Pre-activación capa 1
        self.a1 = relu(self.z1)                    # Activación ReLU
        self.z2 = self.a1 @ self.W2 + self.b2     # Pre-activación capa 2
        self.a2 = relu(self.z2)                    # Activación ReLU
        self.z3 = self.a2 @ self.W3 + self.b3     # Salida (lineal, regresión)
        return self.z3
    
    def compute_loss(self, y_true, y_pred):
        """MSE Loss: L = (1/2m) * Σ(y - ŷ)²"""
        m = y_true.shape[0]
        return np.mean((y_true - y_pred) ** 2) / 2
    
    def backward(self, X, y_true, y_pred):
        """Backward pass: calcula gradientes por regla de la cadena."""
        m = X.shape[0]
        
        # Gradiente de la pérdida respecto a la salida
        dz3 = (y_pred - y_true) / m
        
        # Capa 3 (salida)
        self.dW3 = self.a2.T @ dz3
        self.db3 = np.sum(dz3, axis=0, keepdims=True)
        
        # Capa 2
        da2 = dz3 @ self.W3.T
        dz2 = da2 * relu_derivative(self.z2)
        self.dW2 = self.a1.T @ dz2
        self.db2 = np.sum(dz2, axis=0, keepdims=True)
        
        # Capa 1
        da1 = dz2 @ self.W2.T
        dz1 = da1 * relu_derivative(self.z1)
        self.dW1 = X.T @ dz1
        self.db1 = np.sum(dz1, axis=0, keepdims=True)
    
    def update_parameters(self):
        """Descenso del gradiente: W ← W - α·∂L/∂W"""
        self.W1 -= self.lr * self.dW1
        self.b1 -= self.lr * self.db1
        self.W2 -= self.lr * self.dW2
        self.b2 -= self.lr * self.db2
        self.W3 -= self.lr * self.dW3
        self.b3 -= self.lr * self.db3
    
    def train(self, X, y, epochs=1000, X_test=None, y_test=None, verbose=True):
        """Bucle de entrenamiento completo."""
        train_losses = []
        test_losses = []
        
        for epoch in range(epochs):
            # Forward
            y_pred = self.forward(X)
            loss = self.compute_loss(y, y_pred)
            train_losses.append(loss)
            
            # Backward + update
            self.backward(X, y, y_pred)
            self.update_parameters()
            
            # Evaluar en test
            if X_test is not None:
                y_test_pred = self.forward(X_test)
                test_loss = self.compute_loss(y_test, y_test_pred)
                test_losses.append(test_loss)
                # Re-forward en train para restaurar activaciones
                self.forward(X)
            
            if verbose and (epoch + 1) % 200 == 0:
                msg = f"Epoch {epoch+1}/{epochs} — Train Loss: {loss:.4f}"
                if test_losses:
                    msg += f" | Test Loss: {test_losses[-1]:.4f}"
                print(msg)
        
        return train_losses, test_losses
    
    def predict(self, X):
        """Genera predicciones (forward pass sin gradientes)."""
        return self.forward(X)

print("✅ Clase NeuralNetwork definida")

3. Preparación de datos y entrenamiento

Estandarizamos tanto las características como la variable objetivo para que el descenso del gradiente converja más rápido. Recordemos: fit solo en train, transform en ambos.

[ ]
# ── Preparación de datos ──────────────────────────────────
X = diabetes.data
y = diabetes.target.reshape(-1, 1)  # Columna para compatibilidad matricial

# Split 80/20
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# Estandarizar X e y
scaler_X = StandardScaler()
scaler_y = StandardScaler()

X_train_s = scaler_X.fit_transform(X_train)
X_test_s = scaler_X.transform(X_test)
y_train_s = scaler_y.fit_transform(y_train)
y_test_s = scaler_y.transform(y_test)

print(f"Train: {X_train_s.shape[0]} muestras | Test: {X_test_s.shape[0]} muestras")
print(f"Features shape: {X_train_s.shape[1]} | Target shape: {y_train_s.shape[1]}")

3.1 Entrenamiento

Creamos la red y la entrenamos durante 1000 epochs con learning rate = 0.1. Observamos cómo evolucionan las pérdidas en train y test.

[ ]
# ── Crear y entrenar la red ───────────────────────────────
nn = NeuralNetwork(input_dim=X_train_s.shape[1], lr=0.1)
train_losses, test_losses = nn.train(
    X_train_s, y_train_s,
    epochs=1000,
    X_test=X_test_s, y_test=y_test_s
)

# ── Curvas de pérdida ──────────────────────────────────────
plt.figure(figsize=(10, 5))
plt.plot(train_losses, label="Train Loss", color="#6C5CE7", lw=2)
plt.plot(test_losses, label="Test Loss", color="#E17055", lw=2, linestyle="--")
plt.xlabel("Epoch")
plt.ylabel("MSE Loss")
plt.title("Curvas de entrenamiento y validación")
plt.legend()
plt.grid(alpha=0.3)
plt.show()

print(f"\nLoss final — Train: {train_losses[-1]:.4f} | Test: {test_losses[-1]:.4f}")
Epoch 0: Loss = 0.5125696294607077
Epoch 100: Loss = 0.5122755450727943
Epoch 200: Loss = 0.5120368625252987
Epoch 300: Loss = 0.5095651801931181
Epoch 400: Loss = 0.27894822119507817
Epoch 500: Loss = 0.23845086392388531
Epoch 600: Loss = 0.2307567498750363
Epoch 700: Loss = 0.22351454649942454
Epoch 800: Loss = 0.21944806405819262
Epoch 900: Loss = 0.21551759382587002
Output
Test loss: 0.2269616210018652

4. Evaluación: predicciones vs. valores reales

Visualizamos la calidad de las predicciones comparándolas con los valores reales. La línea punteada negra representa la predicción perfecta ($\hat{y} = y$). Cuanto más cerca estén los puntos de esta línea, mejor generaliza la red.

[ ]
# ── Predicciones en escala original ───────────────────────
y_train_pred = scaler_y.inverse_transform(nn.predict(X_train_s))
y_test_pred = scaler_y.inverse_transform(nn.predict(X_test_s))

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))

# Train
ax1.scatter(y_train, y_train_pred, alpha=0.6, color="#6C5CE7", s=30)
lims = [y_train.min(), y_train.max()]
ax1.plot(lims, lims, "k--", lw=1.5, label="Predicción perfecta")
ax1.set_xlabel("Valor real")
ax1.set_ylabel("Predicción")
ax1.set_title("Entrenamiento")
ax1.legend()
ax1.grid(alpha=0.3)

# Test
ax2.scatter(y_test, y_test_pred, alpha=0.6, color="#E17055", s=30)
lims = [y_test.min(), y_test.max()]
ax2.plot(lims, lims, "k--", lw=1.5, label="Predicción perfecta")
ax2.set_xlabel("Valor real")
ax2.set_ylabel("Predicción")
ax2.set_title("Test")
ax2.legend()
ax2.grid(alpha=0.3)

plt.suptitle("Predicciones vs. Valores Reales (escala original)", fontsize=14)
plt.tight_layout()
plt.show()
Output

5. Conclusiones

En este ejemplo hemos construido y entrenado una red neuronal sin ningún framework, lo que nos ha permitido ver cada paso del proceso:

  1. Forward pass: cada capa calcula $z = Wx + b$ y aplica la activación ReLU.
  2. Función de pérdida: MSE mide la distancia entre predicciones y valores reales.
  3. Backward pass: la regla de la cadena propaga el error desde la salida hasta la primera capa, calculando $\frac{\partial L}{\partial W}$ y $\frac{\partial L}{\partial b}$ en cada capa.
  4. Actualización: el descenso del gradiente ajusta los pesos en la dirección que reduce el error.

💡 Nota: En la práctica usamos frameworks como PyTorch o TensorFlow que implementan esto de forma optimizada (autograd, GPU, etc.), pero entender el mecanismo interno es fundamental para diagnosticar problemas de entrenamiento.