🏭 Caso de Uso

Impacto de la Inicialización de Pesos

Comparativa experimental de 6 estrategias de inicialización (ceros, unos, normal, uniforme, Xavier, He) sobre un MLP con FashionMNIST.

🐍 Python 📓 Jupyter Notebook

Impacto de la Inicialización de Pesos en Redes Neuronales

Comparativa experimental de 6 estrategias de inicialización sobre un MLP con FashionMNIST


¿Por qué importa cómo inicializamos los pesos?

Cuando creamos una red neuronal, todos sus pesos parten de un valor inicial. Podría parecer un detalle menor — al fin y al cabo, el optimizador los irá ajustando durante el entrenamiento. Pero la realidad es muy distinta: la inicialización de pesos determina si la red puede aprender, a qué velocidad lo hace y qué rendimiento final alcanza.

El problema fundamental tiene que ver con cómo se propagan las señales a través de las capas. En un MLP con $L$ capas ocultas, la salida de cada capa $l$ se calcula como:

$$z^{(l)} = W^{(l)} \cdot a^{(l-1)} + b^{(l)}, \qquad a^{(l)} = f(z^{(l)})$$

donde $W^{(l)}$ son los pesos, $a^{(l-1)}$ las activaciones de la capa anterior, $b^{(l)}$ los sesgos y $f$ la función de activación.

Si asumimos que los pesos $W_{ij}$ y las entradas $a_j$ son independientes y de media cero, la varianza de la pre-activación en cada capa es:

$$\text{Var}(z^{(l)}) = n_{l-1} \cdot \text{Var}(W^{(l)}) \cdot \text{Var}(a^{(l-1)})$$

donde $n_{l-1}$ es el número de neuronas de la capa anterior (fan-in). Esto tiene dos consecuencias inmediatas:

Si $n_{l-1} \cdot \text{Var}(W) > 1$ Si $n_{l-1} \cdot \text{Var}(W) < 1$
La varianza crece capa a capa La varianza decrece capa a capa
Explosión de activaciones Desvanecimiento de activaciones
Gradientes enormes, NaN Gradientes ~0, no aprende

El objetivo de una buena inicialización es elegir $\text{Var}(W)$ de forma que la varianza se mantenga estable a lo largo de todas las capas.

Un ejemplo numérico rápido

Para entender la intuición, pensemos en una red con capas de 256 neuronas. Si inicializamos los pesos con una distribución $\mathcal{N}(0, 1)$ (media 0, varianza 1):

$$\text{Var}(z^{(l)}) = 256 \times 1 \times \text{Var}(a^{(l-1)}) = 256 \cdot \text{Var}(a^{(l-1)})$$

Es decir, la varianza se multiplica por 256 en cada capa. Tras 4 capas, la varianza se ha multiplicado por $256^4 \approx 4.3 \times 10^9$. ¡Los valores explotan!

En cambio, con inicialización He ($\text{Var}(W) = 2/n_{in} = 2/256$):

$$\text{Var}(z^{(l)}) = 256 \times \frac{2}{256} \times \text{Var}(a^{(l-1)}) = 2 \cdot \text{Var}(a^{(l-1)})$$

El factor 2 compensa exactamente el hecho de que ReLU anula la mitad de los valores (los negativos), por lo que $\text{Var}(a^{(l)}) \approx \text{Var}(a^{(l-1)})$: la varianza se mantiene estable.

Veamos esto en código:

[1]
import numpy as np

np.random.seed(42)

n_neurons = 256
n_layers = 4

# Simulamos la propagación de varianza con distintas inicializaciones
print("=" * 65)
print("Propagación de varianza a través de 4 capas (256 neuronas/capa)")
print("=" * 65)

# Señal de entrada simulada (por ejemplo, píxeles normalizados)
x = np.random.randn(1000, n_neurons) * 0.5  # Var ≈ 0.25

strategies = {
    "Normal(0,1)":   lambda n_in: np.random.randn(n_in, n_in) * 1.0,
    "Uniform[-1,1]": lambda n_in: np.random.uniform(-1, 1, (n_in, n_in)),
    "Xavier":        lambda n_in: np.random.randn(n_in, n_in) * np.sqrt(2.0 / (n_in + n_in)),
    "He/Kaiming":    lambda n_in: np.random.randn(n_in, n_in) * np.sqrt(2.0 / n_in),
}

for name, weight_fn in strategies.items():
    a = x.copy()
    print(f"\n📊 {name}:")
    print(f"   Entrada  → Var = {np.var(a):.6f}")
    for l in range(n_layers):
        W = weight_fn(n_neurons)
        z = a @ W.T
        a = np.maximum(0, z)  # ReLU
        var = np.var(a)
        status = "✅" if 0.01 < var < 100 else ("💥 EXPLOTA" if var > 100 else "📉 Se desvanece")
        print(f"   Capa {l+1}   → Var = {var:.6f}  {status}")

print("\n" + "=" * 65)
print("💡 Solo He/Kaiming mantiene la varianza estable con ReLU")
print("=" * 65)
=================================================================
Propagación de varianza a través de 4 capas (256 neuronas/capa)
=================================================================

📊 Normal(0,1):
   Entrada  → Var = 0.250069
   Capa 1   → Var = 21.908540  ✅
   Capa 2   → Var = 3054.449559  💥 EXPLOTA
   Capa 3   → Var = 401112.449012  💥 EXPLOTA
   Capa 4   → Var = 46439070.003931  💥 EXPLOTA

📊 Uniform[-1,1]:
   Entrada  → Var = 0.250069
   Capa 1   → Var = 7.291779  ✅
   Capa 2   → Var = 306.495569  💥 EXPLOTA
   Capa 3   → Var = 14505.010660  💥 EXPLOTA
   Capa 4   → Var = 667639.630781  💥 EXPLOTA

