Un modelo lineal no puede ajustar cualquier conjunto de datos. Introducimos la no linealidad apilando capas ocultas de neuronas entre la entrada y la salida.
Un MLP (perceptrón multicapa) con una capa oculta totalmente conectada: 4 entradas, 5 unidades ocultas, 3 salidas.
Una capa totalmente conectada o densa conecta cada neurona con todas las neuronas de la capa anterior. Su salida es
,
donde ϕ es la función de activación, la matriz de pesos, el vector de sesgos y el vector de entrada. Entrenar un MLP significa encontrar los pesos y los sesgos que minimizan una función de pérdida. Con suficientes unidades ocultas, un MLP puede aproximar una clase amplia de funciones, y por eso se dice que los MLP son aproximadores universales.
En este cuaderno construimos un MLP multiclase en PyTorch sobre el conjunto de datos de fuentes sísmicas del cuaderno 4.1, agregamos dropout (apagado aleatorio de neuronas) y normalización por lotes (batch normalization), aprendemos a guardar y restaurar modelos, y comparamos con el MLP incorporado de scikit-learn.
1. Funciones de activación¶
Las funciones de activación aplican una transformación no lineal a la suma ponderada de las entradas de una neurona. Sin ellas, una pila de capas lineales colapsa en una sola capa lineal. Tres opciones comunes:
- Unidad lineal rectificada (ReLU): . Su derivada es 0 o 1, lo que mantiene la optimización bien comportada. ReLU es la opción por defecto para las capas ocultas.
- Sigmoide: mapea cualquier número real a (0, 1). Es una aproximación suave, «en forma de S», de una unidad de umbral. Todavía se usa en unidades de salida cuando la salida debe ser una probabilidad en clasificación binaria.
- Tanh: mapea a (-1, 1) y es casi lineal cerca de cero. Su comportamiento queda entre la sigmoide y ReLU.
import numpy as np
import matplotlib.pyplot as plt
x = np.linspace(-2, 2, 100)
def sigm(x): # sigmoid
return 1 / (1 + np.exp(-x))
def relu(x): # rectified linear unit
return np.maximum(x, 0)
fig, ax = plt.subplots(figsize=(5, 3.5))
ax.plot(x, sigm(x), label='sigmoid')
ax.plot(x, relu(x), label='ReLU')
ax.plot(x, np.tanh(x), label='tanh')
ax.grid(True)
ax.legend()
ax.set_xlim([-2, 2])
ax.set_xlabel('x')
ax.set_ylabel(r'$\phi(x)$')
ax.set_title('Activation functions')
plt.tight_layout()
2. Estructuras típicas de un MLP¶
Un MLP de regresión produce valores escalares. El número de neuronas de salida es igual al número de valores a predecir. Para predecir coordenadas geoespaciales 2D, por ejemplo, se necesitan 2 neuronas de salida: latitud y longitud. Para salidas reales sin cota, la capa de salida no lleva activación. Para restringir las salidas, agregue una activación en la capa de salida: ReLU o softplus para valores estrictamente positivos, sigmoide o tanh para valores acotados entre 0 (o -1) y 1.

Un MLP de clasificación produce un puntaje por clase. Para problemas binarios, una sola neurona de salida con una sigmoide da la probabilidad de la clase positiva. Para problemas multiclase, la capa de salida tiene una neurona por clase y una softmax convierte los puntajes en probabilidades que suman 1. En PyTorch, el modelo normalmente produce los puntajes crudos (llamados logits) y la función de pérdida nn.CrossEntropyLoss aplica la softmax internamente.

