| |
| """Train the paper-scale or explicitly reduced DeepONet experiments.""" |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import copy |
| import csv |
| import os |
| import random |
| import sys |
| import tempfile |
| from pathlib import Path |
| from typing import Any, Dict, Iterable, Mapping, Tuple |
|
|
| import numpy as np |
| import torch |
| import yaml |
| from torch import Tensor, nn |
| from torch.utils.data import DataLoader |
|
|
| PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| if str(PROJECT_ROOT) not in sys.path: |
| sys.path.insert(0, str(PROJECT_ROOT)) |
|
|
| from models.DeepONet import build_model, count_parameters |
| from models.dataset import OperatorDataset, build_datasets, resolve_config |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("--config", type=Path, default=PROJECT_ROOT / "config/config.yaml") |
| parser.add_argument( |
| "--experiment", |
| default="antiderivative", |
| help="One experiment name or 'all' for all four paper experiments.", |
| ) |
| parser.add_argument( |
| "--variant", |
| default=None, |
| help="Variant name, 'all', or omit to use each experiment's paper default.", |
| ) |
| parser.add_argument("--smoke-test", action="store_true", help="Run reduced non-paper settings.") |
| parser.add_argument("--device", default=None, help="cpu, cuda, cuda:N, or auto") |
| parser.add_argument("--seed", type=int, default=None) |
| parser.add_argument("--resume", action="store_true") |
| parser.add_argument("--no-cache", action="store_true") |
| return parser.parse_args() |
|
|
|
|
| def load_config(path: Path, smoke_test: bool) -> Dict[str, Any]: |
| with path.expanduser().resolve().open("r", encoding="utf-8") as handle: |
| raw = yaml.safe_load(handle) |
| if not isinstance(raw, Mapping): |
| raise ValueError(f"Configuration {path} must contain a mapping") |
| return resolve_config(raw, smoke_test=smoke_test) |
|
|
|
|
| def resolve_path(path_value: str, root: Path = PROJECT_ROOT) -> Path: |
| path = Path(path_value).expanduser() |
| return path if path.is_absolute() else root / path |
|
|
|
|
| def select_device(requested: str) -> torch.device: |
| if requested == "auto": |
| return torch.device("cuda" if torch.cuda.is_available() else "cpu") |
| device = torch.device(requested) |
| if device.type == "cuda" and not torch.cuda.is_available(): |
| raise RuntimeError(f"CUDA device {requested!r} requested but CUDA is unavailable") |
| return device |
|
|
|
|
| def set_seed(seed: int) -> None: |
| random.seed(seed) |
| np.random.seed(seed) |
| torch.manual_seed(seed) |
| if torch.cuda.is_available(): |
| torch.cuda.manual_seed_all(seed) |
|
|
|
|
| def make_loader( |
| dataset: OperatorDataset, |
| batch_size: int | None, |
| *, |
| shuffle: bool, |
| num_workers: int, |
| pin_memory: bool, |
| ) -> DataLoader: |
| effective_batch = len(dataset) if batch_size is None else int(batch_size) |
| if effective_batch < 1: |
| raise ValueError("batch size must be positive") |
| return DataLoader( |
| dataset, |
| batch_size=effective_batch, |
| shuffle=shuffle, |
| num_workers=num_workers, |
| pin_memory=pin_memory, |
| drop_last=False, |
| ) |
|
|
|
|
| def metric_values(prediction: np.ndarray, target: np.ndarray, trim_fraction: float = 0.0) -> Dict[str, float]: |
| squared_error = np.square(prediction.astype(np.float64) - target.astype(np.float64)).reshape(-1) |
| mse = float(np.mean(squared_error)) |
| denominator = float(np.linalg.norm(target.astype(np.float64).reshape(-1))) |
| relative_l2 = float( |
| np.linalg.norm(prediction.astype(np.float64).reshape(-1) - target.astype(np.float64).reshape(-1)) |
| / max(denominator, np.finfo(np.float64).eps) |
| ) |
| metrics = {"test_mse": mse, "relative_l2": relative_l2} |
| if trim_fraction > 0.0: |
| remove_count = min(len(squared_error) - 1, int(np.ceil(len(squared_error) * trim_fraction))) |
| kept = np.partition(squared_error, len(squared_error) - remove_count - 1)[ |
| : len(squared_error) - remove_count |
| ] |
| metrics["trimmed_test_mse"] = float(np.mean(kept)) |
| return metrics |
|
|
|
|
| @torch.inference_mode() |
| def evaluate( |
| model: nn.Module, |
| dataset: OperatorDataset, |
| device: torch.device, |
| batch_size: int, |
| trim_fraction: float, |
| num_workers: int, |
| ) -> Dict[str, float]: |
| loader = make_loader( |
| dataset, |
| batch_size, |
| shuffle=False, |
| num_workers=num_workers, |
| pin_memory=device.type == "cuda", |
| ) |
| predictions, targets = [], [] |
| model.eval() |
| for branch, trunk, target in loader: |
| output = model(branch.to(device), trunk.to(device)) |
| predictions.append(output.detach().cpu().numpy()) |
| targets.append(target.numpy()) |
| return metric_values(np.concatenate(predictions), np.concatenate(targets), trim_fraction) |
|
|
|
|
| def _cpu_copy(value: Any) -> Any: |
| if isinstance(value, Tensor): |
| return value.detach().cpu() |
| if isinstance(value, dict): |
| return {key: _cpu_copy(item) for key, item in value.items()} |
| if isinstance(value, list): |
| return [_cpu_copy(item) for item in value] |
| if isinstance(value, tuple): |
| return tuple(_cpu_copy(item) for item in value) |
| return value |
|
|
|
|
| def torch_load(path: Path, map_location: str | torch.device = "cpu") -> Any: |
| try: |
| return torch.load(path, map_location=map_location, weights_only=False) |
| except TypeError: |
| return torch.load(path, map_location=map_location) |
|
|
|
|
| def load_bundle(path: Path) -> Dict[str, Any]: |
| if not path.exists(): |
| return {"format_version": 1, "entries": {}} |
| bundle = torch_load(path) |
| if not isinstance(bundle, dict) or not isinstance(bundle.get("entries"), dict): |
| raise ValueError(f"Checkpoint {path} is not a DeepONet indexed bundle") |
| return bundle |
|
|
|
|
| def save_bundle_entry(path: Path, key: str, entry: Mapping[str, Any]) -> None: |
| path.parent.mkdir(parents=True, exist_ok=True) |
| bundle = load_bundle(path) |
| bundle["format_version"] = 1 |
| bundle["entries"][key] = _cpu_copy(dict(entry)) |
| with tempfile.NamedTemporaryFile(dir=path.parent, suffix=".pth", delete=False) as handle: |
| temporary_path = Path(handle.name) |
| try: |
| torch.save(bundle, temporary_path) |
| os.replace(temporary_path, path) |
| finally: |
| if temporary_path.exists(): |
| temporary_path.unlink() |
|
|
|
|
| def append_history(path: Path, row: Mapping[str, Any]) -> None: |
| path.parent.mkdir(parents=True, exist_ok=True) |
| exists = path.exists() |
| with path.open("a", newline="", encoding="utf-8") as handle: |
| writer = csv.DictWriter(handle, fieldnames=list(row.keys())) |
| if not exists: |
| writer.writeheader() |
| writer.writerow(row) |
| handle.flush() |
|
|
|
|
| def variants_for(config: Mapping[str, Any], experiment: str, requested: str | None) -> Iterable[str]: |
| if requested is None: |
| return [str(config["experiments"][experiment]["default_variant"])] |
| if requested == "all": |
| return list(config["variants"]) |
| if requested not in config["variants"]: |
| raise KeyError(f"Unknown variant {requested!r}; choose from {list(config['variants'])}") |
| return [requested] |
|
|
|
|
| def run_training( |
| config: Dict[str, Any], |
| experiment: str, |
| variant: str, |
| *, |
| no_cache: bool, |
| resume: bool, |
| ) -> None: |
| experiment_config = config["experiments"][experiment] |
| training_config = config["training_defaults"] |
| device = select_device(str(config["runtime"]["device"])) |
| seed = int(config["runtime"]["seed"]) |
| set_seed(seed) |
|
|
| if no_cache: |
| from models.dataset import build_split |
|
|
| train_dataset = build_split(config, experiment, "train", PROJECT_ROOT, use_cache=False) |
| test_dataset = build_split(config, experiment, "test", PROJECT_ROOT, use_cache=False) |
| else: |
| train_dataset, test_dataset = build_datasets(config, experiment, PROJECT_ROOT) |
|
|
| model = build_model(config, experiment, variant).to(device) |
| optimizer = torch.optim.Adam( |
| model.parameters(), |
| lr=float(training_config["learning_rate"]), |
| weight_decay=float(training_config.get("weight_decay", 0.0)), |
| ) |
| criterion = nn.MSELoss(reduction="mean") |
| checkpoint_path = resolve_path(str(config["paths"]["checkpoint"])) |
| entry_key = f"{experiment}/{variant}" |
| start_iteration = 0 |
| best_metric = float("inf") |
| if resume and checkpoint_path.exists(): |
| entry = load_bundle(checkpoint_path)["entries"].get(entry_key) |
| if entry is None: |
| raise KeyError(f"Cannot resume: {entry_key!r} is absent from {checkpoint_path}") |
| model.load_state_dict(entry["model_state"], strict=True) |
| optimizer.load_state_dict(entry["optimizer_state"]) |
| start_iteration = int(entry["iteration"]) |
| best_metric = float(entry["best_metric"]) |
|
|
| batch_size = training_config.get("batch_size") |
| train_loader = make_loader( |
| train_dataset, |
| None if batch_size is None else int(batch_size), |
| shuffle=True, |
| num_workers=int(config["runtime"].get("num_workers", 0)), |
| pin_memory=device.type == "cuda", |
| ) |
| train_iterator = iter(train_loader) |
| iterations = int(experiment_config["iterations"]) |
| print_every = int(training_config["print_every"]) |
| evaluate_every = int(training_config["evaluate_every"]) |
| eval_batch_size = int(training_config["evaluation_batch_size"]) |
| trim_fraction = float(experiment_config.get("trim_fraction", 0.0)) |
| history_path = resolve_path(str(config["paths"]["results"])) / experiment / variant / "history.csv" |
| paper_scale = bool(config["project"]["paper_scale"]) |
| if not resume and history_path.exists(): |
| history_path.unlink() |
|
|
| print( |
| f"START experiment={experiment} variant={variant} device={device} " |
| f"parameters={count_parameters(model)} train_points={len(train_dataset)} " |
| f"test_points={len(test_dataset)} iterations={iterations} paper_scale={paper_scale}", |
| flush=True, |
| ) |
| for iteration in range(start_iteration + 1, iterations + 1): |
| try: |
| branch, trunk, target = next(train_iterator) |
| except StopIteration: |
| train_iterator = iter(train_loader) |
| branch, trunk, target = next(train_iterator) |
| model.train() |
| optimizer.zero_grad(set_to_none=True) |
| prediction = model(branch.to(device), trunk.to(device)) |
| loss = criterion(prediction, target.to(device)) |
| if not torch.isfinite(loss): |
| raise FloatingPointError(f"non-finite training loss at iteration {iteration}") |
| loss.backward() |
| optimizer.step() |
| train_loss = float(loss.detach().cpu()) |
|
|
| should_evaluate = iteration == 1 or iteration % evaluate_every == 0 or iteration == iterations |
| should_print = iteration == 1 or iteration % print_every == 0 or should_evaluate |
| metrics: Dict[str, float] = {} |
| if should_evaluate: |
| metrics = evaluate( |
| model, |
| test_dataset, |
| device, |
| eval_batch_size, |
| trim_fraction, |
| int(config["runtime"].get("num_workers", 0)), |
| ) |
| row: Dict[str, Any] = { |
| "iteration": iteration, |
| "train_loss": train_loss, |
| "test_mse": metrics["test_mse"], |
| "relative_l2": metrics["relative_l2"], |
| "generalization_error": metrics["test_mse"] - train_loss, |
| "paper_scale": paper_scale, |
| } |
| if "trimmed_test_mse" in metrics: |
| row["trimmed_test_mse"] = metrics["trimmed_test_mse"] |
| append_history(history_path, row) |
| if metrics["test_mse"] < best_metric: |
| best_metric = metrics["test_mse"] |
| save_bundle_entry( |
| checkpoint_path, |
| entry_key, |
| { |
| "experiment": experiment, |
| "variant": variant, |
| "iteration": iteration, |
| "best_metric": best_metric, |
| "metric_name": "test_mse", |
| "model_state": model.state_dict(), |
| "optimizer_state": optimizer.state_dict(), |
| "run_config": copy.deepcopy(config), |
| "paper_scale": paper_scale, |
| }, |
| ) |
| if should_print: |
| metric_text = " ".join(f"{name}={value:.8e}" for name, value in metrics.items()) |
| print( |
| f"TRAIN experiment={experiment} variant={variant} iteration={iteration}/{iterations} " |
| f"train_loss={train_loss:.8e} {metric_text}".rstrip(), |
| flush=True, |
| ) |
| print( |
| f"DONE experiment={experiment} variant={variant} best_test_mse={best_metric:.8e} " |
| f"checkpoint={checkpoint_path} history={history_path}", |
| flush=True, |
| ) |
|
|
|
|
| def main() -> None: |
| args = parse_args() |
| config = load_config(args.config, args.smoke_test) |
| if args.device is not None: |
| config["runtime"]["device"] = args.device |
| if args.seed is not None: |
| config["runtime"]["seed"] = args.seed |
| experiments = list(config["experiments"]) if args.experiment == "all" else [args.experiment] |
| unknown = [name for name in experiments if name not in config["experiments"]] |
| if unknown: |
| raise KeyError(f"Unknown experiments: {unknown}; choose from {list(config['experiments'])}") |
| for experiment in experiments: |
| for variant in variants_for(config, experiment, args.variant): |
| run_training( |
| config, |
| experiment, |
| variant, |
| no_cache=args.no_cache, |
| resume=args.resume or bool(config["training_defaults"].get("resume", False)), |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|