📊 Xavier:
   Entrada  → Var = 0.250069
   Capa 1   → Var = 0.085043  ✅
   Capa 2   → Var = 0.039854  ✅
   Capa 3   → Var = 0.018639  ✅
   Capa 4   → Var = 0.008048  📉 Se desvanece

📊 He/Kaiming:
   Entrada  → Var = 0.250069
   Capa 1   → Var = 0.172315  ✅
   Capa 2   → Var = 0.169230  ✅
   Capa 3   → Var = 0.171975  ✅
   Capa 4   → Var = 0.177531  ✅

=================================================================
💡 Solo He/Kaiming mantiene la varianza estable con ReLU
=================================================================

Las 6 estrategias que compararemos

En este notebook vamos a poner a prueba 6 estrategias de inicialización sobre la misma red y el mismo dataset, para ver experimentalmente qué ocurre con cada una:

# Estrategia Distribución Hipótesis
1 Todo ceros $W_{ij} = 0$ ❌ Simetría perfecta: todas las neuronas son idénticas
2 Todo unos $W_{ij} = 1$ ❌ Activaciones explotan exponencialmente por capa
3 Normal estándar $W_{ij} \sim \mathcal{N}(0, 1)$ ⚠️ $\text{Var}(z) = n_{in} \cdot 1$ — varianza crece con el ancho
4 Uniforme [-1, 1] $W_{ij} \sim \mathcal{U}(-1, 1)$ ⚠️ $\text{Var}(W) = 1/3$, no se adapta a la arquitectura
5 Xavier/Glorot $W_{ij} \sim \mathcal{N}!\left(0,; \frac{2}{n_{in}+n_{out}}\right)$ ✅ Equilibra forward y backward (ideal para tanh/sigmoid)
6 He/Kaiming $W_{ij} \sim \mathcal{N}!\left(0,; \frac{2}{n_{in}}\right)$ ✅ Corrige Xavier para ReLU (compensa el factor $\frac{1}{2}$)

Diseño del experimento

Para que la comparativa sea justa y las diferencias sean atribuibles únicamente a la inicialización, fijamos todo lo demás:

Componente Elección Justificación
Dataset FashionMNIST (60k train / 10k test) Más exigente que MNIST clásico: prendas de ropa con texturas similares hacen más evidentes las diferencias entre inicializaciones
Modelo MLP con 4 capas ocultas × 256 neuronas Profundidad suficiente para amplificar los problemas de varianza. Con 2 capas las diferencias serían mínimas
Activación ReLU La más usada en la práctica. Anula el 50% de las pre-activaciones, lo que afecta directamente a la propagación de varianza
Optimizador SGD (lr=0.01, momentum=0.9) Deliberadamente no usamos Adam, porque su tasa de aprendizaje adaptativa enmascara las malas inicializaciones
Épocas 15 Suficientes para ver convergencia (o falta de ella)
Seed 42 (fija) Mismo punto de partida estructural para todas las estrategias

Estructura del notebook

  1. Importaciones y configuración
  2. Dataset: carga y visualización de FashionMNIST
  3. Arquitectura del MLP: definición con hooks para capturar activaciones
  4. 6 funciones de inicialización: una por estrategia
  5. Activaciones iniciales: visualización antes de entrenar (¿explotan? ¿se desvanecen?)
  6. Entrenamiento comparativo: misma red, mismos datos, distinta inicialización
  7. Curvas de loss y accuracy: evolución temporal
  8. Ranking final: ¿qué inicialización gana?
  9. Distribución de pesos: cómo quedaron los pesos después del entrenamiento
  10. Conclusiones: qué hemos aprendido y reglas prácticas

Empecemos. 👇

1. Importaciones y configuración

Cargamos PyTorch, torchvision (para el dataset), matplotlib (para las gráficas) y fijamos las semillas de aleatoriedad para garantizar la reproducibilidad del experimento.

[2]
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
import matplotlib.pyplot as plt
import numpy as np
import copy
import warnings
warnings.filterwarnings('ignore')

# Reproducibilidad
torch.manual_seed(42)
np.random.seed(42)

# Dispositivo
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"Dispositivo: {device}")
print(f"PyTorch: {torch.__version__}")
Dispositivo: cpu
PyTorch: 2.10.0+cu128

2. Dataset: FashionMNIST

Usamos FashionMNIST en lugar del MNIST clásico porque es más exigente (las prendas de ropa son más difíciles de distinguir que los dígitos), lo que hará más evidentes las diferencias entre inicializaciones.

Cada imagen es de 28×28 píxeles en escala de grises (784 features al aplanar).

[3]
# Transformación: normalizar a [0,1] y aplanar a vector de 784
transform = transforms.Compose([
    transforms.ToTensor(),  # [0,255] → [0,1]
])

# Descargar datasets
train_dataset = datasets.FashionMNIST(
    root='./data', train=True, download=True, transform=transform
)
test_dataset = datasets.FashionMNIST(
    root='./data', train=False, download=True, transform=transform
)

# DataLoaders
BATCH_SIZE = 256
train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False)

# Clases
class_names = ['Camiseta', 'Pantalón', 'Jersey', 'Vestido', 'Abrigo',
               'Sandalia', 'Camisa', 'Zapatilla', 'Bolso', 'Bota']

print(f"Train: {len(train_dataset)} imágenes")
print(f"Test:  {len(test_dataset)} imágenes")
print(f"Clases: {len(class_names)}")
print(f"Forma de una imagen: {train_dataset[0][0].shape}")
100.0%
100.0%
100.0%
100.0%
Train: 60000 imágenes
Test:  10000 imágenes
Clases: 10
Forma de una imagen: torch.Size([1, 28, 28])

Veamos algunas muestras del dataset:

[4]
fig, axes = plt.subplots(2, 8, figsize=(14, 4))
for i, ax in enumerate(axes.flat):
    img, label = train_dataset[i]
    ax.imshow(img.squeeze(), cmap='gray')
    ax.set_title(class_names[label], fontsize=8)
    ax.axis('off')
