scPTR / src /scptr /deep /_trainer.py
bryan7264's picture
Add files using upload-large-folder tool
e8edb9d verified
Raw
History Blame Contribute Delete
6.17 kB
"""Training loop for DeepPTR with KL warmup and early stopping."""
from __future__ import annotations
import math
from dataclasses import dataclass, field
import torch
from torch import nn
from torch.utils.data import DataLoader
from ._model import DeepPTR
@dataclass
class TrainHistory:
"""Stores per-epoch training metrics."""
train_loss: list[float] = field(default_factory=list)
val_loss: list[float] = field(default_factory=list)
train_recon: list[float] = field(default_factory=list)
val_recon: list[float] = field(default_factory=list)
train_kl: list[float] = field(default_factory=list)
val_kl: list[float] = field(default_factory=list)
kl_weight: list[float] = field(default_factory=list)
lr: list[float] = field(default_factory=list)
class Trainer:
"""Train a :class:`DeepPTR` model.
Parameters
----------
model
A :class:`DeepPTR` instance.
lr
Initial learning rate for Adam.
weight_decay
L2 regularization.
max_epochs
Maximum training epochs.
kl_warmup_epochs
Number of epochs for linear KL annealing (0→1).
patience
Early-stopping patience (starts counting after warmup).
max_grad_norm
Gradient clipping threshold.
device
``"cuda"`` or ``"cpu"``.
"""
def __init__(
self,
model: DeepPTR,
lr: float = 1e-3,
weight_decay: float = 1e-6,
max_epochs: int = 400,
kl_warmup_epochs: int = 50,
patience: int = 30,
max_grad_norm: float = 5.0,
device: str | None = None,
) -> None:
if device is None:
device = "cuda" if torch.cuda.is_available() else "cpu"
self.device = torch.device(device)
self.model = model.to(self.device)
self.max_epochs = max_epochs
self.kl_warmup_epochs = kl_warmup_epochs
self.patience = patience
self.max_grad_norm = max_grad_norm
self.optimizer = torch.optim.Adam(
model.parameters(), lr=lr, weight_decay=weight_decay
)
self.scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
self.optimizer, mode="min", factor=0.5, patience=10, min_lr=1e-6
)
self.history = TrainHistory()
def _kl_weight(self, epoch: int) -> float:
if self.kl_warmup_epochs <= 0:
return 1.0
return min(1.0, epoch / self.kl_warmup_epochs)
def _run_epoch(
self, loader: DataLoader, kl_w: float, train: bool = True
) -> tuple[float, float, float]:
self.model.train(train)
total_loss = 0.0
total_recon = 0.0
total_kl = 0.0
n_batches = 0
ctx = torch.enable_grad() if train else torch.no_grad()
with ctx:
for s, u, l_s, l_u in loader:
s = s.to(self.device)
u = u.to(self.device)
l_s = l_s.to(self.device)
l_u = l_u.to(self.device)
out = self.model(s, u, l_s, l_u, kl_weight=kl_w)
loss = out["loss"]
if train:
self.optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(
self.model.parameters(), self.max_grad_norm
)
self.optimizer.step()
total_loss += loss.item()
total_recon += out["recon_loss"].item()
total_kl += out["kl_loss"].item()
n_batches += 1
return (
total_loss / max(n_batches, 1),
total_recon / max(n_batches, 1),
total_kl / max(n_batches, 1),
)
def fit(
self,
train_dl: DataLoader,
val_dl: DataLoader,
verbose: bool = True,
) -> TrainHistory:
"""Run the full training loop.
Parameters
----------
train_dl, val_dl
Training and validation DataLoaders.
verbose
Print progress every 10 epochs.
Returns
-------
TrainHistory
"""
best_val = math.inf
wait = 0
best_state = None
for epoch in range(1, self.max_epochs + 1):
kl_w = self._kl_weight(epoch)
tr_loss, tr_recon, tr_kl = self._run_epoch(train_dl, kl_w, train=True)
vl_loss, vl_recon, vl_kl = self._run_epoch(val_dl, kl_w, train=False)
self.scheduler.step(vl_loss)
cur_lr = self.optimizer.param_groups[0]["lr"]
self.history.train_loss.append(tr_loss)
self.history.val_loss.append(vl_loss)
self.history.train_recon.append(tr_recon)
self.history.val_recon.append(vl_recon)
self.history.train_kl.append(tr_kl)
self.history.val_kl.append(vl_kl)
self.history.kl_weight.append(kl_w)
self.history.lr.append(cur_lr)
if verbose and (epoch % 10 == 0 or epoch == 1):
print(
f"Epoch {epoch:4d} | "
f"train {tr_loss:.2f} (recon {tr_recon:.2f}, kl {tr_kl:.2f}) | "
f"val {vl_loss:.2f} | kl_w {kl_w:.3f} | lr {cur_lr:.1e}"
)
# Early stopping (only after warmup)
if epoch >= self.kl_warmup_epochs:
if vl_loss < best_val:
best_val = vl_loss
wait = 0
best_state = {
k: v.cpu().clone()
for k, v in self.model.state_dict().items()
}
else:
wait += 1
if wait >= self.patience:
if verbose:
print(
f"Early stopping at epoch {epoch} "
f"(best val={best_val:.2f})"
)
break
# Restore best model
if best_state is not None:
self.model.load_state_dict(best_state)
self.model.to(self.device)
return self.history