Efecto del Tamaño del Batch en el Entrenamiento
Comparación de batch completo, SGD estocástico y mini-batch entrenando un MLP con NumPy sobre California Housing: convergencia, estabilidad y tiempos.
⚡ Efecto del Tamaño del Batch en el Entrenamiento
Submódulo: Descenso del Gradiente · Fundamentos de Deep Learning
El tamaño del batch es uno de los hiperparámetros más importantes en el entrenamiento de redes neuronales. Determina cuántas muestras se procesan antes de actualizar los pesos y afecta directamente a:
- Velocidad de convergencia: más actualizaciones por época → convergencia potencialmente más rápida
- Estabilidad del gradiente: gradientes promediados sobre más muestras → menos ruido
- Tiempo de cómputo: batches grandes aprovechan mejor la paralelización
En este notebook implementamos un MLP desde cero con NumPy y lo entrenamos con tres estrategias distintas sobre el dataset California Housing:
| Método | Batch Size | Actualizaciones/Época |
|---|---|---|
| Batch completo (GD) | N (~16.500) | 1 |
| Estocástico (SGD) | 1 | ~16.500 |
| Mini-batch | 32 | ~516 |
1. Exploración del Dataset California Housing
El dataset contiene 20.640 muestras con 8 características sobre viviendas en California. La variable objetivo (MedHouseVal) es el valor medio de la vivienda en cientos de miles de dólares.
| Variable | Descripción |
|---|---|
| MedInc | Ingreso medio del área (miles $) |
| HouseAge | Edad media de las casas |
| AveRooms | Habitaciones promedio por hogar |
| AveBedrms | Dormitorios promedio por hogar |
| Population | Población del área |
| AveOccup | Ocupantes promedio por hogar |
| Latitude | Latitud |
| Longitude | Longitud |
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import time
from sklearn.datasets import fetch_california_housing
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
# ── Cargar dataset ────────────────────────────────────────
data = fetch_california_housing()
df = pd.DataFrame(data.data, columns=data.feature_names)
df['MedHouseVal'] = data.target
print(f"Dataset: {df.shape[0]:,} muestras, {df.shape[1]-1} features")
print(f"\nEstadísticas principales:")
df.describe().round(2)
1.1 Matriz de correlación
Analizamos las correlaciones entre variables. El ingreso medio (MedInc) muestra la correlación más fuerte con el precio de la vivienda.
# ── Matriz de correlación ────────────────────────────────
corr = df.corr()
plt.figure(figsize=(10, 8))
sns.heatmap(corr, annot=True, fmt='.2f', cmap='RdBu_r', center=0,
square=True, linewidths=0.5)
plt.title('Correlación entre variables', fontsize=14)
plt.tight_layout()
plt.show()
1.2 Distribución de las variables
Visualizamos la distribución de cada variable para detectar sesgos, valores atípicos y la forma general de los datos.
# ── Histogramas ────────────────────────────────────────────
fig, axes = plt.subplots(3, 3, figsize=(14, 10))
for i, col in enumerate(df.columns):
ax = axes.flat[i]
ax.hist(df[col], bins=40, alpha=0.7, color='#6C5CE7', edgecolor='white')
ax.set_title(col, fontsize=11)
ax.grid(alpha=0.3)
plt.suptitle('Distribución de cada variable', fontsize=14)
plt.tight_layout()
plt.show()
2. Preparación de datos
Estandarizamos tanto las features como el target (media=0, std=1) para mejorar la convergencia del gradiente y dividimos en train/test.
# ── Preparación ───────────────────────────────────────────
X, y = data.data, data.target.reshape(-1, 1)
# Estandarizar features y target
scaler_X = StandardScaler()
scaler_y = StandardScaler()
X = scaler_X.fit_transform(X)
y = scaler_y.fit_transform(y)
# Split train/test
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=11
)
print(f"Train: {X_train.shape} | Test: {X_test.shape}")
3. Red neuronal desde cero con NumPy
Implementamos un MLP con dos capas ocultas y activación ReLU:
Entrada (8) → Linear(64) → ReLU → Linear(32) → ReLU → Linear(1) → Salida
- Inicialización He: $W \sim \mathcal{N}(0, \sqrt{2/n_{in}})$ — ideal para ReLU
- Pérdida: MSE = $\frac{1}{m}\sum_{i=1}^{m}(\hat{y}_i - y_i)^2$
- Backpropagation: calculamos los gradientes capa por capa mediante la regla de la cadena
class NeuralNetwork:
"""
MLP de 2 capas ocultas (64 → 32 → 1) implementado con NumPy.
Usa ReLU, inicialización He e incluye forward, backward y update.
"""
def __init__(self, input_size, hidden1=64, hidden2=32, output_size=1, lr=0.01, seed=42):
np.random.seed(seed)
# Inicialización He (óptima para ReLU)
self.W1 = np.random.randn(input_size, hidden1) * np.sqrt(2. / input_size)
self.b1 = np.zeros((1, hidden1))
self.W2 = np.random.randn(hidden1, hidden2) * np.sqrt(2. / hidden1)
self.b2 = np.zeros((1, hidden2))
self.W3 = np.random.randn(hidden2, output_size) * np.sqrt(2. / hidden2)
self.b3 = np.zeros((1, output_size))
self.lr = lr
def relu(self, Z):
return np.maximum(0, Z)
def relu_derivative(self, Z):
return (Z > 0).astype(float)
def forward(self, X):
# Capa 1: entrada → 64 neuronas
self.Z1 = X @ self.W1 + self.b1
self.A1 = self.relu(self.Z1)
# Capa 2: 64 → 32 neuronas
self.Z2 = self.A1 @ self.W2 + self.b2
self.A2 = self.relu(self.Z2)
# Salida: 32 → 1 (regresión lineal)
self.Z3 = self.A2 @ self.W3 + self.b3
return self.Z3
def compute_loss(self, y_pred, y_true):
return np.mean((y_pred - y_true) ** 2)
def backward(self, X, y_true, y_pred):
m = y_true.shape[0]
# Gradiente de MSE: dL/dy_pred = 2(y_pred - y_true)/m
dZ3 = (y_pred - y_true) * (2 / m)
dW3 = self.A2.T @ dZ3
db3 = np.sum(dZ3, axis=0, keepdims=True)
dA2 = dZ3 @ self.W3.T
dZ2 = dA2 * self.relu_derivative(self.Z2)
dW2 = self.A1.T @ dZ2
db2 = np.sum(dZ2, axis=0, keepdims=True)
dA1 = dZ2 @ self.W2.T
dZ1 = dA1 * self.relu_derivative(self.Z1)
dW1 = X.T @ dZ1
db1 = np.sum(dZ1, axis=0, keepdims=True)
return {'dW1': dW1, 'db1': db1, 'dW2': dW2, 'db2': db2, 'dW3': dW3, 'db3': db3}
def update_parameters(self, grads):
for param in ['W1', 'b1', 'W2', 'b2', 'W3', 'b3']:
setattr(self, param, getattr(self, param) - self.lr * grads[f'd{param}'])
print('✅ Clase NeuralNetwork definida')
print(f' Arquitectura: 8 → 64 (ReLU) → 32 (ReLU) → 1')
print(f' Parámetros: {8*64+64 + 64*32+32 + 32*1+1:,}')
4. Función de entrenamiento
La función train_model acepta un batch_size que determina la estrategia de optimización:
| batch_size | Estrategia | Comportamiento |
|---|---|---|
| N (todo el dataset) | Gradient Descent (GD) | 1 actualización/época, gradiente exacto |
| 1 | SGD estocástico | N actualizaciones/época, gradiente muy ruidoso |
| 32 | Mini-batch SGD | N/32 actualizaciones/época, buen balance |
def train_model(model, X, y, epochs=50, batch_size=32):
"""Entrena el modelo con el batch_size especificado."""
history_epoch = [] # Pérdida media por época
history_batch = [] # Pérdida por batch (para ver estabilidad)
m = X.shape[0]
for epoch in range(epochs):
# Barajar datos cada época
idx = np.random.permutation(m)
X_shuf, y_shuf = X[idx], y[idx]
epoch_loss = 0
batch_losses = []
for i in range(0, m, batch_size):
X_b = X_shuf[i:i+batch_size]
y_b = y_shuf[i:i+batch_size]
y_pred = model.forward(X_b) # Forward
loss = model.compute_loss(y_pred, y_b) # Pérdida
grads = model.backward(X_b, y_b, y_pred) # Backward
model.update_parameters(grads) # Actualizar
epoch_loss += loss * X_b.shape[0]
batch_losses.append(loss)
epoch_loss /= m
history_epoch.append(epoch_loss)
history_batch.extend(batch_losses)
if (epoch + 1) % 2 == 0:
print(f' Época {epoch+1:>3}/{epochs} — MSE: {epoch_loss:.4f}')
return history_epoch, history_batch
print('✅ Función train_model definida')
5. Entrenamiento con los tres métodos
Entrenamos tres modelos idénticos (misma inicialización) variando únicamente el tamaño del batch:
- Batch completo (GD clásico): batch_size = N
- SGD puro: batch_size = 1
- Mini-batch: batch_size = 32
# ── Configuración ─────────────────────────────────────────
epochs = 10
lr = 0.001
# ── 1) Batch completo (GD clásico) ───────────────────────
print('═' * 50)
print('MÉTODO 1: Batch completo (GD)')
print('═' * 50)
model_full = NeuralNetwork(input_size=X_train.shape[1], lr=lr)
t0 = time.time()
hist_full, batches_full = train_model(model_full, X_train, y_train, epochs, batch_size=X_train.shape[0])
time_full = time.time() - t0
print(f' ⏱ Tiempo: {time_full:.3f}s\n')
# ── 2) SGD puro (batch_size = 1) ─────────────────────────
print('═' * 50)
print('MÉTODO 2: SGD (batch_size = 1)')
print('═' * 50)
model_sgd = NeuralNetwork(input_size=X_train.shape[1], lr=lr)
t0 = time.time()
hist_sgd, batches_sgd = train_model(model_sgd, X_train, y_train, epochs, batch_size=1)
time_sgd = time.time() - t0
print(f' ⏱ Tiempo: {time_sgd:.3f}s\n')
# ── 3) Mini-batch (batch_size = 32) ──────────────────────
print('═' * 50)
print('MÉTODO 3: Mini-batch (batch_size = 32)')
print('═' * 50)
model_mini = NeuralNetwork(input_size=X_train.shape[1], lr=lr)
t0 = time.time()
hist_mini, batches_mini = train_model(model_mini, X_train, y_train, epochs, batch_size=32)
time_mini = time.time() - t0
print(f' ⏱ Tiempo: {time_mini:.3f}s')
Entrenamiento con batch completo Época 2/10 - Pérdida: 2.1975 Época 4/10 - Pérdida: 2.0170 Época 6/10 - Pérdida: 1.8635 Época 8/10 - Pérdida: 1.7325 Época 10/10 - Pérdida: 1.6202 Tiempo de entrenamiento (batch completo): 0.3165 segundos Entrenamiento con SGD (batch de 1) Época 2/10 - Pérdida: 0.4071 Época 4/10 - Pérdida: 0.3140 Época 6/10 - Pérdida: 0.2908 Época 8/10 - Pérdida: 0.2757 Época 10/10 - Pérdida: 0.2655 Tiempo de entrenamiento (SGD): 8.6939 segundos Entrenamiento con mini-batch (tamaño 32) Época 2/10 - Pérdida: 0.5006 Época 4/10 - Pérdida: 0.4386 Época 6/10 - Pérdida: 0.4167 Época 8/10 - Pérdida: 0.3805 Época 10/10 - Pérdida: 0.3922 Tiempo de entrenamiento (mini-batch): 0.3889 segundos
6. Comparación de resultados
6.1 Evolución de la pérdida por época
Comparamos la curva de pérdida (MSE) media por época para cada método. Observa:
- GD clásico: convergencia suave pero con solo 1 actualización por época
- SGD: muchas más actualizaciones → puede converger más rápido
- Mini-batch: equilibrio entre las dos anteriores
# ── Pérdida por época ─────────────────────────────────────
plt.figure(figsize=(10, 5))
plt.plot(hist_full, 'o-', label=f'Batch completo ({time_full:.2f}s)', lw=2)
plt.plot(hist_sgd, 's-', label=f'SGD, bs=1 ({time_sgd:.2f}s)', lw=2)
plt.plot(hist_mini, '^-', label=f'Mini-batch, bs=32 ({time_mini:.2f}s)', lw=2)
plt.xlabel('Época', fontsize=12)
plt.ylabel('MSE', fontsize=12)
plt.title('Evolución de la pérdida por época', fontsize=14)
plt.legend(fontsize=11)
plt.grid(alpha=0.3)
plt.tight_layout()
plt.show()
6.2 Evolución batch a batch (estabilidad)
Al visualizar la pérdida en cada actualización (no solo por época), se aprecia claramente la diferencia en estabilidad entre los métodos:
- GD: una línea plana (1 punto por época)
- SGD: fluctuaciones enormes (ruido alto del gradiente)
- Mini-batch: fluctuaciones moderadas, tendencia descendente clara
# ── Pérdida batch a batch ────────────────────────────────
# Interpolamos para que los 3 tengan la misma longitud
max_len = len(batches_sgd)
# GD: 1 punto por época → repetir para igualar
full_interp = []
pts_per_epoch = max_len // epochs
for val in batches_full:
full_interp.extend([val] * pts_per_epoch)
# Mini-batch: interpolar
mini_interp = []
pts_mini_epoch = max_len // epochs
batches_per_epoch_mini = len(batches_mini) // epochs
for ep in range(epochs):
ep_batches = batches_mini[ep*batches_per_epoch_mini:(ep+1)*batches_per_epoch_mini]
ratio = pts_mini_epoch // max(len(ep_batches), 1)
for v in ep_batches:
mini_interp.extend([v] * ratio)
# Submuestrear para la visualización
step = max(1, max_len // 500)
plt.figure(figsize=(12, 5))
plt.plot(full_interp[::step], label='Batch completo', alpha=0.9, lw=1.5)
plt.plot(batches_sgd[::step], label='SGD (bs=1)', alpha=0.5, lw=0.5)
plt.plot(mini_interp[::step], label='Mini-batch (bs=32)', alpha=0.7, lw=1)
plt.ylim(0, 4)
plt.xlabel('Paso de actualización', fontsize=12)
plt.ylabel('MSE', fontsize=12)
plt.title('Estabilidad del entrenamiento (pérdida por batch)', fontsize=14)
plt.legend(fontsize=11)
plt.grid(alpha=0.3)
plt.tight_layout()
plt.show()
6.3 Comparación de tiempos
# ── Tiempo de entrenamiento ──────────────────────────────
methods = ['Batch completo', 'SGD (bs=1)', 'Mini-batch (bs=32)']
times = [time_full, time_sgd, time_mini]
colors = ['#6C5CE7', '#00B894', '#FDCB6E']
fig, ax = plt.subplots(figsize=(8, 4))
bars = ax.barh(methods, times, color=colors, edgecolor='white', height=0.5)
for bar, t in zip(bars, times):
ax.text(bar.get_width() + 0.05, bar.get_y() + bar.get_height()/2,
f'{t:.2f}s', va='center', fontsize=12)
ax.set_xlabel('Tiempo (segundos)', fontsize=12)
ax.set_title('Tiempo de entrenamiento por método', fontsize=14)
ax.grid(axis='x', alpha=0.3)
plt.tight_layout()
plt.show()
7. Conclusiones
| Aspecto | Batch Completo (GD) | SGD (bs=1) | Mini-batch (bs=32) |
|---|---|---|---|
| Gradiente | Exacto | Muy ruidoso | Buen balance |
| Actualizaciones/época | 1 | N | N/32 |
| Estabilidad | ✅ Muy estable | ❌ Muy inestable | ✅ Moderada |
| Velocidad cómputo | Rápido/época | Lento (overhead Python) | Intermedio |
| Escapar mínimos locales | ❌ Difícil | ✅ Ruido ayuda | ✅ Algo de ruido |
En la práctica, el mini-batch es la estrategia más utilizada porque:
- Ofrece un buen equilibrio entre estabilidad y velocidad de convergencia
- Aprovecha la paralelización GPU mejor que SGD puro
- Tiene suficiente ruido para escapar de mínimos locales subóptimos
💡 Tamaños típicos de batch: 32, 64, 128 o 256. Potencias de 2 para optimizar el uso de memoria GPU.