plt.suptitle('Muestras de FashionMNIST', fontsize=12, fontweight='bold')
plt.tight_layout()
plt.show()
Output

3. Definición del MLP

Definimos un MLP con 4 capas ocultas de 256 neuronas y activación ReLU. La profundidad es deliberada: con pocas capas, las diferencias entre inicializaciones son menos evidentes. Con 4 capas ocultas, los problemas de varianza se amplifican y las malas inicializaciones fracasan claramente.

También registramos un hook en cada capa para capturar las activaciones durante el forward pass — esto nos permitirá visualizar cómo se propagan las activaciones con cada inicialización.

[5]
class MLP(nn.Module):
    """MLP con capas ocultas y captura de activaciones."""

    def __init__(self, input_dim=784, hidden_dim=256, output_dim=10, n_hidden=4):
        super().__init__()

        layers = []
        # Primera capa oculta
        layers.append(nn.Linear(input_dim, hidden_dim))
        layers.append(nn.ReLU())

        # Capas ocultas intermedias
        for _ in range(n_hidden - 1):
            layers.append(nn.Linear(hidden_dim, hidden_dim))
            layers.append(nn.ReLU())

        # Capa de salida (sin activación — se usa CrossEntropyLoss)
        layers.append(nn.Linear(hidden_dim, output_dim))

        self.network = nn.Sequential(*layers)

        # Almacén de activaciones para análisis
        self.activations = {}
        self._register_hooks()

    def _register_hooks(self):
        """Registra hooks para capturar activaciones de cada capa ReLU."""
        layer_idx = 0
        for module in self.network:
            if isinstance(module, nn.ReLU):
                name = f'relu_{layer_idx}'
                module.register_forward_hook(self._make_hook(name))
                layer_idx += 1

    def _make_hook(self, name):
        def hook(module, input, output):
            self.activations[name] = output.detach().cpu()
        return hook

    def forward(self, x):
        x = x.view(x.size(0), -1)  # Aplanar: [B, 1, 28, 28] → [B, 784]
        return self.network(x)


# Verificar arquitectura
model_test = MLP()
print(model_test)
n_params = sum(p.numel() for p in model_test.parameters())
print(f"\nTotal parámetros: {n_params:,}")
MLP(
  (network): Sequential(
    (0): Linear(in_features=784, out_features=256, bias=True)
    (1): ReLU()
    (2): Linear(in_features=256, out_features=256, bias=True)
    (3): ReLU()
    (4): Linear(in_features=256, out_features=256, bias=True)
    (5): ReLU()
    (6): Linear(in_features=256, out_features=256, bias=True)
    (7): ReLU()
    (8): Linear(in_features=256, out_features=10, bias=True)
  )
)

Total parámetros: 400,906

4. Funciones de inicialización

Definimos las 6 estrategias de inicialización que queremos comparar. Cada función recibe un modelo y modifica sus pesos in-place.

Recordemos qué dice la teoría:

  • Ceros / Unos: Problema de simetría — todas las neuronas computan lo mismo.
  • Normal estándar ($\sigma=1$): La varianza crece como $n_{in}$ por capa → explosión.
  • Uniforme [-1, 1]: Similar problema — la varianza no se adapta al tamaño de la capa.
  • Xavier: $\text{Var}(W) = \frac{2}{n_{in}+n_{out}}$ — mantiene la varianza estable con activaciones lineales/tanh/sigmoid.
  • He: $\text{Var}(W) = \frac{2}{n_{in}}$ — corrige Xavier para ReLU (que anula la mitad de los valores).
[6]
def init_zeros(model):
    """Inicializa todos los pesos a cero."""
    for name, param in model.named_parameters():
        nn.init.zeros_(param)
    return model


def init_ones(model):
    """Inicializa todos los pesos a uno."""
    for name, param in model.named_parameters():
        nn.init.ones_(param)
    return model


def init_normal(model):
    """Inicializa pesos con N(0, 1) — varianza demasiado grande."""
    for name, param in model.named_parameters():
        if 'weight' in name:
            nn.init.normal_(param, mean=0.0, std=1.0)
        elif 'bias' in name:
            nn.init.zeros_(param)
    return model


def init_uniform(model):
    """Inicializa pesos con U(-1, 1) — varianza no adaptada."""
    for name, param in model.named_parameters():
        if 'weight' in name:
            nn.init.uniform_(param, a=-1.0, b=1.0)
        elif 'bias' in name:
            nn.init.zeros_(param)
    return model


def init_xavier(model):
    """Inicialización Xavier/Glorot Normal — ideal para tanh/sigmoid."""
    for name, param in model.named_parameters():
        if 'weight' in name and param.dim() >= 2:
            nn.init.xavier_normal_(param)
        elif 'bias' in name:
            nn.init.zeros_(param)
    return model


def init_he(model):
    """Inicialización He/Kaiming Normal — ideal para ReLU."""
    for name, param in model.named_parameters():
        if 'weight' in name and param.dim() >= 2:
            nn.init.kaiming_normal_(param, mode='fan_in', nonlinearity='relu')
        elif 'bias' in name:
            nn.init.zeros_(param)
    return model


# Diccionario con todas las estrategias
INIT_STRATEGIES = {
    'Ceros':        init_zeros,
    'Unos':         init_ones,
    'Normal(0,1)':  init_normal,
    'Uniform[-1,1]':init_uniform,
    'Xavier':       init_xavier,
    'He/Kaiming':   init_he,
}

print(f"Estrategias definidas: {list(INIT_STRATEGIES.keys())}")
Estrategias definidas: ['Ceros', 'Unos', 'Normal(0,1)', 'Uniform[-1,1]', 'Xavier', 'He/Kaiming']

5. Visualización de las activaciones iniciales

