Download scripts/train.py from OneScience-Group/FNO: direct link, hf CLI and curl.
- Browser
- Download file 30.8 kB
-
https://huggingface.co/OneScience-Group/FNO/resolve/main/scripts/train.py
- Command line
-
hf download hf://OneScience-Group/FNO/scripts/train.py
-
curl -L -o train.py https://huggingface.co/OneScience-Group/FNO/resolve/main/scripts/train.py
30.8 kB
| #!/usr/bin/env python3 | |
| """Train the paper-specified recurrent FNO-2D Navier--Stokes model.""" | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import platform | |
| import random | |
| import sys | |
| import time | |
| from copy import deepcopy | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Any | |
| import numpy as np | |
| import scipy | |
| import scipy.io | |
| import torch | |
| import yaml | |
| from torch import Tensor, nn | |
| from torch.utils.data import DataLoader, TensorDataset | |
| PROJECT_ROOT = Path(__file__).resolve().parents[1] | |
| if str(PROJECT_ROOT) not in sys.path: | |
| sys.path.insert(0, str(PROJECT_ROOT)) | |
| from models import build_model_from_config # noqa: E402 | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser( | |
| description="Train FNO-2D on the validated Navier-Stokes trajectory MAT file." | |
| ) | |
| parser.add_argument( | |
| "--config", | |
| type=Path, | |
| default=PROJECT_ROOT / "config" / "config.yaml", | |
| help="YAML configuration path.", | |
| ) | |
| parser.add_argument("--epochs", type=int, default=None, help="Override epochs (smoke only).") | |
| parser.add_argument( | |
| "--rollout-steps", type=int, default=None, help="Override rollout steps (smoke only)." | |
| ) | |
| parser.add_argument( | |
| "--max-train-samples", type=int, default=None, help="Limit train samples (smoke only)." | |
| ) | |
| parser.add_argument( | |
| "--max-test-samples", type=int, default=None, help="Limit test samples (smoke only)." | |
| ) | |
| parser.add_argument("--batch-size", type=int, default=None, help="Override batch size.") | |
| parser.add_argument( | |
| "--device", default="auto", help="auto, cpu, cuda, or an explicit torch device." | |
| ) | |
| parser.add_argument("--checkpoint", type=Path, default=None, help="Output checkpoint path.") | |
| parser.add_argument( | |
| "--resume", | |
| type=Path, | |
| default=None, | |
| help="Resume from a formal training-state checkpoint.", | |
| ) | |
| parser.add_argument("--output-dir", type=Path, default=None, help="Output directory.") | |
| parser.add_argument( | |
| "--run-type", choices=("formal", "smoke"), default="formal", help="Run provenance tag." | |
| ) | |
| return parser.parse_args() | |
| def load_config(path: Path) -> dict[str, Any]: | |
| config_path = path.expanduser().resolve() | |
| if not config_path.is_file(): | |
| raise FileNotFoundError(f"Configuration file does not exist: {config_path}") | |
| with config_path.open("r", encoding="utf-8") as handle: | |
| config = yaml.safe_load(handle) | |
| if not isinstance(config, dict): | |
| raise ValueError("Configuration root must be a mapping") | |
| validate_config(config) | |
| return config | |
| def validate_config(config: dict[str, Any]) -> None: | |
| for section in ("paper", "data", "model", "training", "paths"): | |
| if section not in config or not isinstance(config[section], dict): | |
| raise ValueError(f"Missing configuration section: {section}") | |
| data = config["data"] | |
| model = config["model"] | |
| training = config["training"] | |
| required_data = { | |
| "root", | |
| "file", | |
| "key", | |
| "layout", | |
| "dtype", | |
| "expected_shape", | |
| "resolution", | |
| "ntrain", | |
| "ntest", | |
| "test_start", | |
| "history", | |
| "horizon", | |
| "normalization", | |
| } | |
| required_model = { | |
| "input_channels", | |
| "output_channels", | |
| "width", | |
| "modes1", | |
| "modes2", | |
| "num_layers", | |
| "projection_width", | |
| "use_grid", | |
| } | |
| required_training = { | |
| "epochs", | |
| "batch_size", | |
| "optimizer", | |
| "learning_rate", | |
| "weight_decay", | |
| "scheduler", | |
| "scheduler_step_size", | |
| "scheduler_gamma", | |
| "seed", | |
| "dtype", | |
| "relative_l2_epsilon", | |
| "checkpoint_monitor", | |
| } | |
| for label, mapping, required in ( | |
| ("data", data, required_data), | |
| ("model", model, required_model), | |
| ("training", training, required_training), | |
| ): | |
| missing = sorted(required.difference(mapping)) | |
| if missing: | |
| raise ValueError(f"Missing {label} configuration keys: {missing}") | |
| if data["layout"] != "N,H,W,T": | |
| raise ValueError("This reproduction requires data.layout=N,H,W,T") | |
| if data["dtype"] != "float32" or training["dtype"] != "float32": | |
| raise ValueError("The audited reproduction requires float32 data and training") | |
| if data["normalization"] != "none": | |
| raise ValueError("The paper-faithful default requires normalization=none") | |
| if int(data["history"]) != 10 or int(model["input_channels"]) != 10: | |
| raise ValueError("The FNO-2D experiment requires ten history channels") | |
| if int(data["horizon"]) != 10 or int(model["output_channels"]) != 1: | |
| raise ValueError("The experiment requires a ten-step rollout of one-step outputs") | |
| if int(model["width"]) != 32 or int(model["num_layers"]) != 4: | |
| raise ValueError("Strict paper settings require width=32 and num_layers=4") | |
| if int(model["modes1"]) != 12 or int(model["modes2"]) != 12: | |
| raise ValueError("Strict paper settings require 12 retained modes per axis") | |
| if str(training["optimizer"]).lower() != "adam": | |
| raise ValueError("The paper specifies Adam") | |
| if str(training["scheduler"]).lower() != "step_lr": | |
| raise ValueError("The paper schedule is represented by StepLR") | |
| if str(training["checkpoint_monitor"]) != "train_full_relative_l2": | |
| raise ValueError("Test metrics must not select the checkpoint") | |
| def resolve_project_path(value: str | Path) -> Path: | |
| path = Path(value).expanduser() | |
| return path.resolve() if path.is_absolute() else (PROJECT_ROOT / path).resolve() | |
| def data_file_from_config(config: dict[str, Any]) -> Path: | |
| data = config["data"] | |
| path = Path(str(data["root"])).expanduser() / str(data["file"]) | |
| path = path.resolve() | |
| if not path.is_file(): | |
| raise FileNotFoundError(f"Navier-Stokes MAT file does not exist: {path}") | |
| return path | |
| def load_trajectory_array(config: dict[str, Any]) -> np.ndarray: | |
| data = config["data"] | |
| path = data_file_from_config(config) | |
| key = str(data["key"]) | |
| payload = scipy.io.loadmat(path, variable_names=[key]) | |
| if key not in payload: | |
| raise KeyError(f"MAT field {key!r} is missing from {path}") | |
| trajectories = payload[key] | |
| expected_shape = tuple(int(value) for value in data["expected_shape"]) | |
| if trajectories.shape != expected_shape: | |
| raise ValueError( | |
| f"Expected {key} shape {expected_shape}, received {trajectories.shape}" | |
| ) | |
| if trajectories.dtype != np.float32: | |
| raise TypeError(f"Expected {key} dtype float32, received {trajectories.dtype}") | |
| if not np.isfinite(trajectories).all(): | |
| raise ValueError("Trajectory array contains NaN or Inf") | |
| return trajectories | |
| def build_datasets( | |
| config: dict[str, Any], | |
| max_train_samples: int | None = None, | |
| max_test_samples: int | None = None, | |
| rollout_steps: int | None = None, | |
| ) -> tuple[TensorDataset, TensorDataset]: | |
| data = config["data"] | |
| trajectories = load_trajectory_array(config) | |
| history = int(data["history"]) | |
| horizon = int(data["horizon"]) | |
| steps = horizon if rollout_steps is None else int(rollout_steps) | |
| if not 1 <= steps <= horizon: | |
| raise ValueError(f"rollout_steps must be in [1,{horizon}], got {steps}") | |
| ntrain = int(data["ntrain"]) | |
| ntest = int(data["ntest"]) | |
| train_start = int(data.get("train_start", 0)) | |
| test_start = int(data["test_start"]) | |
| train_count = ntrain if max_train_samples is None else min(ntrain, max_train_samples) | |
| test_count = ntest if max_test_samples is None else min(ntest, max_test_samples) | |
| if train_count <= 0 or test_count <= 0: | |
| raise ValueError("Training and test sample counts must be positive") | |
| train = trajectories[train_start : train_start + train_count] | |
| test = trajectories[test_start : test_start + test_count] | |
| train_history = torch.from_numpy(train[..., :history]) | |
| train_target = torch.from_numpy(train[..., history : history + steps]) | |
| test_history = torch.from_numpy(test[..., :history]) | |
| test_target = torch.from_numpy(test[..., history : history + steps]) | |
| expected_hw = tuple(int(value) for value in data["resolution"]) | |
| expected_history_shape = (expected_hw[0], expected_hw[1], history) | |
| if tuple(train_history.shape[1:]) != expected_history_shape: | |
| raise ValueError(f"Invalid train history shape: {train_history.shape}") | |
| if tuple(test_history.shape[1:]) != expected_history_shape: | |
| raise ValueError(f"Invalid test history shape: {test_history.shape}") | |
| return TensorDataset(train_history, train_target), TensorDataset(test_history, test_target) | |
| def seed_everything(seed: int, deterministic: bool) -> None: | |
| random.seed(seed) | |
| np.random.seed(seed) | |
| torch.manual_seed(seed) | |
| if torch.cuda.is_available(): | |
| torch.cuda.manual_seed_all(seed) | |
| if hasattr(torch.backends, "cudnn"): | |
| torch.backends.cudnn.deterministic = deterministic | |
| torch.backends.cudnn.benchmark = not deterministic | |
| if deterministic: | |
| torch.use_deterministic_algorithms(True, warn_only=True) | |
| 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"Requested {requested}, but torch reports no CUDA/DCU device") | |
| return device | |
| def relative_l2_per_sample(prediction: Tensor, target: Tensor, epsilon: float) -> Tensor: | |
| if prediction.shape != target.shape: | |
| raise ValueError(f"Relative L2 shape mismatch: {prediction.shape} vs {target.shape}") | |
| if prediction.ndim < 2: | |
| raise ValueError("Relative L2 inputs must include a batch and at least one feature axis") | |
| difference = torch.linalg.vector_norm( | |
| (prediction - target).reshape(prediction.shape[0], -1), dim=1 | |
| ) | |
| denominator = torch.linalg.vector_norm(target.reshape(target.shape[0], -1), dim=1) | |
| return difference / (denominator + epsilon) | |
| def autoregressive_rollout( | |
| model: nn.Module, | |
| history: Tensor, | |
| target: Tensor, | |
| epsilon: float, | |
| ) -> tuple[Tensor, Tensor, Tensor]: | |
| if history.ndim != 4 or target.ndim != 4: | |
| raise ValueError("history and target must be channel-last four-dimensional tensors") | |
| window = history | |
| predictions: list[Tensor] = [] | |
| step_ratios: list[Tensor] = [] | |
| for step in range(target.shape[-1]): | |
| prediction = model(window) | |
| expected_step_shape = (*history.shape[:-1], 1) | |
| if tuple(prediction.shape) != expected_step_shape: | |
| raise ValueError( | |
| f"Model returned {tuple(prediction.shape)}, expected {expected_step_shape}" | |
| ) | |
| target_step = target[..., step : step + 1] | |
| predictions.append(prediction) | |
| step_ratios.append(relative_l2_per_sample(prediction, target_step, epsilon)) | |
| window = torch.cat((window[..., 1:], prediction), dim=-1) | |
| rollout = torch.cat(predictions, dim=-1) | |
| ratios = torch.stack(step_ratios, dim=1) | |
| backward_loss = ratios.mean(dim=0).sum() | |
| return rollout, ratios, backward_loss | |
| def build_loader( | |
| dataset: TensorDataset, | |
| batch_size: int, | |
| shuffle: bool, | |
| num_workers: int, | |
| pin_memory: bool, | |
| seed: int, | |
| ) -> DataLoader: | |
| generator = torch.Generator() | |
| generator.manual_seed(seed) | |
| return DataLoader( | |
| dataset, | |
| batch_size=batch_size, | |
| shuffle=shuffle, | |
| num_workers=num_workers, | |
| pin_memory=pin_memory, | |
| drop_last=False, | |
| generator=generator, | |
| ) | |
| def run_epoch( | |
| model: nn.Module, | |
| loader: DataLoader, | |
| device: torch.device, | |
| epsilon: float, | |
| optimizer: torch.optim.Optimizer | None, | |
| ) -> dict[str, float]: | |
| training = optimizer is not None | |
| model.train(training) | |
| total_samples = 0 | |
| total_step_ratio = 0.0 | |
| total_full_ratio = 0.0 | |
| steps_per_sample: int | None = None | |
| context = torch.enable_grad() if training else torch.inference_mode() | |
| with context: | |
| for history, target in loader: | |
| history = history.to(device=device, dtype=torch.float32, non_blocking=True) | |
| target = target.to(device=device, dtype=torch.float32, non_blocking=True) | |
| if training: | |
| optimizer.zero_grad(set_to_none=True) | |
| prediction, step_ratios, backward_loss = autoregressive_rollout( | |
| model, history, target, epsilon | |
| ) | |
| full_ratios = relative_l2_per_sample(prediction, target, epsilon) | |
| if not torch.isfinite(backward_loss) or not torch.isfinite(full_ratios).all(): | |
| raise FloatingPointError("Non-finite training/evaluation loss encountered") | |
| if training: | |
| backward_loss.backward() | |
| for name, parameter in model.named_parameters(): | |
| if parameter.grad is not None and not torch.isfinite(parameter.grad).all(): | |
| raise FloatingPointError(f"Non-finite gradient in parameter {name}") | |
| optimizer.step() | |
| batch_samples = int(history.shape[0]) | |
| total_samples += batch_samples | |
| total_step_ratio += float(step_ratios.detach().sum().cpu()) | |
| total_full_ratio += float(full_ratios.detach().sum().cpu()) | |
| steps_per_sample = int(step_ratios.shape[1]) | |
| if total_samples == 0 or steps_per_sample is None: | |
| raise RuntimeError("DataLoader produced no batches") | |
| return { | |
| "step_loss_sum": total_step_ratio / total_samples, | |
| "mean_step_relative_l2": total_step_ratio / (total_samples * steps_per_sample), | |
| "full_relative_l2": total_full_ratio / total_samples, | |
| "samples": float(total_samples), | |
| "rollout_steps": float(steps_per_sample), | |
| } | |
| def synchronize(device: torch.device) -> None: | |
| if device.type == "cuda" and torch.cuda.is_available(): | |
| torch.cuda.synchronize(device) | |
| def environment_metadata(device: torch.device) -> dict[str, Any]: | |
| device_name = "cpu" | |
| if device.type == "cuda" and torch.cuda.is_available(): | |
| device_name = torch.cuda.get_device_name(device) | |
| return { | |
| "python": platform.python_version(), | |
| "pytorch": torch.__version__, | |
| "numpy": np.__version__, | |
| "scipy": scipy.__version__, | |
| "pyyaml": yaml.__version__, | |
| "device": str(device), | |
| "device_name": device_name, | |
| "torch_cuda_version": torch.version.cuda, | |
| "hostname": platform.node(), | |
| } | |
| def atomic_write_json(path: Path, payload: dict[str, Any]) -> None: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| temporary = path.with_suffix(path.suffix + ".tmp") | |
| with temporary.open("w", encoding="utf-8") as handle: | |
| json.dump(payload, handle, indent=2, ensure_ascii=False) | |
| handle.write("\n") | |
| os.replace(temporary, path) | |
| def atomic_torch_save(path: Path, payload: dict[str, Any]) -> None: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| temporary = path.with_suffix(path.suffix + ".tmp") | |
| torch.save(payload, temporary) | |
| os.replace(temporary, path) | |
| def load_training_checkpoint(path: Path, device: torch.device) -> dict[str, Any]: | |
| checkpoint_path = path.expanduser().resolve() | |
| if not checkpoint_path.is_file(): | |
| raise FileNotFoundError(f"Resume checkpoint does not exist: {checkpoint_path}") | |
| try: | |
| checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False) | |
| except TypeError: | |
| checkpoint = torch.load(checkpoint_path, map_location=device) | |
| if not isinstance(checkpoint, dict): | |
| raise TypeError("Resume checkpoint root must be a mapping") | |
| required = { | |
| "epoch", | |
| "model_state_dict", | |
| "optimizer_state_dict", | |
| "scheduler_state_dict", | |
| "best_train_full_relative_l2", | |
| "monitor", | |
| "test_selected", | |
| "config", | |
| "run_type", | |
| } | |
| missing = sorted(required.difference(checkpoint)) | |
| if missing: | |
| raise KeyError(f"Resume checkpoint is missing required keys: {missing}") | |
| if checkpoint["run_type"] != "formal": | |
| raise ValueError("Formal training can only resume a formal checkpoint") | |
| if checkpoint["monitor"] != "train_full_relative_l2": | |
| raise ValueError(f"Unexpected checkpoint monitor: {checkpoint['monitor']}") | |
| if checkpoint["test_selected"] is not False: | |
| raise ValueError("This reproduction forbids resuming a test-selected checkpoint") | |
| return checkpoint | |
| def capture_rng_state(train_loader: DataLoader) -> dict[str, Any]: | |
| state: dict[str, Any] = { | |
| "python": random.getstate(), | |
| "numpy": np.random.get_state(), | |
| "torch": torch.get_rng_state(), | |
| "train_loader_generator": train_loader.generator.get_state(), | |
| } | |
| if torch.cuda.is_available(): | |
| state["cuda"] = torch.cuda.get_rng_state_all() | |
| return state | |
| def restore_rng_state(state: dict[str, Any], train_loader: DataLoader) -> None: | |
| random.setstate(state["python"]) | |
| np.random.set_state(state["numpy"]) | |
| torch.set_rng_state(state["torch"].cpu()) | |
| train_loader.generator.set_state(state["train_loader_generator"].cpu()) | |
| if torch.cuda.is_available() and "cuda" in state: | |
| torch.cuda.set_rng_state_all([value.cpu() for value in state["cuda"]]) | |
| def main() -> None: | |
| args = parse_args() | |
| config = load_config(args.config) | |
| training = config["training"] | |
| seed = int(training["seed"]) | |
| seed_everything(seed, bool(training.get("deterministic", True))) | |
| device = select_device(args.device) | |
| epochs = int(training["epochs"] if args.epochs is None else args.epochs) | |
| batch_size = int(training["batch_size"] if args.batch_size is None else args.batch_size) | |
| horizon = int(config["data"]["horizon"]) | |
| rollout_steps = horizon if args.rollout_steps is None else int(args.rollout_steps) | |
| if epochs <= 0 or batch_size <= 0: | |
| raise ValueError("epochs and batch_size must be positive") | |
| if args.run_type == "formal" and any( | |
| value is not None | |
| for value in ( | |
| args.epochs, | |
| args.rollout_steps, | |
| args.max_train_samples, | |
| args.max_test_samples, | |
| args.output_dir, | |
| args.checkpoint, | |
| ) | |
| ): | |
| raise ValueError( | |
| "Formal runs use the exact configured data, epochs, rollout, checkpoint, " | |
| "and results paths; overrides require --run-type smoke" | |
| ) | |
| output_dir = ( | |
| resolve_project_path(config["paths"]["results_dir"]) | |
| if args.output_dir is None | |
| else args.output_dir.expanduser().resolve() | |
| ) | |
| checkpoint_path = ( | |
| resolve_project_path(config["paths"]["checkpoint"]) | |
| if args.checkpoint is None | |
| else args.checkpoint.expanduser().resolve() | |
| ) | |
| latest_checkpoint_path = checkpoint_path.with_name("last_model.pth") | |
| history_path = ( | |
| resolve_project_path(config["paths"]["train_history"]) | |
| if args.output_dir is None | |
| else output_dir / "train_history.json" | |
| ) | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| checkpoint_path.parent.mkdir(parents=True, exist_ok=True) | |
| train_dataset, test_dataset = build_datasets( | |
| config, | |
| max_train_samples=args.max_train_samples, | |
| max_test_samples=args.max_test_samples, | |
| rollout_steps=rollout_steps, | |
| ) | |
| pin_memory = bool(training.get("pin_memory", True)) and device.type == "cuda" | |
| train_loader = build_loader( | |
| train_dataset, | |
| batch_size, | |
| True, | |
| int(training.get("num_workers", 0)), | |
| pin_memory, | |
| seed, | |
| ) | |
| test_loader = build_loader( | |
| test_dataset, | |
| batch_size, | |
| False, | |
| int(training.get("num_workers", 0)), | |
| pin_memory, | |
| seed, | |
| ) | |
| # Move devices without forcing a global dtype conversion: the pointwise | |
| # parameters are float32 while spectral weights must remain complex64. | |
| model = build_model_from_config(config).to(device=device) | |
| parameter_count = sum(parameter.numel() for parameter in model.parameters()) | |
| paper_parameter_count = int(config["paper"]["reference_parameter_count"]) | |
| optimizer = torch.optim.Adam( | |
| model.parameters(), | |
| lr=float(training["learning_rate"]), | |
| weight_decay=float(training["weight_decay"]), | |
| ) | |
| scheduler = torch.optim.lr_scheduler.StepLR( | |
| optimizer, | |
| step_size=int(training["scheduler_step_size"]), | |
| gamma=float(training["scheduler_gamma"]), | |
| ) | |
| epsilon = float(training["relative_l2_epsilon"]) | |
| run_started = datetime.now(timezone.utc).isoformat() | |
| history_payload: dict[str, Any] = { | |
| "schema_version": "fno-ns2d-train-history-v1", | |
| "run_type": args.run_type, | |
| "started_at": run_started, | |
| "completed_at": None, | |
| "config_path": str(args.config.expanduser().resolve()), | |
| "data_path": str(data_file_from_config(config)), | |
| "checkpoint_path": str(checkpoint_path), | |
| "config": deepcopy(config), | |
| "environment": environment_metadata(device), | |
| "parameter_count": parameter_count, | |
| "paper_parameter_count": paper_parameter_count, | |
| "parameter_count_difference": parameter_count - paper_parameter_count, | |
| "test_selected": False, | |
| "epochs_requested": epochs, | |
| "rollout_steps": rollout_steps, | |
| "train_samples": len(train_dataset), | |
| "test_samples": len(test_dataset), | |
| "history": [], | |
| } | |
| best_metric = float("inf") | |
| start_epoch = 1 | |
| resume_exact_rng = True | |
| if args.resume is not None: | |
| resume_path = args.resume.expanduser().resolve() | |
| resume_checkpoint = load_training_checkpoint(resume_path, device) | |
| if resume_checkpoint["config"] != config: | |
| raise ValueError("Resume checkpoint configuration differs from the current YAML") | |
| resume_epoch = int(resume_checkpoint["epoch"]) | |
| if not 1 <= resume_epoch < epochs: | |
| raise ValueError( | |
| f"Resume epoch must be in [1,{epochs - 1}], received {resume_epoch}" | |
| ) | |
| if not history_path.is_file(): | |
| raise FileNotFoundError( | |
| f"Training history required for audited resume is missing: {history_path}" | |
| ) | |
| with history_path.open("r", encoding="utf-8") as handle: | |
| previous_history = json.load(handle) | |
| if not isinstance(previous_history, dict): | |
| raise TypeError("Existing training history root must be a mapping") | |
| records = previous_history.get("history") | |
| if not isinstance(records, list) or len(records) < resume_epoch: | |
| raise ValueError( | |
| f"Existing history has {len(records) if isinstance(records, list) else 0} " | |
| f"records, fewer than resume epoch {resume_epoch}" | |
| ) | |
| retained_records = records[:resume_epoch] | |
| if int(retained_records[-1]["epoch"]) != resume_epoch: | |
| raise ValueError("Existing training history is not contiguous at the resume epoch") | |
| checkpoint_metric = float(resume_checkpoint["best_train_full_relative_l2"]) | |
| history_best = min(float(record["train_full_relative_l2"]) for record in retained_records) | |
| if not np.isclose(checkpoint_metric, history_best, rtol=1e-7, atol=1e-9): | |
| raise ValueError( | |
| f"Resume checkpoint best metric {checkpoint_metric} differs from retained " | |
| f"history best {history_best}" | |
| ) | |
| model.load_state_dict(resume_checkpoint["model_state_dict"], strict=True) | |
| optimizer.load_state_dict(resume_checkpoint["optimizer_state_dict"]) | |
| scheduler.load_state_dict(resume_checkpoint["scheduler_state_dict"]) | |
| if not bool(resume_checkpoint.get("scheduler_step_applied", False)): | |
| scheduler.step() | |
| rng_state = resume_checkpoint.get("rng_state") | |
| if isinstance(rng_state, dict): | |
| restore_rng_state(rng_state, train_loader) | |
| else: | |
| resume_exact_rng = False | |
| discarded_records = len(records) - resume_epoch | |
| history_payload = previous_history | |
| history_payload["history"] = retained_records | |
| history_payload["completed_at"] = None | |
| history_payload["completed"] = False | |
| history_payload["epochs_requested"] = epochs | |
| history_payload["resume_exact_rng"] = resume_exact_rng | |
| resume_events = history_payload.setdefault("resume_events", []) | |
| resume_events.append( | |
| { | |
| "resumed_at": run_started, | |
| "checkpoint_path": str(resume_path), | |
| "checkpoint_epoch": resume_epoch, | |
| "discarded_history_records": discarded_records, | |
| "exact_rng_state_restored": resume_exact_rng, | |
| } | |
| ) | |
| best_metric = checkpoint_metric | |
| start_epoch = resume_epoch + 1 | |
| atomic_write_json(history_path, history_payload) | |
| print( | |
| f"resume checkpoint={resume_path} epoch={resume_epoch} " | |
| f"discarded_history_records={discarded_records} " | |
| f"exact_rng_state_restored={'yes' if resume_exact_rng else 'no'}", | |
| flush=True, | |
| ) | |
| print( | |
| f"device={device} parameters={parameter_count} " | |
| f"paper_parameters={paper_parameter_count} delta={parameter_count-paper_parameter_count}", | |
| flush=True, | |
| ) | |
| if device.type == "cuda" and torch.cuda.is_available(): | |
| torch.cuda.reset_peak_memory_stats(device) | |
| for epoch in range(start_epoch, epochs + 1): | |
| synchronize(device) | |
| epoch_start = time.perf_counter() | |
| lr_used = float(optimizer.param_groups[0]["lr"]) | |
| train_metrics = run_epoch(model, train_loader, device, epsilon, optimizer) | |
| test_metrics = run_epoch(model, test_loader, device, epsilon, optimizer=None) | |
| synchronize(device) | |
| duration = time.perf_counter() - epoch_start | |
| peak_memory_bytes = ( | |
| int(torch.cuda.max_memory_allocated(device)) | |
| if device.type == "cuda" and torch.cuda.is_available() | |
| else 0 | |
| ) | |
| monitor = float(train_metrics["full_relative_l2"]) | |
| is_best = monitor < best_metric | |
| if is_best: | |
| best_metric = monitor | |
| checkpoint = { | |
| "schema_version": "fno-ns2d-checkpoint-v1", | |
| "epoch": epoch, | |
| "model_state_dict": model.state_dict(), | |
| "optimizer_state_dict": optimizer.state_dict(), | |
| "scheduler_state_dict": scheduler.state_dict(), | |
| "best_train_full_relative_l2": best_metric, | |
| "test_full_relative_l2_at_best_epoch": float( | |
| test_metrics["full_relative_l2"] | |
| ), | |
| "monitor": "train_full_relative_l2", | |
| "test_selected": False, | |
| "config": deepcopy(config), | |
| "seed": seed, | |
| "parameter_count": parameter_count, | |
| "paper_parameter_count": paper_parameter_count, | |
| "parameter_count_difference": parameter_count - paper_parameter_count, | |
| "run_type": args.run_type, | |
| "scheduler_step_applied": False, | |
| "rng_state": capture_rng_state(train_loader), | |
| } | |
| atomic_torch_save(checkpoint_path, checkpoint) | |
| epoch_record = { | |
| "epoch": epoch, | |
| "learning_rate": lr_used, | |
| "duration_seconds": duration, | |
| "train_step_loss_sum": float(train_metrics["step_loss_sum"]), | |
| "train_mean_step_relative_l2": float( | |
| train_metrics["mean_step_relative_l2"] | |
| ), | |
| "train_full_relative_l2": float(train_metrics["full_relative_l2"]), | |
| "test_mean_step_relative_l2": float(test_metrics["mean_step_relative_l2"]), | |
| "test_full_relative_l2": float(test_metrics["full_relative_l2"]), | |
| "peak_accelerator_memory_bytes": peak_memory_bytes, | |
| "best": is_best, | |
| } | |
| history_payload["history"].append(epoch_record) | |
| history_payload["best_epoch"] = next( | |
| record["epoch"] | |
| for record in reversed(history_payload["history"]) | |
| if record["best"] | |
| ) | |
| history_payload["best_train_full_relative_l2"] = best_metric | |
| atomic_write_json(history_path, history_payload) | |
| print( | |
| f"epoch={epoch:04d}/{epochs:04d} time={duration:.3f}s lr={lr_used:.6g} " | |
| f"train_step_loss_sum={train_metrics['step_loss_sum']:.8f} " | |
| f"train_step_rel_l2={train_metrics['mean_step_relative_l2']:.8f} " | |
| f"train_full_rel_l2={train_metrics['full_relative_l2']:.8f} " | |
| f"test_step_rel_l2={test_metrics['mean_step_relative_l2']:.8f} " | |
| f"test_full_rel_l2={test_metrics['full_relative_l2']:.8f} " | |
| f"peak_mem_gib={peak_memory_bytes / (1024 ** 3):.3f} " | |
| f"best={'yes' if is_best else 'no'}", | |
| flush=True, | |
| ) | |
| scheduler.step() | |
| latest_checkpoint = { | |
| "schema_version": "fno-ns2d-training-state-v1", | |
| "epoch": epoch, | |
| "model_state_dict": model.state_dict(), | |
| "optimizer_state_dict": optimizer.state_dict(), | |
| "scheduler_state_dict": scheduler.state_dict(), | |
| "best_train_full_relative_l2": best_metric, | |
| "latest_train_full_relative_l2": float(train_metrics["full_relative_l2"]), | |
| "latest_test_full_relative_l2": float(test_metrics["full_relative_l2"]), | |
| "monitor": "train_full_relative_l2", | |
| "test_selected": False, | |
| "config": deepcopy(config), | |
| "seed": seed, | |
| "parameter_count": parameter_count, | |
| "paper_parameter_count": paper_parameter_count, | |
| "parameter_count_difference": parameter_count - paper_parameter_count, | |
| "run_type": args.run_type, | |
| "scheduler_step_applied": True, | |
| "rng_state": capture_rng_state(train_loader), | |
| } | |
| atomic_torch_save(latest_checkpoint_path, latest_checkpoint) | |
| history_payload["completed_at"] = datetime.now(timezone.utc).isoformat() | |
| history_payload["completed"] = True | |
| atomic_write_json(history_path, history_payload) | |
| print( | |
| f"training_complete best_epoch={history_payload['best_epoch']} " | |
| f"best_train_full_rel_l2={best_metric:.8f} checkpoint={checkpoint_path}", | |
| flush=True, | |
| ) | |
| if __name__ == "__main__": | |
| main() | |