Sesgo y Varianza: Dataset de Diabetes
Caso de uso práctico donde entrenamos redes neuronales de diferente complejidad con PyTorch sobre el dataset de diabetes para observar empíricamente el compromiso sesgo-varianza.
🏥 Caso de Uso: Sesgo y Varianza en el Entrenamiento de Redes Neuronales
Submódulo: Entrenamiento de Redes Neuronales · Fundamentos de Deep Learning
Introducción
Uno de los conceptos más importantes en el entrenamiento de redes neuronales es el compromiso entre sesgo (bias) y varianza (variance). Este equilibrio determina si nuestro modelo:
- Subajusta (underfitting): el modelo es demasiado simple para capturar los patrones de los datos → alto sesgo.
- Sobreajusta (overfitting): el modelo memoriza los datos de entrenamiento pero no generaliza → alta varianza.
En este caso de uso, utilizaremos el Diabetes Dataset de scikit-learn (442 pacientes, 10 características clínicas) para entrenar redes neuronales de diferente complejidad con PyTorch y observar empíricamente cómo el sesgo y la varianza cambian a medida que aumentamos la capacidad del modelo. Este análisis es fundamental para elegir la arquitectura correcta y aplicar técnicas de regularización adecuadas.
1. Carga y exploración de los datos
Comenzamos importando las librerías necesarias y cargando el dataset. El dataset de diabetes contiene 10 variables clínicas (edad, sexo, IMC, presión arterial y 6 medidas séricas) ya normalizadas con media 0 y norma L2 unitaria. 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
from sklearn.metrics import r2_score, mean_absolute_error
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
# ── Cargar el dataset de diabetes ──────────────────────────
diabetes = load_diabetes()
X = diabetes.data # (442, 10) — 10 características clínicas
y = diabetes.target # (442,) — progresión de la enfermedad
# Convertimos a DataFrame para facilitar la exploración
df = pd.DataFrame(X, columns=diabetes.feature_names)
df["target"] = y
print(f"Dataset: {X.shape[0]} pacientes, {X.shape[1]} características")
print(f"Variable objetivo — min: {y.min():.0f}, max: {y.max():.0f}, media: {y.mean():.1f}")
1.1 Estadísticas descriptivas
Antes de entrenar cualquier modelo, es esencial entender la distribución y escala de nuestros datos. Un buen análisis exploratorio nos ayuda a detectar problemas de preprocesamiento que podrían dificultar el entrenamiento (variables con escalas muy diferentes, valores atípicos, etc.).
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
Los histogramas nos revelan la forma de cada distribución. Observa que las características ya están centradas en torno a 0 (preprocesadas por sklearn), lo que facilita el entrenamiento con descenso del gradiente. La variable objetivo (target) tiene una distribución aproximadamente normal con cola derecha.
df.hist(bins=15, figsize=(15,10))
plt.tight_layout()
plt.show()
1.3 Matriz de correlación
La correlación nos indica qué características están más relacionadas con la variable objetivo. Variables con alta correlación positiva (como bmi, s5) serán los predictores más potentes. Esto nos da intuición sobre qué debe aprender la red neuronal.
# Correlación con la variable objetivo
corr_matrix = df.corr()
plt.figure(figsize=(12,10))
sns.heatmap(corr_matrix, annot=True, cmap='coolwarm')
plt.show()
# Correlación específica con la variable objetivo
corr_target = corr_matrix['target'].sort_values(ascending=False)
print(corr_target)
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. Construcción y entrenamiento del modelo con PyTorch
Pasamos ahora a la fase central: entrenar una red neuronal profunda para predecir la progresión de la diabetes. Seguiremos el pipeline estándar de entrenamiento:
- Preparar los datos: split train/test, estandarización, creación de DataLoaders
- Definir la arquitectura: red fully-connected con activaciones ReLU
- Configurar el entrenamiento: función de pérdida (MSE) + optimizador (Adam)
- Bucle de entrenamiento: forward pass → cálculo de loss → backpropagation → actualización de pesos
# ── Preparación de datos para entrenamiento ────────────────
# Separar características (X) y variable objetivo (y)
X = df.drop("target", axis=1).values
y = df["target"].values.reshape(-1, 1) # Reshape a columna para compatibilidad
# Split 80% entrenamiento / 20% test
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# Estandarización: media=0, std=1
# IMPORTANTE: fit SOLO en train, transform en ambos (evita data leakage)
scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test)
# Convertir arrays NumPy → tensores PyTorch
X_train_tensor = torch.FloatTensor(X_train)
y_train_tensor = torch.FloatTensor(y_train)
X_test_tensor = torch.FloatTensor(X_test)
y_test_tensor = torch.FloatTensor(y_test)
# DataLoaders: permiten iterar en mini-batches durante el entrenamiento
train_dataset = TensorDataset(X_train_tensor, y_train_tensor)
test_dataset = TensorDataset(X_test_tensor, y_test_tensor)
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=32, shuffle=False)
print(f"Train: {len(train_dataset)} muestras | Test: {len(test_dataset)} muestras")
2.1 Arquitectura de la red neuronal
Definimos una red con dos capas ocultas y activaciones ReLU. El parámetro n1 y n2 controla el número de neuronas en cada capa, lo que determina la capacidad (complejidad) del modelo. Más adelante variaremos estos valores para estudiar el sesgo y la varianza.
Entrada (10) → Linear(n1) → ReLU → Linear(n2) → ReLU → Linear(1) → Salida
# ── Definición de la red neuronal ───────────────────────────
class DiabetesNet(nn.Module):
"""
Red fully-connected parametrizable.
- n1: neuronas en la primera capa oculta
- n2: neuronas en la segunda capa oculta
Variando n1 y n2 controlamos la capacidad del modelo.
"""
def __init__(self, n1=64, n2=32):
super(DiabetesNet, self).__init__()
self.fc1 = nn.Linear(10, n1) # Capa 1: 10 entradas → n1 neuronas
self.relu1 = nn.ReLU()
self.fc2 = nn.Linear(n1, n2) # Capa 2: n1 → n2 neuronas
self.relu2 = nn.ReLU()
self.fc3 = nn.Linear(n2, 1) # Salida: n2 → 1 (regresión)
def forward(self, x):
out = self.relu1(self.fc1(x)) # Forward pass capa 1
out = self.relu2(self.fc2(out)) # Forward pass capa 2
out = self.fc3(out) # Predicción final (sin activación → regresión)
return out
2.2 Función de pérdida y optimizador
Usamos MSE (Mean Squared Error) como función de pérdida, natural para problemas de regresión. Como optimizador, elegimos Adam — una variante de SGD que adapta la tasa de aprendizaje por parámetro, ideal como punto de partida.
# Instanciamos el modelo con capacidad intermedia (64, 32)
model = DiabetesNet(64, 32)
print(f"Parámetros totales: {sum(p.numel() for p in model.parameters()):,}")
2.3 Bucle de entrenamiento
El bucle de entrenamiento es el corazón de cualquier modelo de deep learning. En cada epoch:
- Forward pass: la red calcula predicciones para cada mini-batch
- Cálculo de pérdida: comparamos predicciones vs. valores reales (MSE)
- Backward pass: calculamos gradientes con
loss.backward()(backpropagation) - Actualización de pesos: el optimizador ajusta los parámetros con
optimizer.step()
Registramos la pérdida tanto en train como en test para monitorizar el entrenamiento.
# ── Función de entrenamiento reutilizable ──────────────────
def train_model(model, num_epochs=1000, lr=0.01, verbose=False):
"""
Entrena el modelo y devuelve las curvas de pérdida (train y test).
"""
train_losses = []
test_losses = []
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
for epoch in range(num_epochs):
# ── Fase de entrenamiento ──
model.train()
running_loss = 0.0
for features, labels in train_loader:
optimizer.zero_grad() # Limpiar gradientes acumulados
outputs = model(features) # Forward pass
loss = criterion(outputs, labels) # Calcular pérdida
loss.backward() # Backpropagation
optimizer.step() # Actualizar pesos
running_loss += loss.item() * features.size(0)
epoch_loss = running_loss / len(train_loader.dataset)
train_losses.append(epoch_loss)
# ── Fase de evaluación (sin gradientes) ──
model.eval()
with torch.no_grad():
test_running_loss = 0.0
for features, labels in test_loader:
outputs = model(features)
loss = criterion(outputs, labels)
test_running_loss += loss.item() * features.size(0)
test_epoch_loss = test_running_loss / len(test_loader.dataset)
test_losses.append(test_epoch_loss)
if (epoch + 1) % 100 == 0 and verbose:
print(f"Epoch [{epoch+1}/{num_epochs}] — Train Loss: {epoch_loss:.4f} | Val Loss: {test_epoch_loss:.4f}")
return train_losses, test_losses
# Entrenamos el modelo durante 100 epochs
train_losses, test_losses = train_model(model, num_epochs=100, verbose=True)
Epoch [100/100], Loss: 2425.1490, Val Loss: 2829.2424
2.4 Curvas de entrenamiento
Las curvas de pérdida nos dan información crucial sobre el proceso de entrenamiento:
- Si ambas curvas bajan juntas → el modelo está aprendiendo y generalizando bien.
- Si la curva de train baja pero la de test sube → estamos sobreajustando (overfitting).
- Si ambas se estancan en un valor alto → el modelo no tiene suficiente capacidad (underfitting).
# Curvas de entrenamiento
plt.figure(figsize=(10,5))
plt.plot(range(1, len(train_losses)+1), train_losses, label='Pérdida de entrenamiento')
plt.plot(range(1, len(test_losses)+1), test_losses, label='Pérdida de validación')
plt.xlabel('Épocas')
plt.ylabel('Pérdida (MSE)')
plt.title('Curvas de entrenamiento y validación')
plt.legend()
plt.show()
2.5 Métricas de evaluación
Evaluamos el modelo con tres métricas complementarias:
- R² (coeficiente de determinación): proporción de varianza explicada. R²=1 es perfecto; R²=0 es equivalente a predecir siempre la media.
- MAE (Mean Absolute Error): error promedio en las mismas unidades que la variable objetivo.
- MSE (Mean Squared Error): penaliza más los errores grandes al elevarlos al cuadrado.
# ── Función de evaluación reutilizable ─────────────────────
def get_metrics(model, X_tensor, y_true, verbose=True):
"""Calcula R², MAE y MSE para un modelo dado."""
model.eval()
with torch.no_grad():
predictions = model(X_tensor).numpy()
r2 = r2_score(y_true, predictions)
mae = mean_absolute_error(y_true, predictions)
criterion = nn.MSELoss()
mse = criterion(torch.FloatTensor(predictions), torch.FloatTensor(y_true)).item()
if verbose:
print(f"R² (coef. determinación): {r2:.4f}")
print(f"MAE (error absoluto medio): {mae:.4f}")
print(f"MSE (error cuadrático medio): {mse:.4f}")
return r2, mae, mse
get_metrics(model, X_test_tensor, y_test)
Coeficiente de determinación R2: 0.4660 Error absoluto medio MAE: 42.5969 Error cuadrático medio MSE: 2829.2422
(0.46599445752062485, 42.5969001684296, 2829.2421875)
2.6 Predicciones vs. valores reales
Un scatter plot donde la diagonal perfecta (línea punteada) representa predicción = realidad. Cuanto más agrupados estén los puntos alrededor de esta línea, mejor generaliza nuestro modelo.
predictions = model(X_test_tensor).detach().numpy()
true_values = y_test
plt.figure(figsize=(10,5))
plt.scatter(true_values, predictions, alpha=0.7)
plt.xlabel('Valores reales')
plt.ylabel('Predicciones')
plt.title('Comparación de valores reales vs predicciones')
plt.plot([true_values.min(), true_values.max()], [true_values.min(), true_values.max()], 'k--')
plt.show()
3. Análisis del compromiso Sesgo-Varianza
Llegamos al núcleo del ejercicio. Vamos a entrenar modelos de diferente complejidad (número de neuronas) y comparar su rendimiento en entrenamiento vs. test.
Intuición:
- Un modelo muy simple (pocas neuronas) tiene alto sesgo: no puede capturar la relación subyacente → error alto tanto en train como en test.
- Un modelo muy complejo (muchas neuronas) tiene alta varianza: se ajusta al ruido de train → error bajo en train pero alto en test.
Para aislar mejor este efecto, usaremos un split más grande para test (40%) y entrenaremos durante más épocas.
# ── Preparar datos con split más grande para test ──────────
X = df.drop("target", axis=1).values
y = df["target"].values.reshape(-1, 1)
# 60% train / 40% test — más datos de test para medir varianza
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.4, random_state=42)
scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test)
X_train_tensor = torch.FloatTensor(X_train)
y_train_tensor = torch.FloatTensor(y_train)
X_test_tensor = torch.FloatTensor(X_test)
y_test_tensor = torch.FloatTensor(y_test)
train_dataset = TensorDataset(X_train_tensor, y_train_tensor)
test_dataset = TensorDataset(X_test_tensor, y_test_tensor)
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=32, shuffle=False)
print(f"Train: {len(train_dataset)} | Test: {len(test_dataset)} (split 60/40)")
3.1 Entrenamiento con cuatro niveles de complejidad
Entrenamos cuatro arquitecturas con capacidad creciente:
| Modelo | Capa 1 | Capa 2 | Parámetros aprox. |
|---|---|---|---|
| Muy simple | 4 | 2 | ~54 |
| Intermedio | 16 | 8 | ~305 |
| Complejo | 64 | 32 | ~3,137 |
| Muy complejo | 128 | 64 | ~11,585 |
n_epochs = 600
# Modelo súper simple
model_0 = DiabetesNet(4,2)
train_losses_0, test_losses_0 = train_model(model_0, num_epochs=n_epochs, verbose=False)
r20, mae0, mse0 = get_metrics(model_0, X_test_tensor, y_test, verbose=False)
r20_train, mae0_train, mse0_train = get_metrics(model_0, X_train_tensor, y_train, verbose=False)
# Modelo intermedio
model_1 = DiabetesNet(16,8)
train_losses_1, test_losses_1 = train_model(model_1, num_epochs=n_epochs, verbose=False)
r21, mae1, mse1 = get_metrics(model_1, X_test_tensor, y_test, verbose=False)
r21_train, mae1_train, mse1_train = get_metrics(model_1, X_train_tensor, y_train, verbose=False)
# Modelo complejo
model_2 = DiabetesNet(64,32)
train_losses_2, test_losses_2 = train_model(model_2, num_epochs=n_epochs, verbose=False)
r22, mae2, mse2 = get_metrics(model_2, X_test_tensor, y_test, verbose=False)
r22_train, mae2_train, mse2_train = get_metrics(model_2, X_train_tensor, y_train, verbose=False)
# Modelo super complejo
model_3 = DiabetesNet(128,64)
train_losses_3, test_losses_3 = train_model(model_3, num_epochs=n_epochs, verbose=False)
r23, mae3, mse3 = get_metrics(model_3, X_test_tensor, y_test, verbose=False)
r23_train, mae3_train, mse3_train = get_metrics(model_3, X_train_tensor, y_train, verbose=False)
3.2 Comparación de curvas de entrenamiento y validación
Visualizamos las curvas de pérdida de los 4 modelos. Las líneas sólidas representan la pérdida en entrenamiento y las punteadas la pérdida en validación. Observa:
- El modelo (4,2) no consigue bajar la pérdida → alto sesgo (underfitting)
- Los modelos grandes divergen entre train y test → alta varianza (overfitting)
plt.figure(figsize=(10,5))
plt.xlabel('Épocas')
plt.ylabel('Pérdida (MSE)')
plt.title('Curvas de validación para diferentes modelos')
tl0 = test_losses_0[::1] # smooth out the curve
tl1 = test_losses_1[::1]
tl2 = test_losses_2[::1]
tl3 = test_losses_3[::1]
tr0 = train_losses_0[::1]
tr1 = train_losses_1[::1]
tr2 = train_losses_2[::1]
tr3 = train_losses_3[::1]
# plot train as solid lines and test as dashed
plt.plot(range(1, len(tr0)+1), tr0, label='Entrenamiento (4,2)', color='blue')
plt.plot(range(1, len(tl0)+1), tl0, label='Validación (4,2)', color='blue', linestyle='--')
plt.plot(range(1, len(tr1)+1), tr1, label='Entrenamiento (16,8)', color='red')
plt.plot(range(1, len(tl1)+1), tl1, label='Validación (16,8)', color='red', linestyle='--')
plt.plot(range(1, len(tr2)+1), tr2, label='Entrenamiento (64,32)', color='green')
plt.plot(range(1, len(tl2)+1), tl2, label='Validación (64,32)', color='green', linestyle='--')
plt.plot(range(1, len(tr3)+1), tr3, label='Entrenamiento (128,64)', color='purple')
plt.plot(range(1, len(tl3)+1), tl3, label='Validación (128,64)', color='purple', linestyle='--')
plt.legend()
3.3 Comparación de MSE (entrenamiento vs. validación)
El gráfico de barras muestra claramente la brecha entre MSE de entrenamiento (contorno rojo) y validación (barras azules). Cuanto mayor es la brecha, mayor es el sobreajuste. Un modelo ideal tiene ambas barras bajas y similares.
# Plot the mse values for train and test
plt.figure(figsize=(10,5))
plt.xlabel('Modelo')
plt.ylabel('MSE')
plt.title('Comparación de MSE para diferentes modelos')
# plot both train and test as bars with no fill
plt.bar([0,1,2,3], [mse0, mse1, mse2, mse3], label='Validación', color='blue', fill=True, lw=3, alpha=0.7)
plt.bar([0,1,2,3], [mse0_train, mse1_train, mse2_train, mse3_train], label='Entrenamiento', color='red', fill=False, lw=3, alpha=0.7)
plt.legend()
plt.show()
3.4 Cálculo de la varianza de las predicciones
La varianza mide cuánto cambian las predicciones del modelo respecto a los valores reales. Un modelo con alta varianza produce predicciones muy dispersas — indica que está capturando ruido en lugar de la señal verdadera.
# ── Función para calcular varianza de los errores ──────────
def variance(model, X_test, y_test):
"""Varianza de los residuos (predicción - valor real)."""
predictions = model(X_test).detach().numpy()
true_values = np.array(y_test)
return np.var(predictions - true_values)
vars = []
for model in [model_0, model_1, model_2, model_3]:
var = variance(model, X_test_tensor, y_test)
vars.append(var)
plt.figure(figsize=(10,5))
plt.xlabel('Modelo')
plt.ylabel('Varianza')
plt.title('Varianza de MSE para diferentes modelos')
plt.bar([0,1,2,3], vars, color='blue', fill=True, lw=3, alpha=0.7)
plt.show()
4. Barrido completo de complejidad: la curva de Sesgo vs. Varianza
Finalmente, realizamos un barrido sistemático variando el número de neuronas de 10 a 190. Para cada configuración entrenamos un modelo completo y medimos su MSE (train y test) y la varianza de sus predicciones. Esto nos permite construir las curvas clásicas de sesgo-varianza.
# ── Barrido de complejidad ──────────────────────────────────
model_complexity = np.arange(10, 200, 10, dtype=int)
vars_list = []
mse_test = []
mse_train = []
for i, n in enumerate(model_complexity):
print(f"Entrenando modelo ({n}, {n//2}) — [{i+1}/{len(model_complexity)}]...")
model = DiabetesNet(n, n // 2)
train_losses, test_losses = train_model(model, num_epochs=400, verbose=False)
# Métricas en test y train
r2, mae, mse = get_metrics(model, X_test_tensor, y_test, verbose=False)
r2train, maetrain, msetrain = get_metrics(model, X_train_tensor, y_train, verbose=False)
vars_list.append(variance(model, X_test_tensor, y_test))
mse_test.append(mse)
mse_train.append(msetrain)
print("✅ Barrido completado")
Training model with complexity 10 (1/19)... Training model with complexity 20 (2/19)... Training model with complexity 30 (3/19)... Training model with complexity 40 (4/19)... Training model with complexity 50 (5/19)... Training model with complexity 60 (6/19)... Training model with complexity 70 (7/19)... Training model with complexity 80 (8/19)... Training model with complexity 90 (9/19)... Training model with complexity 100 (10/19)... Training model with complexity 110 (11/19)... Training model with complexity 120 (12/19)... Training model with complexity 130 (13/19)... Training model with complexity 140 (14/19)... Training model with complexity 150 (15/19)... Training model with complexity 160 (16/19)... Training model with complexity 170 (17/19)... Training model with complexity 180 (18/19)... Training model with complexity 190 (19/19)...
# ── Gráfico 1: Varianza vs. Complejidad ────────────────────
plt.figure(figsize=(10, 5))
plt.plot(model_complexity, vars_list, color="blue", lw=3, marker="o", markersize=4)
plt.xlabel("Complejidad del modelo (neuronas en capa 1)")
plt.ylabel("Varianza de los residuos")
plt.title("Varianza vs. Complejidad del modelo")
plt.grid(alpha=0.3)
plt.show()
# ── Gráfico 2: MSE Train vs. Complejidad ──────────────────
plt.figure(figsize=(10, 5))
plt.plot(model_complexity, mse_train, label="MSE Entrenamiento", color="red", lw=3, marker="o", markersize=4)
plt.plot(model_complexity, mse_test, label="MSE Validación", color="blue", lw=3, marker="s", markersize=4)
plt.xlabel("Complejidad del modelo (neuronas en capa 1)")
plt.ylabel("MSE")
plt.title("MSE vs. Complejidad del modelo")
plt.legend()
plt.grid(alpha=0.3)
plt.show()
# ── Gráfico final: Sesgo vs. Varianza (normalizado) ────────
# Normalizamos ambas métricas para visualizarlas en la misma escala
vars_norm = np.array(vars_list) / np.max(vars_list)
mse_train_norm = np.array(mse_train) / np.max(mse_train)
plt.figure(figsize=(10, 5))
plt.plot(model_complexity, mse_train_norm, label="Sesgo (MSE train normalizado)", color="#E17055", lw=3)
plt.plot(model_complexity, vars_norm, label="Varianza (normalizada)", color="#6C5CE7", lw=3)
plt.fill_between(model_complexity, mse_train_norm, alpha=0.1, color="#E17055")
plt.fill_between(model_complexity, vars_norm, alpha=0.1, color="#6C5CE7")
plt.xlabel("Complejidad del modelo (neuronas en capa 1)")
plt.ylabel("Valor normalizado")
plt.title("Compromiso Sesgo-Varianza")
plt.legend(fontsize=12)
plt.grid(alpha=0.3)
plt.tight_layout()
plt.show()
5. Conclusiones
En este caso de uso hemos observado empíricamente el compromiso sesgo-varianza:
Modelos demasiado simples (pocas neuronas) tienen alto sesgo: su MSE de entrenamiento ya es alto porque no pueden capturar los patrones. La varianza es baja porque sus predicciones son consistentemente "malas".
Modelos demasiado complejos (muchas neuronas) tienen alta varianza: su MSE de entrenamiento es muy bajo (memorizan), pero el MSE de test es alto. La varianza crece porque las predicciones son sensibles a los datos específicos de entrenamiento.
El punto óptimo está en una complejidad intermedia donde el error total (sesgo² + varianza) es mínimo. En la práctica, esto se puede encontrar mediante:
- Validación cruzada para estimar el error de generalización
- Early stopping para detener el entrenamiento antes del sobreajuste
- Regularización (L1, L2, dropout) para penalizar modelos complejos
- Más datos para reducir la varianza sin aumentar el sesgo
💡 Concepto clave: el error de generalización se descompone como: $E_{total} = Sesgo^2 + Varianza + Ruido\ irreducible$. No podemos reducir ambos simultáneamente — toda decisión de diseño implica un compromiso.