Antes de entrenar, veamos cómo se propagan las activaciones a lo largo de las 4 capas ReLU con cada inicialización. Esto nos permite ver el efecto inmediato de la inicialización sobre la distribución de valores.

Esperamos:

  • Ceros: Todas las activaciones son 0 (ReLU(0)=0).
  • Unos: Activaciones enormes que crecen exponencialmente.
  • Normal(0,1): Activaciones que explotan o se saturan según la capa.
  • Xavier/He: Activaciones razonablemente estables a lo largo de las capas.
[7]
def capture_activations(model, data_loader):
    """Pasa un batch por el modelo y devuelve las activaciones capturadas."""
    model.eval()
    images, _ = next(iter(data_loader))
    images = images.to(device)
    with torch.no_grad():
        _ = model(images)
    return {k: v.numpy() for k, v in model.activations.items()}


# Capturar activaciones para cada inicialización
fig, axes = plt.subplots(len(INIT_STRATEGIES), 4, figsize=(16, 14))

for row, (name, init_fn) in enumerate(INIT_STRATEGIES.items()):
    # Crear modelo fresco e inicializar
    torch.manual_seed(42)
    model = MLP().to(device)
    init_fn(model)

    # Capturar activaciones
    acts = capture_activations(model, train_loader)

    for col, (layer_name, layer_acts) in enumerate(acts.items()):
        ax = axes[row, col]
        flat = layer_acts.flatten()

        # Limitar rango para visualización
        valid = flat[np.isfinite(flat)]
        if len(valid) > 0 and np.std(valid) > 0:
            clip_val = min(np.percentile(np.abs(valid), 99), 50)
            clipped = np.clip(valid, -clip_val, clip_val)
            ax.hist(clipped, bins=50, color='#0984E3', alpha=0.7, density=True)
            ax.set_title(f'{layer_name}', fontsize=8)
            ax.text(0.95, 0.95, f'μ={np.mean(valid):.2f}\nσ={np.std(valid):.2f}',
                    transform=ax.transAxes, ha='right', va='top', fontsize=7,
                    bbox=dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.8))
        else:
            ax.text(0.5, 0.5, 'Todo ceros\no NaN/Inf',
                    transform=ax.transAxes, ha='center', va='center', fontsize=9)
            ax.set_title(f'{layer_name}', fontsize=8)

        if col == 0:
            ax.set_ylabel(name, fontsize=9, fontweight='bold')
        ax.tick_params(labelsize=6)

plt.suptitle('Distribución de activaciones ANTES del entrenamiento (por capa)',
             fontsize=13, fontweight='bold', y=1.01)
plt.tight_layout()
plt.show()
Output

Observaciones clave:

  • Ceros: todas las activaciones son exactamente 0. La red no puede aprender porque no hay diferencia entre neuronas (simetría perfecta).
  • Unos: las activaciones crecen enormemente con cada capa. Valores extremos que causan inestabilidad numérica.
  • Normal(0,1): la distribución se distorsiona mucho — demasiada varianza para 784 (o 256) entradas por capa.
  • Uniforme[-1,1]: similar a Normal(0,1) — la varianza no está adaptada al tamaño de la capa.
  • Xavier: distribución más concentrada y estable, aunque no es óptima para ReLU.
  • He/Kaiming: distribución estable a lo largo de todas las capas — exactamente lo que queremos con ReLU.

6. Entrenamiento comparativo

Ahora entrenamos el MLP durante 15 épocas con cada inicialización, usando exactamente los mismos hiperparámetros:

  • Optimizador: SGD con learning rate 0.01 y momentum 0.9
  • Loss: CrossEntropyLoss
  • Batch size: 256

Usamos SGD (no Adam) deliberadamente: Adam enmascara parcialmente las malas inicializaciones porque adapta el learning rate por parámetro. Con SGD, el efecto de la inicialización es más evidente.

