""" Training script for DeepHealth model. Implements the complete training pipeline: 1. Load data and split train/val/test 2. Build model, optimizer, readout, and loss 3. Train with adaptive learning rate (warmup + cosine annealing) 4. Early stopping based on validation loss 5. Save checkpoints and metrics """ from __future__ import annotations import argparse import json import logging import math import os import sys import time from datetime import datetime from pathlib import Path from typing import Any, Dict, Tuple import numpy as np import torch import torch.nn as nn from torch.utils.data import DataLoader, RandomSampler, Subset from torch.optim import AdamW from torch.nn.utils import clip_grad_norm_ from tqdm.auto import tqdm from dataset import HealthDataset, collate_fn from models import DeepHealth from readouts import build_readout from losses import build_loss from targets import PAD_IDX, CHECKUP_IDX, NO_EVENT_IDX # --------------------------------------------------------------------------- # Setup Logging # --------------------------------------------------------------------------- def setup_logging(run_dir: Path) -> logging.Logger: """Configure logging to both console and file.""" run_dir.mkdir(parents=True, exist_ok=True) log_file = run_dir / "train.log" logger = logging.getLogger("DeepHealth") logger.setLevel(logging.INFO) logger.handlers.clear() # Console handler console_handler = logging.StreamHandler(sys.stdout) console_handler.setLevel(logging.INFO) console_formatter = logging.Formatter( "%(asctime)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S" ) console_handler.setFormatter(console_formatter) logger.addHandler(console_handler) # File handler file_handler = logging.FileHandler(log_file, mode="w") file_handler.setLevel(logging.INFO) file_formatter = logging.Formatter( "%(asctime)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S" ) file_handler.setFormatter(file_formatter) logger.addHandler(file_handler) return logger # --------------------------------------------------------------------------- # Utilities # --------------------------------------------------------------------------- def set_seed(seed: int) -> None: """Set random seed for reproducibility.""" np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed(seed) def load_extra_info_types_file(path: str) -> list[int]: """ Load other-information type ids from a text or JSON file. Text files may use whitespace, commas, or semicolons as separators. Lines may contain comments after '#'. JSON files should contain a top-level list of ids. """ file_path = Path(path) if not file_path.exists(): raise FileNotFoundError(f"extra_info_types_file not found: {path}") if not file_path.is_file(): raise ValueError(f"extra_info_types_file is not a file: {path}") text = file_path.read_text(encoding="utf-8").strip() if not text: return [] if text.startswith("["): try: raw_items = json.loads(text) except json.JSONDecodeError as exc: raise ValueError( f"Invalid JSON in extra_info_types_file: {path}" ) from exc if not isinstance(raw_items, list): raise ValueError( f"extra_info_types_file JSON must be a list, got {type(raw_items).__name__}" ) else: tokens: list[str] = [] for line in text.splitlines(): line = line.split("#", 1)[0].strip() if not line: continue tokens.extend(line.replace(",", " ").replace(";", " ").split()) raw_items = tokens parsed: list[int] = [] for item in raw_items: try: type_id = int(item) except (TypeError, ValueError) as exc: raise ValueError( f"Invalid extra info type id {item!r} in {path}; expected integers." ) from exc parsed.append(type_id) return parsed def configure_torch_for_training(device: torch.device) -> None: """Enable backend settings that can improve training throughput on CUDA.""" if device.type == "cuda": torch.backends.cuda.matmul.allow_tf32 = True torch.backends.cudnn.allow_tf32 = True if hasattr(torch, "set_float32_matmul_precision"): torch.set_float32_matmul_precision("high") def resolve_device(device_arg: str) -> torch.device: """Resolve and validate requested device string.""" requested = device_arg.strip().lower() if requested == "cpu": return torch.device("cpu") if requested == "cuda": if not torch.cuda.is_available(): return torch.device("cpu") return torch.device("cuda") if requested.startswith("cuda:"): if not torch.cuda.is_available(): return torch.device("cpu") try: index = int(requested.split(":", 1)[1]) except ValueError as exc: raise ValueError( f"Invalid CUDA device format '{device_arg}'. Use 'cuda' or 'cuda:'." ) from exc n_cuda = torch.cuda.device_count() if index < 0 or index >= n_cuda: raise ValueError( f"Requested device '{device_arg}' is out of range. " f"Available CUDA devices: 0..{max(0, n_cuda - 1)}" ) return torch.device(f"cuda:{index}") raise ValueError( f"Unsupported device '{device_arg}'. Use 'cpu', 'cuda', or 'cuda:'." ) def split_dataset( dataset: HealthDataset, train_ratio: float, val_ratio: float, test_ratio: float, seed: int, ) -> Tuple[Subset, Subset, Subset]: """ Split dataset into train/val/test subsets. Parameters ---------- train_ratio, val_ratio, test_ratio : float Ratios must sum to 1.0 (within 1e-6 tolerance). seed : int Random seed for splitting. Returns ------- train_subset, val_subset, test_subset """ total = train_ratio + val_ratio + test_ratio if not np.isclose(total, 1.0, atol=1e-6): raise ValueError( f"train_ratio + val_ratio + test_ratio must equal 1.0, " f"got {total}" ) n = len(dataset) rng = np.random.RandomState(seed) indices = rng.permutation(n) n_train = int(n * train_ratio) n_val = int(n * val_ratio) train_indices = indices[:n_train] val_indices = indices[n_train: n_train + n_val] test_indices = indices[n_train + n_val:] return ( Subset(dataset, train_indices), Subset(dataset, val_indices), Subset(dataset, test_indices), ) def build_model(args: argparse.Namespace, dataset: HealthDataset) -> DeepHealth: """ Build DeepHealth model using metadata from dataset. Uses unified other-information metadata computed during dataset initialization. """ model = DeepHealth( vocab_size=dataset.vocab_size, n_embd=args.n_embd, n_head=args.n_head, n_hist_layer=args.n_hist_layer, n_tab_layer=args.n_tab_layer, n_types=dataset.n_types, n_cont_types=dataset.n_cont_types, n_categories=dataset.n_categories, cont_type_ids=dataset.cont_type_ids, n_bins=args.n_bins, target_mode="next_token", time_mode=args.time_mode, dist_mode="exponential", dropout=args.dropout, ) return model def build_optimizer(args: argparse.Namespace, model: nn.Module) -> AdamW: """Build AdamW optimizer.""" return AdamW( model.parameters(), lr=args.base_lr, betas=args.betas, weight_decay=args.weight_decay, ) def get_lr( epoch: int, args: argparse.Namespace, adaptive_lr: float, total_epochs: int, ) -> float: """ Calculate learning rate for the given epoch. Warmup: linear from 0 to adaptive_lr over warmup_epochs After: cosine annealing from adaptive_lr to adaptive_lr * min_lr_ratio """ if epoch < args.warmup_epochs: # Linear warmup return adaptive_lr * (epoch + 1) / args.warmup_epochs else: # Cosine annealing progress = (epoch - args.warmup_epochs) / \ (total_epochs - args.warmup_epochs) return adaptive_lr * (args.min_lr_ratio + 0.5 * (1 + math.cos(math.pi * progress)) * (1 - args.min_lr_ratio)) def set_optimizer_lr(optimizer: AdamW, lr: float) -> None: """Set learning rate for all parameter groups.""" for param_group in optimizer.param_groups: param_group["lr"] = lr def move_batch_to_device(batch: Dict, device: torch.device) -> Dict: """Move all tensors in batch to specified device.""" non_blocking = device.type == "cuda" return { key: value.to(device, non_blocking=non_blocking) if isinstance( value, torch.Tensor) else value for key, value in batch.items() } def compute_loss( args: argparse.Namespace, model: DeepHealth, readout: nn.Module, criterion: nn.Module, batch: Dict[str, torch.Tensor], device: torch.device, ) -> tuple[torch.Tensor, Dict[str, torch.Tensor]]: """ Compute loss for one batch. Flow: forward model, apply readout, compute risk logits, compute loss. Parameters ---------- args : argparse.Namespace Training configuration with target_mode and loss options. model : DeepHealth The model. readout : nn.Module Readout module. criterion : nn.Module Loss criterion. batch : Dict Batch data from DataLoader. device : torch.device Device to compute on. Returns ------- loss : torch.Tensor Scalar loss tensor. """ # Move batch to device batch = move_batch_to_device(batch, device) event_seq = batch["event_seq"] # (B, L) time_seq = batch["time_seq"] # (B, L) padding_mask = batch["padding_mask"] # (B, L) sex = batch["sex"] # (B,) hidden = model( event_seq=event_seq, time_seq=time_seq, sex=sex, padding_mask=padding_mask, other_type=batch["other_type"], other_value=batch["other_value"], other_value_kind=batch["other_value_kind"], other_time=batch["other_time"], target_mode="next_token", ) # Apply readout readout_mask = ( batch["readout_mask"] if args.readout_name == "same_time_group_end" else None ) readout_out = readout( hidden=hidden, time_seq=time_seq, padding_mask=padding_mask, readout_mask=readout_mask, ) # Compute risk logits logits = model.calc_risk(readout_out.hidden) # Compute loss based on target_mode if args.target_mode == "delphi2m": loss_out = criterion( logits=logits, target_events=batch["target_event_seq"], target_times=batch["target_time_seq"], current_times=batch["time_seq"], padding_mask=readout_out.readout_mask, return_components=True, ) elif args.target_mode == "uts": loss_out = criterion( logits=logits, target_multi_hot=batch["target_multi_hot"], target_dt_unique=batch["target_dt_unique"], readout_mask=readout_out.readout_mask, return_components=True, ) else: raise ValueError(f"Unknown target_mode: {args.target_mode}") loss, loss_parts = loss_out # Check for NaN/Inf if not torch.isfinite(loss): raise RuntimeError( f"Loss is not finite: {loss.item()}. " f"batch_idx info: event_seq shape {event_seq.shape}, " f"logits shape {logits.shape}, logits range [{logits.min():.4f}, {logits.max():.4f}]" ) return loss, loss_parts def run_one_epoch( logger: logging.Logger, args: argparse.Namespace, model: DeepHealth, readout: nn.Module, criterion: nn.Module, train_loader: DataLoader, optimizer: AdamW, device: torch.device, is_train: bool = True, ) -> float: """ Run one epoch of training or validation. Parameters ---------- logger : logging.Logger args : argparse.Namespace model : DeepHealth readout : nn.Module criterion : nn.Module train_loader : DataLoader optimizer : AdamW Unused if is_train=False. device : torch.device is_train : bool If True, perform training updates. Otherwise just evaluate. Returns ------- avg_loss : float Average loss over the epoch. """ if is_train: model.train() else: model.eval() total_loss = 0.0 n_batches = 0 n_skipped = 0 component_sums: Dict[str, float] = {} epoch_desc = "train" if is_train else "val" progress = tqdm( train_loader, desc=epoch_desc, total=len(train_loader), leave=False, dynamic_ncols=True, ) for batch_idx, batch in enumerate(progress): try: loss, loss_parts = compute_loss( args=args, model=model, readout=readout, criterion=criterion, batch=batch, device=device, ) if is_train: optimizer.zero_grad(set_to_none=True) loss.backward() if args.grad_clip > 0: clip_grad_norm_(model.parameters(), args.grad_clip) optimizer.step() total_loss += loss.item() n_batches += 1 for name, value in loss_parts.items(): component_sums[name] = component_sums.get(name, 0.0) + float( value.detach().item() ) current_loss = loss.item() avg_loss = total_loss / max(1, n_batches) postfix = { "loss": f"{current_loss:.4f}", "avg": f"{avg_loss:.4f}", "skipped": n_skipped, } for name, sum_value in component_sums.items(): postfix[name] = f"{sum_value / max(1, n_batches):.4f}" progress.set_postfix(postfix) except RuntimeError as e: if "Loss is not finite" in str(e): n_skipped += 1 logger.warning(f"Batch {batch_idx} skipped: {str(e)[:100]}") progress.set_postfix( loss="nan", avg=f"{total_loss / max(1, n_batches):.4f}" if n_batches > 0 else "inf", skipped=n_skipped, ) continue else: raise if n_skipped > 0: logger.info(f"Skipped {n_skipped} batches due to non-finite loss") if component_sums and n_batches > 0: component_summary = ", ".join( f"{name}={sum_value / n_batches:.4f}" for name, sum_value in sorted(component_sums.items()) ) logger.info(f"Epoch loss breakdown: {component_summary}") avg_loss = total_loss / max(1, n_batches) if n_batches > 0 else float("inf") return avg_loss def evaluate( logger: logging.Logger, args: argparse.Namespace, model: DeepHealth, readout: nn.Module, criterion: nn.Module, val_loader: DataLoader, device: torch.device, ) -> float: """ Evaluate model on validation set. Returns ------- val_loss : float """ with torch.no_grad(): val_loss = run_one_epoch( logger=logger, args=args, model=model, readout=readout, criterion=criterion, train_loader=val_loader, optimizer=None, device=device, is_train=False, ) return val_loss def save_checkpoint( model: DeepHealth, checkpoint_path: Path, ) -> None: """Save model state_dict.""" torch.save(model.state_dict(), checkpoint_path) def save_config( args: argparse.Namespace, config_path: Path, extra: Dict[str, Any] | None = None, ) -> None: """Save training config as JSON.""" config_dict: Dict[str, Any] = {} for key, value in vars(args).items(): if isinstance(value, tuple): config_dict[key] = list(value) elif isinstance(value, list): config_dict[key] = value elif isinstance(value, (int, float, str, bool, type(None))): config_dict[key] = value else: config_dict[key] = str(value) if extra: config_dict.update(extra) with open(config_path, "w") as f: json.dump(config_dict, f, indent=2) def build_run_metadata( args: argparse.Namespace, dataset: HealthDataset, train_subset: Subset, val_subset: Subset, test_subset: Subset, run_name: str, ) -> Dict[str, Any]: """Collect resolved training facts needed to rebuild the model for evaluation.""" return { "run_name": run_name, "dataset_class": "NextStepHealthDataset", "collate_fn": "next_step_collate_fn", "model_class": "DeepHealth", "model_target_mode": "next_token", "dist_mode": "exponential", "extra_info_types_file": ( Path(args.extra_info_types_file).name if args.extra_info_types_file is not None else None ), "extra_info_types": dataset.extra_info_types, "dataset_metadata": { "vocab_size": int(dataset.vocab_size), "n_types": int(dataset.n_types), "n_cont_types": int(dataset.n_cont_types), "n_categories": int(dataset.n_categories), "cont_type_ids": [int(x) for x in dataset.cont_type_ids], "extra_info_types": [int(x) for x in dataset.extra_info_types], }, "split_sizes": { "train": int(len(train_subset)), "val": int(len(val_subset)), "test": int(len(test_subset)), }, "resolved_readout_name": args.readout_name, "resolved_loss_name": args.loss_name, } def normalize_training_config(args: argparse.Namespace) -> None: """Fill in and validate training options that depend on other flags.""" if args.target_mode not in {"delphi2m", "uts"}: raise ValueError(f"Unknown target_mode: {args.target_mode}") # gap_5y is always enabled, so preserve NO_EVENT target behavior. args.ignore_no_event_in_delphi2m = False if args.target_mode == "uts": args.include_no_event_in_uts_target = True def normalize_loss_and_distribution_config(args: argparse.Namespace) -> None: """Validate and resolve loss/distribution options after auto-selection.""" if args.loss_name not in {"delphi2m", "uts"}: raise ValueError( "Unknown loss_name. Supported values: delphi2m, uts." ) if args.loss_name == "delphi2m" and args.target_mode != "delphi2m": raise ValueError( "loss_name=delphi2m requires target_mode=delphi2m." ) if args.loss_name == "uts" and args.target_mode != "uts": raise ValueError( "loss_name=uts requires target_mode=uts." ) # --------------------------------------------------------------------------- # Main Training Loop # --------------------------------------------------------------------------- def main(): """Main training function.""" parser = argparse.ArgumentParser(description="DeepHealth Training") # ---- Data & Output ---- parser.add_argument("--data_prefix", type=str, default="ukb", help="Prefix for data files") parser.add_argument("--labels_file", type=str, default="labels.csv", help="Path to labels file") parser.add_argument("--seed", type=int, default=42, help="Random seed") # ---- Dataset ---- parser.add_argument("--no_event_interval_years", type=float, default=5.0, help="Interval in years for no-event insertion") parser.add_argument("--include_no_event_in_uts_target", action="store_true", help="Include NO_EVENT in UTS target multi-hot") # ---- Split ---- parser.add_argument("--train_ratio", type=float, default=0.7, help="Training set ratio") parser.add_argument("--val_ratio", type=float, default=0.15, help="Validation set ratio") parser.add_argument("--test_ratio", type=float, default=0.15, help="Test set ratio") # ---- Model ---- parser.add_argument("--n_embd", type=int, default=120, help="Embedding dimension") parser.add_argument("--n_head", type=int, default=10, help="Number of attention heads") parser.add_argument("--n_hist_layer", type=int, default=12, help="Number of history encoder layers") parser.add_argument("--n_tab_layer", type=int, default=4, help="Number of self-attention layers for other-token encoder") parser.add_argument("--n_bins", type=int, default=16, help="Number of bins for continuous other-token values") parser.add_argument("--time_mode", type=str, default="relative", choices=["relative", "absolute"], help="Time encoding mode for disease history") parser.add_argument("--dropout", type=float, default=0.0, help="Dropout rate") parser.add_argument("--extra_info_types_file", type=str, default=None, help="Optional file containing other-information type ids to include") # ---- Training Protocol ---- parser.add_argument("--target_mode", type=str, default="uts", choices=["delphi2m", "uts"], help="Target supervision mode") parser.add_argument("--readout_name", type=str, default=None, help="Readout name (auto-selected if None)") parser.add_argument("--readout_reduce", type=str, default="mean", choices=["mean", "sum"], help="Readout reduction for SameTimeGroupEndReadout") # ---- Loss ---- parser.add_argument("--loss_name", type=str, default=None, help="Loss name (auto-selected if None): delphi2m, uts") parser.add_argument("--t_min", type=float, default=0.0027378507871321013, help="Minimum time for loss (1/365.25)") parser.add_argument("--max_exp_input", type=float, default=60.0, help="Max exponent input for loss") parser.add_argument("--ce_weight", type=float, default=1.0, help="Cross-entropy weight in delphi2m loss") parser.add_argument("--time_weight", type=float, default=1.0, help="Time loss weight in delphi2m loss") parser.add_argument("--ignore_no_event_in_delphi2m", action="store_true", help="Ignore NO_EVENT in delphi2m loss") # ---- Optimization ---- parser.add_argument("--batch_size", type=int, default=128, help="Batch size") parser.add_argument("--base_lr", type=float, default=3e-4, help="Base learning rate") parser.add_argument("--weight_decay", type=float, default=0.1, help="Weight decay (L2 regularization)") parser.add_argument("--betas", type=float, nargs=2, default=(0.9, 0.99), help="AdamW betas") parser.add_argument("--grad_clip", type=float, default=1.0, help="Gradient clipping norm") parser.add_argument("--max_epochs", type=int, default=200, help="Maximum number of epochs") parser.add_argument("--warmup_epochs", type=int, default=10, help="Number of warmup epochs") parser.add_argument("--patience", type=int, default=15, help="Early stopping patience") parser.add_argument("--min_lr_ratio", type=float, default=0.1, help="Minimum LR as ratio of adaptive_lr") parser.add_argument("--num_workers", type=int, default=4, help="Number of DataLoader workers") parser.add_argument("--device", type=str, default="cuda", help="Device to use for training: cpu, cuda, or cuda:") args = parser.parse_args() args.extra_info_types = ( load_extra_info_types_file(args.extra_info_types_file) if args.extra_info_types_file is not None else None ) # ---- Setup ---- set_seed(args.seed) device = resolve_device(args.device) configure_torch_for_training(device) normalize_training_config(args) runs_root = Path("runs") while True: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") run_name = ( f"{args.time_mode}_exponential_{args.target_mode}_" f"other_tokens_gap_5y_{timestamp}" ) run_dir = runs_root / run_name if not run_dir.exists(): break time.sleep(1) run_dir.mkdir(parents=True, exist_ok=False) logger = setup_logging(run_dir) logger.info(f"Starting training run: {run_name}") logger.info(f"Device: {device}") logger.info(f"extra_info_types: {args.extra_info_types or 'all'}") logger.info( f"Resolved no_event config: ignore_no_event_in_delphi2m={args.ignore_no_event_in_delphi2m}, " f"include_no_event_in_uts_target={args.include_no_event_in_uts_target}" ) # ---- Validate Arguments ---- total_ratio = args.train_ratio + args.val_ratio + args.test_ratio if not np.isclose(total_ratio, 1.0, atol=1e-6): raise ValueError( f"train_ratio + val_ratio + test_ratio must equal 1.0, got {total_ratio}" ) # Auto-select readout if not specified if args.readout_name is None: args.readout_name = ( "token" if args.target_mode == "delphi2m" else "same_time_group_end" ) # Auto-select loss if not specified if args.loss_name is None: args.loss_name = ( "delphi2m" if args.target_mode == "delphi2m" else "uts" ) normalize_loss_and_distribution_config(args) logger.info(f"Auto-selected readout: {args.readout_name}") logger.info(f"Auto-selected loss: {args.loss_name}") # ---- Load Dataset ---- logger.info("Loading dataset...") dataset = HealthDataset( data_prefix=args.data_prefix, labels_file=args.labels_file, no_event_interval_years=args.no_event_interval_years, include_no_event_in_uts_target=args.include_no_event_in_uts_target, extra_info_types=args.extra_info_types, ) logger.info( f"Dataset loaded: {len(dataset)} samples, vocab_size={dataset.vocab_size}") # ---- Split Dataset ---- train_subset, val_subset, test_subset = split_dataset( dataset=dataset, train_ratio=args.train_ratio, val_ratio=args.val_ratio, test_ratio=args.test_ratio, seed=args.seed, ) logger.info( f"Dataset split: train={len(train_subset)}, val={len(val_subset)}, test={len(test_subset)}" ) # ---- Build DataLoaders ---- train_loader = DataLoader( train_subset, batch_size=args.batch_size, sampler=RandomSampler( train_subset, generator=torch.Generator().manual_seed(args.seed)), collate_fn=collate_fn, num_workers=args.num_workers, pin_memory=device.type == "cuda", persistent_workers=args.num_workers > 0, prefetch_factor=2 if args.num_workers > 0 else None, ) val_loader = DataLoader( val_subset, batch_size=args.batch_size, shuffle=False, collate_fn=collate_fn, num_workers=args.num_workers, pin_memory=device.type == "cuda", persistent_workers=args.num_workers > 0, prefetch_factor=2 if args.num_workers > 0 else None, ) test_loader = DataLoader( test_subset, batch_size=args.batch_size, shuffle=False, collate_fn=collate_fn, num_workers=args.num_workers, pin_memory=device.type == "cuda", persistent_workers=args.num_workers > 0, prefetch_factor=2 if args.num_workers > 0 else None, ) # ---- Build Model ---- logger.info("Building model...") model = build_model(args, dataset) model.to(device) n_params = sum(p.numel() for p in model.parameters()) logger.info(f"Model built: {n_params:,} parameters") # ---- Build Optimizer ---- optimizer = build_optimizer(args, model) logger.info( f"Optimizer: AdamW, base_lr={args.base_lr}, weight_decay={args.weight_decay}") # ---- Compute Adaptive LR ---- adaptive_lr = args.base_lr * math.sqrt(args.batch_size / 128) logger.info( f"Adaptive LR: {adaptive_lr:.6f} (base_lr * sqrt(batch_size/128))") # ---- Build Readout ---- if args.readout_name == "token": readout = build_readout("token") elif args.readout_name == "same_time_group_end": readout = build_readout("same_time_group_end", reduce=args.readout_reduce) elif args.readout_name == "last_valid": readout = build_readout("last_valid") else: raise ValueError(f"Unknown readout: {args.readout_name}") logger.info(f"Readout: {args.readout_name}") # ---- Build Loss ---- if args.loss_name == "delphi2m": ignored_tokens = {PAD_IDX, CHECKUP_IDX} if args.ignore_no_event_in_delphi2m: ignored_tokens.add(NO_EVENT_IDX) criterion = build_loss( "delphi2m", ignored_tokens=ignored_tokens, t_min=args.t_min, max_exp_input=args.max_exp_input, ce_weight=args.ce_weight, time_weight=args.time_weight, ) logger.info(f"Loss: delphi2m, ignored_tokens={ignored_tokens}") elif args.loss_name == "uts": ignored_idx = {PAD_IDX, CHECKUP_IDX} criterion = build_loss( "uts", ignored_idx=ignored_idx, t_min=args.t_min, max_exp_input=args.max_exp_input, ) logger.info(f"Loss: uts, ignored_idx={ignored_idx}") else: raise ValueError(f"Unknown loss: {args.loss_name}") # ---- Save Config ---- save_config( args, run_dir / "train_config.json", extra=build_run_metadata( args=args, dataset=dataset, train_subset=train_subset, val_subset=val_subset, test_subset=test_subset, run_name=run_name, ), ) logger.info(f"Config saved to {run_dir / 'train_config.json'}") # ---- Training Loop ---- logger.info("Starting training...") best_val_loss = float("inf") patience_counter = 0 metrics = [] best_model_path = run_dir / "best_model.pt" history_path = run_dir / "history.json" start_time = time.time() for epoch in range(args.max_epochs): lr = get_lr(epoch, args, adaptive_lr, args.max_epochs) set_optimizer_lr(optimizer, lr) # Train train_loss = run_one_epoch( logger=logger, args=args, model=model, readout=readout, criterion=criterion, train_loader=train_loader, optimizer=optimizer, device=device, is_train=True, ) # Validate val_loss = evaluate( logger=logger, args=args, model=model, readout=readout, criterion=criterion, val_loader=val_loader, device=device, ) # Early stopping is_best = False if val_loss < best_val_loss: best_val_loss = val_loss patience_counter = 0 is_best = True save_checkpoint(model, best_model_path) else: patience_counter += 1 elapsed = time.time() - start_time logger.info( f"Epoch {epoch+1}/{args.max_epochs} | lr={lr:.6f} | " f"train_loss={train_loss:.6f} | val_loss={val_loss:.6f} | " f"best_val_loss={best_val_loss:.6f} | patience={patience_counter}/{args.patience} | " f"elapsed={elapsed:.1f}s" ) metrics.append({ "epoch": epoch + 1, "lr": lr, "train_loss": train_loss, "val_loss": val_loss, "best_val_loss": best_val_loss, "is_best": int(is_best), }) if patience_counter >= args.patience: logger.info(f"Early stopping triggered at epoch {epoch+1}") break # ---- Save Training History ---- with open(history_path, "w") as f: json.dump(metrics, f, indent=2) logger.info(f"History saved to {history_path}") # ---- Test on Best Model ---- logger.info("Evaluating best model on test set...") best_state_dict = torch.load(best_model_path, map_location=device) model.load_state_dict(best_state_dict) test_loss = evaluate( logger=logger, args=args, model=model, readout=readout, criterion=criterion, val_loader=test_loader, device=device, ) logger.info(f"Test loss: {test_loss:.6f}") total_time = time.time() - start_time logger.info(f"Training completed in {total_time:.1f}s") logger.info(f"Best checkpoint: {best_model_path}") if __name__ == "__main__": main()