🏭 Caso de Uso

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.

🐍 Python 📓 Jupyter Notebook

⚡ 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()
Output

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()
Output
Output
Output
Output
Output
Output
Output
Output
Output

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:

  1. Batch completo (GD clásico): batch_size = N
  2. SGD puro: batch_size = 1
  3. 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()
Output

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()
Output

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()
Output

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.