[8]
def train_model(model, train_loader, test_loader, epochs=15, lr=0.01):
    """Entrena un modelo y devuelve métricas por época."""
    model = model.to(device)
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.SGD(model.parameters(), lr=lr, momentum=0.9)

    history = {
        'train_loss': [],
        'test_loss': [],
        'train_acc': [],
        'test_acc': [],
    }

    for epoch in range(epochs):
        # ── Entrenamiento ──
        model.train()
        running_loss = 0.0
        correct = 0
        total = 0

        for images, labels in train_loader:
            images, labels = images.to(device), labels.to(device)

            optimizer.zero_grad()
            outputs = model(images)
            loss = criterion(outputs, labels)

            # Proteger contra NaN
            if torch.isnan(loss) or torch.isinf(loss):
                history['train_loss'].append(float('nan'))
                history['test_loss'].append(float('nan'))
                history['train_acc'].append(0.0)
                history['test_acc'].append(0.0)
                print(f"  Epoch {epoch+1}: ⚠️ NaN/Inf detectado — entrenamiento abortado")
                return history

            loss.backward()
            # Gradient clipping para evitar explosión total
            torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
            optimizer.step()

            running_loss += loss.item() * images.size(0)
            _, predicted = torch.max(outputs, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()

        train_loss = running_loss / total
        train_acc = 100.0 * correct / total

        # ── Evaluación ──
        model.eval()
        test_loss = 0.0
        correct = 0
        total = 0

        with torch.no_grad():
            for images, labels in test_loader:
                images, labels = images.to(device), labels.to(device)
                outputs = model(images)
                loss = criterion(outputs, labels)
                test_loss += loss.item() * images.size(0)
                _, predicted = torch.max(outputs, 1)
                total += labels.size(0)
                correct += (predicted == labels).sum().item()

        test_loss /= total
        test_acc = 100.0 * correct / total

        history['train_loss'].append(train_loss)
        history['test_loss'].append(test_loss)
        history['train_acc'].append(train_acc)
        history['test_acc'].append(test_acc)

        print(f"  Epoch {epoch+1:2d}/{epochs} | "
              f"Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.1f}% | "
              f"Test Acc: {test_acc:.1f}%")

    return history

print("Función de entrenamiento definida ✅")
Función de entrenamiento definida ✅
[9]
# ── Entrenar con cada estrategia ──
EPOCHS = 15
results = {}

for name, init_fn in INIT_STRATEGIES.items():
    print(f"\n{'='*60}")
    print(f"🔧 Inicialización: {name}")
    print(f"{'='*60}")

    # Crear modelo fresco con la misma seed
    torch.manual_seed(42)
    model = MLP().to(device)
    init_fn(model)

    # Verificar distribución de pesos inicial
    all_weights = []
    for pname, param in model.named_parameters():
        if 'weight' in pname:
            all_weights.append(param.data.cpu().numpy().flatten())
    all_w = np.concatenate(all_weights)
    print(f"  Pesos iniciales: μ={np.mean(all_w):.4f}, σ={np.std(all_w):.4f}, "
          f"min={np.min(all_w):.4f}, max={np.max(all_w):.4f}")

    # Entrenar
    history = train_model(model, train_loader, test_loader, epochs=EPOCHS)
    results[name] = history

print(f"\n{'='*60}")
print("✅ Entrenamiento completado para todas las estrategias")
print(f"{'='*60}")
============================================================
🔧 Inicialización: Ceros
============================================================
  Pesos iniciales: μ=0.0000, σ=0.0000, min=0.0000, max=0.0000
  Epoch  1/15 | Train Loss: 2.3027 | Train Acc: 9.8% | Test Acc: 10.0%
  Epoch  2/15 | Train Loss: 2.3027 | Train Acc: 9.9% | Test Acc: 10.0%
  Epoch  3/15 | Train Loss: 2.3027 | Train Acc: 9.7% | Test Acc: 10.0%
  Epoch  4/15 | Train Loss: 2.3027 | Train Acc: 9.7% | Test Acc: 10.0%
  Epoch  5/15 | Train Loss: 2.3027 | Train Acc: 9.9% | Test Acc: 10.0%
  Epoch  6/15 | Train Loss: 2.3027 | Train Acc: 9.9% | Test Acc: 10.0%
  Epoch  7/15 | Train Loss: 2.3027 | Train Acc: 10.1% | Test Acc: 10.0%
  Epoch  8/15 | Train Loss: 2.3027 | Train Acc: 9.9% | Test Acc: 10.0%
  Epoch  9/15 | Train Loss: 2.3027 | Train Acc: 9.8% | Test Acc: 10.0%
  Epoch 10/15 | Train Loss: 2.3027 | Train Acc: 9.9% | Test Acc: 10.0%
  Epoch 11/15 | Train Loss: 2.3027 | Train Acc: 9.8% | Test Acc: 10.0%
  Epoch 12/15 | Train Loss: 2.3027 | Train Acc: 9.8% | Test Acc: 10.0%
  Epoch 13/15 | Train Loss: 2.3027 | Train Acc: 9.9% | Test Acc: 10.0%
  Epoch 14/15 | Train Loss: 2.3027 | Train Acc: 9.8% | Test Acc: 10.0%
  Epoch 15/15 | Train Loss: 2.3027 | Train Acc: 9.9% | Test Acc: 10.0%

============================================================
🔧 Inicialización: Unos
============================================================
  Pesos iniciales: μ=1.0000, σ=0.0000, min=1.0000, max=1.0000
  Epoch  1/15 | Train Loss: 5700047105.2373 | Train Acc: 10.1% | Test Acc: 10.0%
  Epoch  2/15 | Train Loss: 6110430329.6512 | Train Acc: 10.1% | Test Acc: 10.0%
  Epoch  3/15 | Train Loss: 5403961516.5781 | Train Acc: 9.8% | Test Acc: 10.0%
  Epoch  4/15 | Train Loss: 5466632587.2640 | Train Acc: 10.1% | Test Acc: 10.0%
  Epoch  5/15 | Train Loss: 6338690893.6875 | Train Acc: 10.1% | Test Acc: 10.0%
  Epoch  6/15 | Train Loss: 5438692141.7387 | Train Acc: 10.0% | Test Acc: 10.0%
  Epoch  7/15 | Train Loss: 5612628683.5712 | Train Acc: 10.0% | Test Acc: 10.0%
  Epoch  8/15 | Train Loss: 5912098270.6859 | Train Acc: 10.0% | Test Acc: 10.0%
  Epoch  9/15 | Train Loss: 5878740279.1595 | Train Acc: 9.9% | Test Acc: 10.0%
  Epoch 10/15 | Train Loss: 5930738921.1989 | Train Acc: 9.8% | Test Acc: 10.0%
  Epoch 11/15 | Train Loss: 5822634862.1824 | Train Acc: 10.1% | Test Acc: 10.0%
  Epoch 12/15 | Train Loss: 4982192605.0475 | Train Acc: 9.9% | Test Acc: 10.0%
  Epoch 13/15 | Train Loss: 5382116999.4411 | Train Acc: 10.1% | Test Acc: 10.0%
  Epoch 14/15 | Train Loss: 5438585349.4613 | Train Acc: 10.0% | Test Acc: 10.0%
  Epoch 15/15 | Train Loss: 5590234524.6037 | Train Acc: 10.0% | Test Acc: 10.0%

============================================================
🔧 Inicialización: Normal(0,1)
============================================================
  Pesos iniciales: μ=-0.0019, σ=0.9987, min=-4.4376, max=4.3865
  Epoch  1/15 | Train Loss: 25387.8072 | Train Acc: 62.5% | Test Acc: 68.7%
  Epoch  2/15 | Train Loss: 4284.2057 | Train Acc: 75.5% | Test Acc: 74.4%
  Epoch  3/15 | Train Loss: 2915.9389 | Train Acc: 76.6% | Test Acc: 72.7%
  Epoch  4/15 | Train Loss: 2150.1975 | Train Acc: 77.4% | Test Acc: 75.7%
  Epoch  5/15 | Train Loss: 1754.7726 | Train Acc: 77.9% | Test Acc: 75.8%
  Epoch  6/15 | Train Loss: 1380.5892 | Train Acc: 78.3% | Test Acc: 76.7%
  Epoch  7/15 | Train Loss: 1159.7362 | Train Acc: 78.5% | Test Acc: 73.5%
  Epoch  8/15 | Train Loss: 971.4804 | Train Acc: 78.6% | Test Acc: 73.0%
  Epoch  9/15 | Train Loss: 810.5222 | Train Acc: 78.9% | Test Acc: 76.8%
  Epoch 10/15 | Train Loss: 666.8215 | Train Acc: 79.2% | Test Acc: 77.5%
  Epoch 11/15 | Train Loss: 609.3056 | Train Acc: 79.0% | Test Acc: 76.4%
  Epoch 12/15 | Train Loss: 526.4207 | Train Acc: 79.3% | Test Acc: 73.2%
  Epoch 13/15 | Train Loss: 451.1011 | Train Acc: 79.4% | Test Acc: 77.9%
  Epoch 14/15 | Train Loss: 404.5953 | Train Acc: 79.4% | Test Acc: 78.7%
  Epoch 15/15 | Train Loss: 350.1268 | Train Acc: 79.5% | Test Acc: 77.3%

============================================================
🔧 Inicialización: Uniform[-1,1]
============================================================
  Pesos iniciales: μ=-0.0001, σ=0.5771, min=-1.0000, max=1.0000
  Epoch  1/15 | Train Loss: 1353.3067 | Train Acc: 66.9% | Test Acc: 74.5%
  Epoch  2/15 | Train Loss: 237.6351 | Train Acc: 75.9% | Test Acc: 75.0%
  Epoch  3/15 | Train Loss: 134.2124 | Train Acc: 77.3% | Test Acc: 75.4%
  Epoch  4/15 | Train Loss: 87.0338 | Train Acc: 77.9% | Test Acc: 76.8%
  Epoch  5/15 | Train Loss: 59.9987 | Train Acc: 78.0% | Test Acc: 75.3%
  Epoch  6/15 | Train Loss: 40.6396 | Train Acc: 78.1% | Test Acc: 77.2%
  Epoch  7/15 | Train Loss: 25.2148 | Train Acc: 77.6% | Test Acc: 76.0%
  Epoch  8/15 | Train Loss: 10.4494 | Train Acc: 69.2% | Test Acc: 51.9%
  Epoch  9/15 | Train Loss: 2.1406 | Train Acc: 26.5% | Test Acc: 21.9%
  Epoch 10/15 | Train Loss: 2.2811 | Train Acc: 17.8% | Test Acc: 15.6%
  Epoch 11/15 | Train Loss: 2.1702 | Train Acc: 16.2% | Test Acc: 15.9%
  Epoch 12/15 | Train Loss: 2.0976 | Train Acc: 19.2% | Test Acc: 25.3%
  Epoch 13/15 | Train Loss: 1.9032 | Train Acc: 25.5% | Test Acc: 23.2%
  Epoch 14/15 | Train Loss: 1.8768 | Train Acc: 25.9% | Test Acc: 26.3%
  Epoch 15/15 | Train Loss: 1.8820 | Train Acc: 25.9% | Test Acc: 26.5%

============================================================
🔧 Inicialización: Xavier
============================================================
  Pesos iniciales: μ=-0.0001, σ=0.0541, min=-0.3419, max=0.2791
  Epoch  1/15 | Train Loss: 0.8200 | Train Acc: 71.6% | Test Acc: 81.7%
  Epoch  2/15 | Train Loss: 0.4551 | Train Acc: 83.9% | Test Acc: 81.9%
  Epoch  3/15 | Train Loss: 0.4088 | Train Acc: 85.4% | Test Acc: 84.8%
  Epoch  4/15 | Train Loss: 0.3728 | Train Acc: 86.6% | Test Acc: 86.1%
  Epoch  5/15 | Train Loss: 0.3588 | Train Acc: 87.1% | Test Acc: 86.3%
  Epoch  6/15 | Train Loss: 0.3411 | Train Acc: 87.5% | Test Acc: 85.5%
  Epoch  7/15 | Train Loss: 0.3191 | Train Acc: 88.5% | Test Acc: 86.9%
  Epoch  8/15 | Train Loss: 0.3065 | Train Acc: 88.7% | Test Acc: 86.8%
  Epoch  9/15 | Train Loss: 0.2975 | Train Acc: 89.0% | Test Acc: 86.3%
  Epoch 10/15 | Train Loss: 0.2873 | Train Acc: 89.4% | Test Acc: 86.3%
  Epoch 11/15 | Train Loss: 0.2756 | Train Acc: 89.9% | Test Acc: 87.6%
  Epoch 12/15 | Train Loss: 0.2686 | Train Acc: 90.1% | Test Acc: 87.4%
  Epoch 13/15 | Train Loss: 0.2587 | Train Acc: 90.5% | Test Acc: 87.5%
  Epoch 14/15 | Train Loss: 0.2588 | Train Acc: 90.3% | Test Acc: 87.6%
  Epoch 15/15 | Train Loss: 0.2442 | Train Acc: 91.0% | Test Acc: 87.5%

============================================================
🔧 Inicialización: He/Kaiming
============================================================
  Pesos iniciales: μ=-0.0001, σ=0.0718, min=-0.3922, max=0.3877
  Epoch  1/15 | Train Loss: 0.6710 | Train Acc: 76.9% | Test Acc: 83.2%
  Epoch  2/15 | Train Loss: 0.4159 | Train Acc: 85.1% | Test Acc: 82.6%
  Epoch  3/15 | Train Loss: 0.3780 | Train Acc: 86.4% | Test Acc: 85.5%
  Epoch  4/15 | Train Loss: 0.3457 | Train Acc: 87.4% | Test Acc: 86.7%
  Epoch  5/15 | Train Loss: 0.3317 | Train Acc: 87.8% | Test Acc: 86.7%
  Epoch  6/15 | Train Loss: 0.3181 | Train Acc: 88.3% | Test Acc: 85.7%
  Epoch  7/15 | Train Loss: 0.2985 | Train Acc: 89.1% | Test Acc: 87.3%
  Epoch  8/15 | Train Loss: 0.2866 | Train Acc: 89.4% | Test Acc: 87.6%
  Epoch  9/15 | Train Loss: 0.2758 | Train Acc: 89.8% | Test Acc: 86.8%
  Epoch 10/15 | Train Loss: 0.2672 | Train Acc: 90.1% | Test Acc: 86.9%
  Epoch 11/15 | Train Loss: 0.2558 | Train Acc: 90.5% | Test Acc: 87.6%
  Epoch 12/15 | Train Loss: 0.2467 | Train Acc: 90.8% | Test Acc: 88.1%
  Epoch 13/15 | Train Loss: 0.2376 | Train Acc: 91.3% | Test Acc: 87.6%
  Epoch 14/15 | Train Loss: 0.2361 | Train Acc: 91.2% | Test Acc: 88.0%
  Epoch 15/15 | Train Loss: 0.2255 | Train Acc: 91.6% | Test Acc: 87.7%

============================================================
✅ Entrenamiento completado para todas las estrategias
============================================================

7. Comparación de curvas de pérdida (loss)

Visualicemos cómo evoluciona la loss de entrenamiento y la loss de test con cada inicialización. Las inicializaciones mal elegidas mostrarán una loss que no baja (ceros), que explota (unos, normal) o que baja mucho más lentamente (uniforme).

[10]
# Colores para cada estrategia
colors = {
    'Ceros':         '#e74c3c',   # rojo
    'Unos':          '#e67e22',   # naranja
    'Normal(0,1)':   '#9b59b6',   # púrpura
    'Uniform[-1,1]': '#f39c12',   # amarillo
    'Xavier':        '#2ecc71',   # verde
    'He/Kaiming':    '#0984E3',   # azul
}

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 5))

