Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

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.

Multi Layer Perceptron

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

h(x)=ϕ(Wx+b)h(\mathbf{x}) = \phi (\mathbf{W} \mathbf{x} + \mathbf{b}),

donde ϕ es la función de activación, W\mathbf{W} la matriz de pesos, b\mathbf{b} el vector de sesgos y x\mathbf{x} 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.

🖥️ Diapositivas — Sesión 21 (mié 18 nov)

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): ReLU(x)=max(x,0)\mathrm{ReLU}(x)=\max(x,0). 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: σ(x)=11+exp(x)\sigma(x) = \frac{1}{1+\exp(-x)} 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: tanh(x)=2σ(2x)1\tanh(x) = 2 \sigma(2x) -1 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()
<Figure size 500x350 with 1 Axes>

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.

Regression MLP

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.

Classification MLP

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 1/(1p)1/(1-p). 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://pytorch.org/docs/stable/optim.html). Después de cada época evaluamos la pérdida y la exactitud en el conjunto de validación.

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 history
history = 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()
<Figure size 900x350 with 2 Axes>

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?

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í:

  1. torch.save(model.state_dict(), path) escribe los pesos a disco.
  2. Para restaurar, construya una instancia nueva de la misma clase de modelo y llame a load_state_dict.
  3. 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 MLPClassifier de 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.