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.
🧠 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()
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())
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
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()
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:
- Forward pass: cada capa calcula $z = Wx + b$ y aplica la activación ReLU.
- Función de pérdida: MSE mide la distancia entre predicciones y valores reales.
- 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.
- 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.