for name, hist in results.items():
    epochs_range = range(1, len(hist['train_loss']) + 1)
    # Filtrar NaN para el plot
    train_loss = [v if not np.isnan(v) else None for v in hist['train_loss']]

    ax1.plot(epochs_range, train_loss, label=name, color=colors[name],
             linewidth=2, marker='o', markersize=3)

    test_loss = [v if not np.isnan(v) else None for v in hist['test_loss']]
    ax2.plot(epochs_range, test_loss, label=name, color=colors[name],
             linewidth=2, marker='o', markersize=3)

ax1.set_title('Loss de Entrenamiento', fontsize=12, fontweight='bold')
ax1.set_xlabel('Época')
ax1.set_ylabel('Cross-Entropy Loss')
ax1.legend(fontsize=8)
ax1.set_ylim(bottom=0)
ax1.grid(True, alpha=0.3)

ax2.set_title('Loss de Test', fontsize=12, fontweight='bold')
ax2.set_xlabel('Época')
ax2.set_ylabel('Cross-Entropy Loss')
ax2.legend(fontsize=8)
ax2.set_ylim(bottom=0)
ax2.grid(True, alpha=0.3)

plt.suptitle('Evolución de la Loss según la Inicialización',
             fontsize=14, fontweight='bold', y=1.02)