3. Entrenamiento de redes neuronales¶
El entrenamiento procede sobre pequeños lotes de datos llamados minilotes (minibatches).
La pasada hacia adelante (forward pass) envía un minilote desde la capa de entrada, a través de las capas ocultas, hasta la capa de salida, calculando y almacenando valores intermedios en el camino, y produce predicciones.
El algoritmo mide entonces el error con una función de pérdida.
La retropropagación (backpropagation) aplica la regla de la cadena hacia atrás a través de la red para calcular el gradiente de la pérdida con respecto a cada peso y cada sesgo. Como la regla de la cadena necesita los valores intermedios de la pasada hacia adelante, entrenar requiere más memoria que predecir.
Finalmente, el optimizador (descenso de gradiente o una variante como Adam) actualiza los pesos usando estos gradientes. Una pasada por el conjunto de entrenamiento completo se llama época. Repetir este bucle durante muchas épocas reduce gradualmente la pérdida.
Un modelo secuencial es un MLP de una sola rama: los datos fluyen por las capas una tras otra.
4. Un MLP multiclase en PyTorch¶
Reutilizamos el conjunto de datos de fuentes sísmicas del cuaderno 4.1: 62 características físicas extraídas de sismogramas de cuatro tipos de fuente (sismos, explosiones, ruido, eventos superficiales), 1000 eventos por clase, de un archivo en Zenodo. Como en 4.1, una columna de características que es toda NaN se elimina durante la limpieza, y quedan 61. En 4.1 entrenamos un MLP superficial. Aquí construimos uno más profundo, con tres capas ocultas, dropout y normalización por lotes.
import os
import pandas as pd
import pooch
import torch
import torch.nn as nn
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import accuracy_score, classification_report
# device-agnostic setup: GPU if available, else Apple silicon, else CPU
device = torch.device("cuda" if torch.cuda.is_available()
else "mps" if torch.backends.mps.is_available()
else "cpu")
print(f"Using device: {device}")
torch.manual_seed(42)
np.random.seed(42)Using device: mps
4.1 Cargar y limpiar los datos¶
Descargamos los cuatro archivos CSV (uno por tipo de fuente) con pooch, los concatenamos y creamos una etiqueta entera a partir del nombre de la clase. Eliminamos las columnas de registro interno, eliminamos una columna de características que es enteramente NaN y quitamos cualquier fila restante con valores NaN o infinitos.
base = "https://zenodo.org/api/records/14025693/files"
class_files = {"earthquake": "1000_earthquakes_physical_features.csv",
"explosion": "1000_explosion_physical_features.csv",
"noise": "1000_noise_physical_features.csv",
"surface event": "1000_surface_physical_features.csv"}
frames = []
for class_name, fname in class_files.items():
path = pooch.retrieve(f"{base}/{fname}/content", known_hash=None, fname=fname)
d = pd.read_csv(path)
d["label"] = class_name
frames.append(d)
df = pd.concat(frames, ignore_index=True)
# drop bookkeeping columns
df = df.drop(columns=["Unnamed: 0", "serial_no", "source"])
# clean: replace inf with NaN, drop all-NaN columns, then drop rows with NaN
df = df.replace([np.inf, -np.inf], np.nan)
df = df.dropna(axis=1, how="all").dropna()
class_names = list(class_files.keys())
y = df["label"].map({name: i for i, name in enumerate(class_names)}).to_numpy()
X = df.drop(columns=["label"]).to_numpy(dtype=np.float32)
print(f"{X.shape[0]} samples, {X.shape[1]} features, {len(class_names)} classes")4000 samples, 61 features, 4 classes
4.2 Dividir y escalar¶
Reservamos el 20 % de los datos para la prueba final y dividimos el resto en conjuntos de entrenamiento y validación. El conjunto de validación monitorea el sobreajuste durante el entrenamiento; el conjunto de prueba se toca una sola vez, al final. Estandarizamos las características con StandardScaler ajustado solo con el conjunto de entrenamiento, de modo que ninguna información de los conjuntos de validación o prueba se filtre al preprocesamiento.
X_trainval, X_test, y_trainval, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y)
X_train, X_val, y_train, y_val = train_test_split(
X_trainval, y_trainval, test_size=0.2, random_state=42, stratify=y_trainval)
scaler = StandardScaler().fit(X_train) # fit on train only
X_train = scaler.transform(X_train).astype(np.float32)
X_val = scaler.transform(X_val).astype(np.float32)
X_test = scaler.transform(X_test).astype(np.float32)
print(f"train: {len(X_train)}, validation: {len(X_val)}, test: {len(X_test)}")train: 2560, validation: 640, test: 800
4.3 Datasets y DataLoaders¶
PyTorch alimenta el modelo a través de un Dataset (almacenamiento indexable de muestras) y un DataLoader (armado de lotes y barajado). Nuestros arreglos ya están listos para volverse tensores, así que TensorDataset es suficiente. Barajamos los lotes de entrenamiento en cada época; los cargadores de validación y prueba no necesitan barajarse.
from torch.utils.data import TensorDataset, DataLoader
batch_size = 64
train_ds = TensorDataset(torch.from_numpy(X_train), torch.from_numpy(y_train))
val_ds = TensorDataset(torch.from_numpy(X_val), torch.from_numpy(y_val))
test_ds = TensorDataset(torch.from_numpy(X_test), torch.from_numpy(y_test))
train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True)
val_loader = DataLoader(val_ds, batch_size=batch_size)
test_loader = DataLoader(test_ds, batch_size=batch_size)4.4 Un MLP más profundo con dropout y normalización por lotes¶
El modelo tiene tres capas ocultas (128, 64 y 32 unidades) en lugar de la única capa oculta de 4.1. Aparecen dos tipos de capa nuevos:
Dropout (nn.Dropout(p)) pone en cero aleatoriamente una fracción p de las activaciones en cada paso de entrenamiento y reescala las sobrevivientes por . Como una neurona no puede depender de que otra neurona en particular esté presente, la red aprende características más redundantes y generales, lo que reduce el sobreajuste. El dropout está activo solo en modo de entrenamiento y se apaga al evaluar.
La normalización por lotes (nn.BatchNorm1d) normaliza las activaciones de cada unidad a media cero y varianza unitaria sobre el minilote actual, y luego aplica una escala y un desplazamiento aprendidos. Esto mantiene las activaciones en un rango estable mientras los pesos cambian, lo que estabiliza y acelera el entrenamiento. Al evaluar usa estimaciones móviles de la media y la varianza recolectadas durante el entrenamiento.
Ambas capas se comportan distinto en entrenamiento y en evaluación. Llame a model.train() antes de entrenar y a model.eval() antes de predecir para cambiar de modo. La capa de salida devuelve logits crudos; nn.CrossEntropyLoss se encargará de la softmax.
class SourceMLP(nn.Module):
def __init__(self, n_features, n_classes, p_drop=0.3):
super().__init__()
self.net = nn.Sequential(
nn.Linear(n_features, 128),
nn.BatchNorm1d(128),
nn.ReLU(),
nn.Dropout(p_drop),
nn.Linear(128, 64),
nn.BatchNorm1d(64),
nn.ReLU(),
nn.Dropout(p_drop),
nn.Linear(64, 32),
nn.BatchNorm1d(32),
nn.ReLU(),
nn.Linear(32, n_classes), # logits, no activation
)
def forward(self, x):
return self.net(x)
n_features, n_classes = X_train.shape[1], len(class_names)
model = SourceMLP(n_features, n_classes).to(device)
print(model)SourceMLP(
(net): Sequential(
(0): Linear(in_features=61, out_features=128, bias=True)
(1): BatchNorm1d(128, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
(2): ReLU()
(3): Dropout(p=0.3, inplace=False)
(4): Linear(in_features=128, out_features=64, bias=True)
(5): BatchNorm1d(64, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
(6): ReLU()
(7): Dropout(p=0.3, inplace=False)
(8): Linear(in_features=64, out_features=32, bias=True)
(9): BatchNorm1d(32, eps=1e-05, momentum=0.1, affine=True, bias=True, track_running_stats=True)
(10): ReLU()
(11): Linear(in_features=32, out_features=4, bias=True)
)
)
4.5 Entrenamiento con minilotes y Adam¶
El bucle de entrenamiento siguiente sigue la receta de la sección 3: pasada hacia adelante, pérdida, retropropagación, actualización de pesos, un minilote a la vez. Usamos el optimizador Adam, una variante del descenso de gradiente con tasas de aprendizaje adaptativas por parámetro (vea la lista de optimizadores en https://
El bucle también guarda un punto de control (checkpoint) cada vez que la pérdida de validación mejora. Volvemos a los checkpoints en la sección 5; por ahora, observe la llamada a torch.save. Los checkpoints van a un directorio local checkpoints/.
Entrenamos durante 40 épocas, lo que basta para este modelo pequeño. Puede aumentar el número de épocas en su propia máquina.
def evaluate(model, loader, criterion):
"""Average loss and accuracy of the model over a DataLoader."""
model.eval() # evaluation mode: dropout off, batchnorm uses running stats
total_loss, correct, n = 0.0, 0, 0
with torch.no_grad(): # no gradients needed for evaluation
for xb, yb in loader:
xb, yb = xb.to(device), yb.to(device)
logits = model(xb)
total_loss += criterion(logits, yb).item() * len(yb)
correct += (logits.argmax(dim=1) == yb).sum().item()
n += len(yb)
return total_loss / n, correct / n
def train(model, train_loader, val_loader, n_epochs=40, lr=1e-3,
ckpt_path="checkpoints/best_mlp.pt"):
os.makedirs("checkpoints", exist_ok=True)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
history = {"train_loss": [], "val_loss": [], "train_acc": [], "val_acc": []}
best_val_loss = float("inf")
for epoch in range(n_epochs):
model.train() # training mode: dropout and batchnorm active
running_loss, correct, n = 0.0, 0, 0
for xb, yb in train_loader:
xb, yb = xb.to(device), yb.to(device)
optimizer.zero_grad() # reset gradients
logits = model(xb) # forward pass
loss = criterion(logits, yb) # loss
loss.backward() # backpropagation
optimizer.step() # weight update
running_loss += loss.item() * len(yb)
correct += (logits.argmax(dim=1) == yb).sum().item()
n += len(yb)
history["train_loss"].append(running_loss / n)
history["train_acc"].append(correct / n)
val_loss, val_acc = evaluate(model, val_loader, criterion)
history["val_loss"].append(val_loss)
history["val_acc"].append(val_acc)
# checkpoint: save the model each time the validation loss improves
if val_loss < best_val_loss:
best_val_loss = val_loss
torch.save({"epoch": epoch + 1,
"state_dict": model.state_dict(),
"optimizer": optimizer.state_dict(),
"val_loss": val_loss}, ckpt_path)
if (epoch + 1) % 5 == 0 or epoch == 0:
print(f"[Epoch {epoch + 1:2d}] train loss: {history['train_loss'][-1]:.3f} "
f"- val loss: {val_loss:.3f} - val accuracy: {val_acc:.3f}")
return historyhistory = train(model, train_loader, val_loader, n_epochs=40)[Epoch 1] train loss: 1.042 - val loss: 0.724 - val accuracy: 0.809
[Epoch 5] train loss: 0.452 - val loss: 0.396 - val accuracy: 0.878
[Epoch 10] train loss: 0.353 - val loss: 0.346 - val accuracy: 0.895
[Epoch 15] train loss: 0.302 - val loss: 0.340 - val accuracy: 0.891
[Epoch 20] train loss: 0.273 - val loss: 0.334 - val accuracy: 0.886
[Epoch 25] train loss: 0.276 - val loss: 0.330 - val accuracy: 0.903
[Epoch 30] train loss: 0.246 - val loss: 0.326 - val accuracy: 0.891
[Epoch 35] train loss: 0.235 - val loss: 0.324 - val accuracy: 0.902
[Epoch 40] train loss: 0.217 - val loss: 0.327 - val accuracy: 0.908
4.6 Curvas de aprendizaje¶
Graficamos la pérdida de entrenamiento y de validación junto con las exactitudes. La brecha entre las dos curvas es una lectura directa del sobreajuste: una pérdida de entrenamiento que sigue bajando mientras la de validación se estanca o sube significa que el modelo está memorizando el conjunto de entrenamiento.
epochs = np.arange(1, len(history["train_loss"]) + 1)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9, 3.5))
ax1.plot(epochs, history["train_loss"], label="train")
ax1.plot(epochs, history["val_loss"], label="validation")
ax1.set_xlabel("Epoch")
ax1.set_ylabel("Cross-entropy loss")
ax1.set_title("Loss")
ax1.legend()
ax1.grid(True)
ax2.plot(epochs, history["train_acc"], label="train")
ax2.plot(epochs, history["val_acc"], label="validation")
ax2.set_xlabel("Epoch")
ax2.set_ylabel("Accuracy")
ax2.set_title("Accuracy")
ax2.legend()
ax2.grid(True)
plt.tight_layout()
4.7 Desempeño en el conjunto de prueba¶
Ahora evaluamos una sola vez en el conjunto de prueba reservado: exactitud global y, por clase, precisión, exhaustividad (recall) y puntaje F1.
def predict(model, loader):
model.eval()
preds = []
with torch.no_grad():
for xb, _ in loader:
logits = model(xb.to(device))
preds.append(logits.argmax(dim=1).cpu().numpy())
return np.concatenate(preds)
y_pred = predict(model, test_loader)
print(f"Test accuracy: {accuracy_score(y_test, y_pred):.3f}\n")
print(classification_report(y_test, y_pred, target_names=class_names))Test accuracy: 0.890
precision recall f1-score support
earthquake 0.90 0.85 0.88 200
explosion 0.86 0.81 0.83 200
noise 0.90 0.96 0.93 200
surface event 0.90 0.94 0.92 200
accuracy 0.89 800
macro avg 0.89 0.89 0.89 800
weighted avg 0.89 0.89 0.89 800
Ejercicio: reconstruya el modelo con p_drop=0.0 y reentrene. ¿Cómo cambia la brecha entre las curvas de entrenamiento y validación?
Solución
Ejecute model_nodrop = SourceMLP(n_features, n_classes, p_drop=0.0).to(device) y llame a train sobre él. Sin dropout, la pérdida de entrenamiento baja más rápido y más lejos, y la exactitud de entrenamiento sube más, pero las curvas de validación se estancan antes: la brecha entre las dos se ensancha. Esa brecha creciente es sobreajuste, que el dropout estaba suprimiendo.
5. Guardar y restaurar un modelo¶
5.1 Guardar los pesos con state_dict¶
Los parámetros entrenables de un modelo de PyTorch viven en su state_dict, un diccionario que asocia cada capa con sus tensores de pesos y sesgos. La manera estándar de guardar un modelo entrenado es guardar este diccionario, no el objeto del modelo en sí:
torch.save(model.state_dict(), path)escribe los pesos a disco.- Para restaurar, construya una instancia nueva de la misma clase de modelo y llame a
load_state_dict. - Llame a
model.eval()antes de predecir, para que el dropout quede apagado y la normalización por lotes use sus estadísticas móviles.
Cargar con map_location=device hace que el archivo sea portable entre máquinas: los pesos guardados en una GPU cargan sin problema en una máquina que solo tiene CPU.
os.makedirs("checkpoints", exist_ok=True)
torch.save(model.state_dict(), "checkpoints/mlp_weights.pt")
# restore into a fresh model instance
restored = SourceMLP(n_features, n_classes).to(device)
restored.load_state_dict(torch.load("checkpoints/mlp_weights.pt", map_location=device))
restored.eval()
# check that the restored model gives the same predictions
y_pred_restored = predict(restored, test_loader)
print(f"Restored predictions identical: {np.array_equal(y_pred, y_pred_restored)}")Restored predictions identical: True
5.2 Puntos de control durante el entrenamiento¶
Guardar solo los pesos finales tiene un defecto: la última época no siempre es la mejor. Si el modelo empieza a sobreajustarse, la pérdida de validación sube mientras el entrenamiento continúa, y los pesos finales son peores que una versión anterior. Los puntos de control (checkpointing) corrigen esto: durante el entrenamiento guardamos el modelo cada vez que la pérdida de validación mejoraba (sección 4.5), junto con el número de época y el estado del optimizador. El estado del optimizador permite reanudar el entrenamiento exactamente donde se tomó el checkpoint.
Ahora restauramos el mejor checkpoint y lo evaluamos en el conjunto de prueba.
ckpt = torch.load("checkpoints/best_mlp.pt", map_location=device)
print(f"Best checkpoint: epoch {ckpt['epoch']}, validation loss {ckpt['val_loss']:.3f}")
best_model = SourceMLP(n_features, n_classes).to(device)
best_model.load_state_dict(ckpt["state_dict"])
best_model.eval()
y_pred_best = predict(best_model, test_loader)
print(f"Test accuracy (final model): {accuracy_score(y_test, y_pred):.3f}")
print(f"Test accuracy (best checkpoint): {accuracy_score(y_test, y_pred_best):.3f}\n")
print(classification_report(y_test, y_pred_best, target_names=class_names))Best checkpoint: epoch 19, validation loss 0.316
Test accuracy (final model): 0.890
Test accuracy (best checkpoint): 0.886
precision recall f1-score support
earthquake 0.89 0.85 0.87 200
explosion 0.86 0.82 0.84 200
noise 0.90 0.93 0.92 200
surface event 0.89 0.94 0.91 200
accuracy 0.89 800
macro avg 0.89 0.89 0.89 800
weighted avg 0.89 0.89 0.89 800
6. El MLP en scikit-learn¶
Scikit-learn incluye su propio MLP, MLPClassifier (y MLPRegressor). Es la misma familia de modelos: capas totalmente conectadas, activaciones ReLU, entrenamiento por minilotes con Adam. Lo que se intercambia es control. Usted renuncia al bucle de entrenamiento propio, al dropout, a la normalización por lotes, a los checkpoints y a la ejecución en GPU, y a cambio todo el flujo de trabajo son unas pocas líneas que se ajustan a la interfaz conocida fit/predict. Para problemas tabulares pequeños, esto suele ser todo lo que se necesita.
Lo demostramos con las características sintéticas del detector de eventos de mlgeo_synth: 4 características derivadas de la forma de onda (STA/LTA, curtosis, centroide espectral, frecuencia dominante) y una etiqueta binaria balanceada (evento vs. ruido).
from mlgeo_synth import detector_features
from sklearn.neural_network import MLPClassifier
from sklearn.pipeline import make_pipeline
det = detector_features(n=2000, event_fraction=0.5, seed=0)
X_det = det.drop(columns=["label"]).to_numpy()
y_det = det["label"].to_numpy()
Xd_train, Xd_test, yd_train, yd_test = train_test_split(
X_det, y_det, test_size=0.2, random_state=42, stratify=y_det)
clf = make_pipeline(
StandardScaler(),
MLPClassifier(hidden_layer_sizes=(32, 16), max_iter=500, random_state=42))
clf.fit(Xd_train, yd_train)
print(f"Train accuracy: {clf.score(Xd_train, yd_train):.3f}")
print(f"Test accuracy: {clf.score(Xd_test, yd_test):.3f}")
print(classification_report(yd_test, clf.predict(Xd_test),
target_names=["noise", "event"]))Train accuracy: 0.991
Test accuracy: 0.985
precision recall f1-score support
noise 0.97 1.00 0.99 200
event 1.00 0.97 0.98 200
accuracy 0.98 400
macro avg 0.99 0.98 0.98 400
weighted avg 0.99 0.98 0.98 400
Dos líneas de código de modelo, sin bucle de entrenamiento, y el pipeline de scikit-learn (Pipeline) se encarga del escalado. Pero las decisiones de arquitectura se detienen en los tamaños de capa, la activación y un puñado de ajustes del solver. Cuando necesite dropout, normalización por lotes, pérdidas personalizadas o una GPU, escribirá la versión en PyTorch.
7. Resumen¶
En este cuaderno usted:
- repasó las funciones de activación y la estructura de los MLP de regresión y de clasificación,
- construyó en PyTorch un MLP de tres capas ocultas con dropout y normalización por lotes, y lo entrenó con minilotes y Adam sobre los datos de fuentes sísmicas de cuatro clases,
- leyó el sobreajuste en las curvas de aprendizaje de entrenamiento/validación,
- guardó y restauró modelos con
state_dict, y usó checkpoints para conservar la mejor versión vista durante el entrenamiento, - comparó con el
MLPClassifierde scikit-learn.
Aquí elegimos a mano los tamaños de capa, la tasa de dropout y la tasa de aprendizaje. Elegirlos de manera sistemática, buscando sobre arquitecturas e hiperparámetros, es el tema del laboratorio del cuaderno 4.5.