# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
Training loop for EM neural networks.
:class:`EMTrainer` wraps a PyTorch ``nn.Module`` and handles:
* Mini-batch training with ``DataLoader``
* Masked MSE loss (ignores NaN padding for variable-depth datasets)
* ``ReduceLROnPlateau`` learning-rate scheduling
* Early stopping with configurable patience
* Per-epoch progress display (``tqdm`` if available)
* History dict for :func:`~pycsamt.ai.plot.convergence.plot_convergence`
"""
from __future__ import annotations
import time
import numpy as np
__all__ = ["EMTrainer"]
_TQDM_AVAILABLE = False
try:
from tqdm.auto import tqdm as _tqdm
_TQDM_AVAILABLE = True
except ImportError:
pass
[docs]
class EMTrainer:
"""
Training loop manager for EM 1-D inversion networks.
Parameters
----------
model : nn.Module
The PyTorch network to train.
lr : float
Initial learning rate. Default 1e-3.
weight_decay : float
L2 regularisation. Default 1e-5.
patience : int
Early-stopping patience (epochs without val-loss improvement).
min_delta : float
Minimum val-loss improvement to reset patience counter.
batch_size : int
Mini-batch size.
device : str
Compute device (``'cpu'``, ``'cuda'``, ``'mps'``).
grad_clip : float or None
If set, clip gradient norms to this value.
verbose : bool
Print per-epoch summary.
Attributes
----------
history : dict
Keys ``'train_loss'`` and ``'val_loss'`` — lists of per-epoch
averages. Also ``'lr'`` and ``'epoch_time'``.
best_epoch : int
Epoch index at which the best validation loss was achieved.
best_val_loss : float
"""
def __init__(
self,
model,
*,
lr: float = 1e-3,
weight_decay: float = 1e-5,
patience: int = 20,
min_delta: float = 1e-5,
batch_size: int = 256,
device: str = "cpu",
grad_clip: float | None = None,
verbose: bool = True,
):
self.model = model
self.lr = lr
self.weight_decay = weight_decay
self.patience = patience
self.min_delta = min_delta
self.batch_size = batch_size
self.device = device
self.grad_clip = grad_clip
self.verbose = verbose
self.history: dict[str, list] = {
"train_loss": [],
"val_loss": [],
"lr": [],
"epoch_time": [],
}
self.best_epoch: int = 0
self.best_val_loss: float = float("inf")
self._best_state: dict | None = None
# ─── main fit ──────────────────────────────────────────────────────────
[docs]
def fit(
self,
train_ds,
val_ds,
epochs: int = 100,
) -> EMTrainer:
"""
Train the network.
Parameters
----------
train_ds : EMDataset
Training split.
val_ds : EMDataset
Validation split.
epochs : int
Maximum number of epochs.
Returns
-------
self
"""
try:
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
except ImportError:
raise ImportError("PyTorch required for EMTrainer.fit().")
from .metrics import masked_mse_loss
device = torch.device(self.device)
self.model = self.model.to(device)
optimiser = torch.optim.Adam(
self.model.parameters(),
lr=self.lr,
weight_decay=self.weight_decay,
)
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
optimiser, mode="min", factor=0.5, patience=10
)
train_loader = DataLoader(
train_ds,
batch_size=self.batch_size,
shuffle=True,
drop_last=False,
pin_memory=(self.device != "cpu"),
)
val_loader = DataLoader(
val_ds,
batch_size=self.batch_size * 2,
shuffle=False,
)
patience_ctr = 0
for epoch in range(1, epochs + 1):
t0 = time.perf_counter()
train_loss = self._train_epoch(
train_loader, optimiser, device, masked_mse_loss
)
val_loss = self._eval_epoch(val_loader, device, masked_mse_loss)
elapsed = time.perf_counter() - t0
scheduler.step(val_loss)
current_lr = optimiser.param_groups[0]["lr"]
self.history["train_loss"].append(train_loss)
self.history["val_loss"].append(val_loss)
self.history["lr"].append(current_lr)
self.history["epoch_time"].append(elapsed)
# Early stopping
if val_loss < self.best_val_loss - self.min_delta:
self.best_val_loss = val_loss
self.best_epoch = epoch
self._best_state = {
k: v.cpu().clone()
for k, v in self.model.state_dict().items()
}
patience_ctr = 0
else:
patience_ctr += 1
if self.verbose and (
epoch % max(1, epochs // 20) == 0 or epoch == 1
):
print(
f" Epoch {epoch:4d}/{epochs} | "
f"train={train_loss:.5f} val={val_loss:.5f} "
f"lr={current_lr:.2e} [{elapsed:.1f}s]"
)
if patience_ctr >= self.patience:
if self.verbose:
print(
f" Early stop at epoch {epoch} "
f"(best val={self.best_val_loss:.5f} @ epoch {self.best_epoch})"
)
break
# Restore best weights
if self._best_state is not None:
self.model.load_state_dict(self._best_state)
self.model = self.model.to(device)
return self
# ─── internal epoch helpers ────────────────────────────────────────────
def _train_epoch(self, loader, optimiser, device, loss_fn) -> float:
import torch
self.model.train()
total = 0.0
n_batch = 0
for x_batch, y_batch in loader:
x_batch = x_batch.to(device, non_blocking=True)
y_batch = y_batch.to(device, non_blocking=True)
optimiser.zero_grad(set_to_none=True)
pred = self.model(x_batch)
loss = loss_fn(pred, y_batch)
loss.backward()
if self.grad_clip is not None:
torch.nn.utils.clip_grad_norm_(
self.model.parameters(), self.grad_clip
)
optimiser.step()
total += loss.item()
n_batch += 1
return total / max(1, n_batch)
def _eval_epoch(self, loader, device, loss_fn) -> float:
import torch
self.model.eval()
total = 0.0
n_batch = 0
with torch.no_grad():
for x_batch, y_batch in loader:
x_batch = x_batch.to(device, non_blocking=True)
y_batch = y_batch.to(device, non_blocking=True)
pred = self.model(x_batch)
total += loss_fn(pred, y_batch).item()
n_batch += 1
return total / max(1, n_batch)
# ─── weight helpers ────────────────────────────────────────────────────
[docs]
def get_weights(self) -> dict[str, np.ndarray]:
"""Return model weights as numpy dict."""
return {
k: v.cpu().numpy() for k, v in self.model.state_dict().items()
}
[docs]
def load_weights(self, weights: dict[str, np.ndarray]) -> None:
"""Restore weights from numpy dict."""
import torch
state = {k: torch.from_numpy(v) for k, v in weights.items()}
self.model.load_state_dict(state)
def __repr__(self) -> str:
return (
f"EMTrainer(lr={self.lr}, batch_size={self.batch_size}, "
f"patience={self.patience}, device={self.device!r})"
)