plt.tight_layout()
plt.show()
Output

8. Comparación de accuracy

La accuracy muestra el impacto práctico de cada inicialización en el rendimiento del clasificador.

[11]
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 5))

for name, hist in results.items():
    epochs_range = range(1, len(hist['train_acc']) + 1)
    ax1.plot(epochs_range, hist['train_acc'], label=name,
             color=colors[name], linewidth=2, marker='o', markersize=3)
    ax2.plot(epochs_range, hist['test_acc'], label=name,
             color=colors[name], linewidth=2, marker='o', markersize=3)

ax1.set_title('Accuracy de Entrenamiento', fontsize=12, fontweight='bold')
ax1.set_xlabel('Época')
ax1.set_ylabel('Accuracy (%)')
ax1.legend(fontsize=8)
ax1.set_ylim(0, 100)
ax1.axhline(y=10, color='gray', linestyle='--', alpha=0.5, label='Azar (10%)')
ax1.grid(True, alpha=0.3)

ax2.set_title('Accuracy de Test', fontsize=12, fontweight='bold')
ax2.set_xlabel('Época')
ax2.set_ylabel('Accuracy (%)')
ax2.legend(fontsize=8)
ax2.set_ylim(0, 100)
ax2.axhline(y=10, color='gray', linestyle='--', alpha=0.5, label='Azar (10%)')
ax2.grid(True, alpha=0.3)

plt.suptitle('Evolución del Accuracy según la Inicialización',
             fontsize=14, fontweight='bold', y=1.02)
plt.tight_layout()
plt.show()
Output

9. Resumen: accuracy final de test

Comparemos los resultados finales de cada estrategia en un gráfico de barras:

[12]
# Accuracy final de test para cada estrategia
final_accs = {}
for name, hist in results.items():
    accs = hist['test_acc']
    final_accs[name] = accs[-1] if accs else 0.0

# Ordenar de mayor a menor
sorted_names = sorted(final_accs, key=final_accs.get, reverse=True)
sorted_accs = [final_accs[n] for n in sorted_names]
sorted_colors = [colors[n] for n in sorted_names]

fig, ax = plt.subplots(figsize=(10, 5))
bars = ax.barh(sorted_names, sorted_accs, color=sorted_colors, edgecolor='white',
               linewidth=0.5, height=0.6)

# Etiquetas en cada barra
for bar, acc in zip(bars, sorted_accs):
    ax.text(bar.get_width() + 0.5, bar.get_y() + bar.get_height()/2,
            f'{acc:.1f}%', va='center', fontweight='bold', fontsize=10)

ax.set_xlabel('Test Accuracy (%)', fontsize=11)
ax.set_title('Accuracy Final de Test por Estrategia de Inicialización',
             fontsize=13, fontweight='bold')
ax.set_xlim(0, 100)
ax.axvline(x=10, color='gray', linestyle='--', alpha=0.5)
ax.text(11, -0.5, 'Azar', color='gray', fontsize=8)
ax.grid(axis='x', alpha=0.3)
ax.invert_yaxis()
plt.tight_layout()
plt.show()

# Tabla resumen
print("\n" + "="*55)
print(f"{'Estrategia':<18} {'Test Acc':>10} {'Veredicto':>25}")
print("="*55)
for name in sorted_names:
    acc = final_accs[name]
    if acc < 15:
        verdict = '❌ No aprende'
    elif acc < 70:
        verdict = '⚠️ Aprende mal'
    elif acc < 85:
        verdict = '🔶 Subóptimo'
    else:
        verdict = '✅ Buen resultado'
    print(f"{name:<18} {acc:>9.1f}% {verdict:>25}")
print("="*55)
Output
=======================================================
Estrategia           Test Acc                 Veredicto
=======================================================
He/Kaiming              87.7%          ✅ Buen resultado
Xavier                  87.5%          ✅ Buen resultado
Normal(0,1)             77.3%               🔶 Subóptimo
Uniform[-1,1]           26.5%            ⚠️ Aprende mal
Ceros                   10.0%              ❌ No aprende
Unos                    10.0%              ❌ No aprende
=======================================================

10. Distribución de pesos tras el entrenamiento

Por último, visualicemos cómo quedaron distribuidos los pesos de la primera capa oculta después del entrenamiento. Las inicializaciones malas llevan a distribuciones de pesos problemáticas, mientras que Xavier y He mantienen distribuciones saludables.

[13]
# Re-entrenar brevemente para capturar pesos finales
# (ya tenemos los históricos, pero necesitamos los modelos finales para pesos)
final_models = {}

for name, init_fn in INIT_STRATEGIES.items():
    torch.manual_seed(42)
    model = MLP().to(device)
    init_fn(model)

    # Entrenar silenciosamente
    model.train()
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)

    for epoch in range(EPOCHS):
        for images, labels in train_loader:
            images, labels = images.to(device), labels.to(device)
            optimizer.zero_grad()
            outputs = model(images)
            loss = criterion(outputs, labels)
            if torch.isnan(loss) or torch.isinf(loss):
                break
            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
            optimizer.step()

    final_models[name] = model

# Visualizar distribución de pesos de la primera capa
fig, axes = plt.subplots(2, 3, figsize=(15, 8))

for idx, (name, model) in enumerate(final_models.items()):
    ax = axes[idx // 3, idx % 3]

    # Obtener pesos de la primera capa lineal
    first_layer = model.network[0]
    weights = first_layer.weight.data.cpu().numpy().flatten()

    if np.all(np.isfinite(weights)) and np.std(weights) > 0:
        ax.hist(weights, bins=80, color=colors[name], alpha=0.7, density=True)
        ax.set_title(f'{name}\nμ={np.mean(weights):.4f}, σ={np.std(weights):.4f}',
                     fontsize=9, fontweight='bold')
    else:
        ax.text(0.5, 0.5, 'Pesos no finitos\no sin variación',
                transform=ax.transAxes, ha='center', va='center', fontsize=10)
        ax.set_title(f'{name}', fontsize=9, fontweight='bold')

    ax.tick_params(labelsize=7)
    ax.grid(True, alpha=0.2)

plt.suptitle('Distribución de pesos de la 1ª capa oculta DESPUÉS del entrenamiento (15 épocas)',
             fontsize=12, fontweight='bold', y=1.01)
plt.tight_layout()
plt.show()
Output

11. Conclusiones

Este experimento confirma las predicciones teóricas sobre la inicialización de pesos:

❌ Inicializaciones que NO funcionan:

Estrategia Problema Resultado
Todo ceros Simetría perfecta: todas las neuronas computan lo mismo La red no aprende nada (~10% = azar)
Todo unos Activaciones explotan exponencialmente por capa Inestabilidad numérica, NaN en la loss

⚠️ Inicializaciones subóptimas:

Estrategia Problema Resultado
Normal(0,1) $\text{Var}(z) = n_{in} \cdot 1$ — la varianza crece con el tamaño de la capa Explosión/saturación parcial, entrenamiento inestable
Uniform[-1,1] $\text{Var}(W) = 1/3$, no adaptada a $n_{in}$ Problema similar a Normal(0,1), peor que Xavier/He

✅ Inicializaciones recomendadas:

Estrategia Principio Resultado
Xavier/Glorot $\text{Var}(W) = \frac{2}{n_{in}+n_{out}}$ — equilibra forward y backward Buen rendimiento, diseñada para tanh/sigmoid
He/Kaiming $\text{Var}(W) = \frac{2}{n_{in}}$ — corrige para ReLU Mejor resultado con ReLU, convergencia más rápida

Lección principal

La inicialización de pesos no es un detalle menor. Con la inicialización correcta (He para ReLU, Xavier para tanh), la red converge rápido y alcanza buen rendimiento. Con una inicialización incorrecta, la misma red con los mismos datos y los mismos hiperparámetros puede no aprender absolutamente nada.

En la práctica, PyTorch ya usa He/Kaiming por defecto para capas Linear y Conv2d, así que a menudo no necesitas especificarlo manualmente. Pero es crucial entender por qué funciona, para poder diagnosticar problemas de entrenamiento y elegir la inicialización adecuada cuando uses activaciones no estándar.