CoolFace
Modelpublic

OneScience-Group/FNO

sourceHugging Facemitupdated 1mo agoView on Hugging Face
0likes7downloads
train.py764 linesDownload Raw Back to scripts
1#!/usr/bin/env python32"""Train the paper-specified recurrent FNO-2D Navier--Stokes model."""3 4from __future__ import annotations5 6import argparse7import json8import os9import platform10import random11import sys12import time13from copy import deepcopy14from datetime import datetime, timezone15from pathlib import Path16from typing import Any17 18import numpy as np19import scipy20import scipy.io21import torch22import yaml23from torch import Tensor, nn24from torch.utils.data import DataLoader, TensorDataset25 26 27PROJECT_ROOT = Path(__file__).resolve().parents[1]28if str(PROJECT_ROOT) not in sys.path:29    sys.path.insert(0, str(PROJECT_ROOT))30 31from models import build_model_from_config  # noqa: E40232 33 34def parse_args() -> argparse.Namespace:35    parser = argparse.ArgumentParser(36        description="Train FNO-2D on the validated Navier-Stokes trajectory MAT file."37    )38    parser.add_argument(39        "--config",40        type=Path,41        default=PROJECT_ROOT / "config" / "config.yaml",42        help="YAML configuration path.",43    )44    parser.add_argument("--epochs", type=int, default=None, help="Override epochs (smoke only).")45    parser.add_argument(46        "--rollout-steps", type=int, default=None, help="Override rollout steps (smoke only)."47    )48    parser.add_argument(49        "--max-train-samples", type=int, default=None, help="Limit train samples (smoke only)."50    )51    parser.add_argument(52        "--max-test-samples", type=int, default=None, help="Limit test samples (smoke only)."53    )54    parser.add_argument("--batch-size", type=int, default=None, help="Override batch size.")55    parser.add_argument(56        "--device", default="auto", help="auto, cpu, cuda, or an explicit torch device."57    )58    parser.add_argument("--checkpoint", type=Path, default=None, help="Output checkpoint path.")59    parser.add_argument(60        "--resume",61        type=Path,62        default=None,63        help="Resume from a formal training-state checkpoint.",64    )65    parser.add_argument("--output-dir", type=Path, default=None, help="Output directory.")66    parser.add_argument(67        "--run-type", choices=("formal", "smoke"), default="formal", help="Run provenance tag."68    )69    return parser.parse_args()70 71 72def load_config(path: Path) -> dict[str, Any]:73    config_path = path.expanduser().resolve()74    if not config_path.is_file():75        raise FileNotFoundError(f"Configuration file does not exist: {config_path}")76    with config_path.open("r", encoding="utf-8") as handle:77        config = yaml.safe_load(handle)78    if not isinstance(config, dict):79        raise ValueError("Configuration root must be a mapping")80    validate_config(config)81    return config82 83 84def validate_config(config: dict[str, Any]) -> None:85    for section in ("paper", "data", "model", "training", "paths"):86        if section not in config or not isinstance(config[section], dict):87            raise ValueError(f"Missing configuration section: {section}")88 89    data = config["data"]90    model = config["model"]91    training = config["training"]92    required_data = {93        "root",94        "file",95        "key",96        "layout",97        "dtype",98        "expected_shape",99        "resolution",100        "ntrain",101        "ntest",102        "test_start",103        "history",104        "horizon",105        "normalization",106    }107    required_model = {108        "input_channels",109        "output_channels",110        "width",111        "modes1",112        "modes2",113        "num_layers",114        "projection_width",115        "use_grid",116    }117    required_training = {118        "epochs",119        "batch_size",120        "optimizer",121        "learning_rate",122        "weight_decay",123        "scheduler",124        "scheduler_step_size",125        "scheduler_gamma",126        "seed",127        "dtype",128        "relative_l2_epsilon",129        "checkpoint_monitor",130    }131    for label, mapping, required in (132        ("data", data, required_data),133        ("model", model, required_model),134        ("training", training, required_training),135    ):136        missing = sorted(required.difference(mapping))137        if missing:138            raise ValueError(f"Missing {label} configuration keys: {missing}")139 140    if data["layout"] != "N,H,W,T":141        raise ValueError("This reproduction requires data.layout=N,H,W,T")142    if data["dtype"] != "float32" or training["dtype"] != "float32":143        raise ValueError("The audited reproduction requires float32 data and training")144    if data["normalization"] != "none":145        raise ValueError("The paper-faithful default requires normalization=none")146    if int(data["history"]) != 10 or int(model["input_channels"]) != 10:147        raise ValueError("The FNO-2D experiment requires ten history channels")148    if int(data["horizon"]) != 10 or int(model["output_channels"]) != 1:149        raise ValueError("The experiment requires a ten-step rollout of one-step outputs")150    if int(model["width"]) != 32 or int(model["num_layers"]) != 4:151        raise ValueError("Strict paper settings require width=32 and num_layers=4")152    if int(model["modes1"]) != 12 or int(model["modes2"]) != 12:153        raise ValueError("Strict paper settings require 12 retained modes per axis")154    if str(training["optimizer"]).lower() != "adam":155        raise ValueError("The paper specifies Adam")156    if str(training["scheduler"]).lower() != "step_lr":157        raise ValueError("The paper schedule is represented by StepLR")158    if str(training["checkpoint_monitor"]) != "train_full_relative_l2":159        raise ValueError("Test metrics must not select the checkpoint")160 161 162def resolve_project_path(value: str | Path) -> Path:163    path = Path(value).expanduser()164    return path.resolve() if path.is_absolute() else (PROJECT_ROOT / path).resolve()165 166 167def data_file_from_config(config: dict[str, Any]) -> Path:168    data = config["data"]169    path = Path(str(data["root"])).expanduser() / str(data["file"])170    path = path.resolve()171    if not path.is_file():172        raise FileNotFoundError(f"Navier-Stokes MAT file does not exist: {path}")173    return path174 175 176def load_trajectory_array(config: dict[str, Any]) -> np.ndarray:177    data = config["data"]178    path = data_file_from_config(config)179    key = str(data["key"])180    payload = scipy.io.loadmat(path, variable_names=[key])181    if key not in payload:182        raise KeyError(f"MAT field {key!r} is missing from {path}")183    trajectories = payload[key]184    expected_shape = tuple(int(value) for value in data["expected_shape"])185    if trajectories.shape != expected_shape:186        raise ValueError(187            f"Expected {key} shape {expected_shape}, received {trajectories.shape}"188        )189    if trajectories.dtype != np.float32:190        raise TypeError(f"Expected {key} dtype float32, received {trajectories.dtype}")191    if not np.isfinite(trajectories).all():192        raise ValueError("Trajectory array contains NaN or Inf")193    return trajectories194 195 196def build_datasets(197    config: dict[str, Any],198    max_train_samples: int | None = None,199    max_test_samples: int | None = None,200    rollout_steps: int | None = None,201) -> tuple[TensorDataset, TensorDataset]:202    data = config["data"]203    trajectories = load_trajectory_array(config)204    history = int(data["history"])205    horizon = int(data["horizon"])206    steps = horizon if rollout_steps is None else int(rollout_steps)207    if not 1 <= steps <= horizon:208        raise ValueError(f"rollout_steps must be in [1,{horizon}], got {steps}")209 210    ntrain = int(data["ntrain"])211    ntest = int(data["ntest"])212    train_start = int(data.get("train_start", 0))213    test_start = int(data["test_start"])214    train_count = ntrain if max_train_samples is None else min(ntrain, max_train_samples)215    test_count = ntest if max_test_samples is None else min(ntest, max_test_samples)216    if train_count <= 0 or test_count <= 0:217        raise ValueError("Training and test sample counts must be positive")218 219    train = trajectories[train_start : train_start + train_count]220    test = trajectories[test_start : test_start + test_count]221    train_history = torch.from_numpy(train[..., :history])222    train_target = torch.from_numpy(train[..., history : history + steps])223    test_history = torch.from_numpy(test[..., :history])224    test_target = torch.from_numpy(test[..., history : history + steps])225    expected_hw = tuple(int(value) for value in data["resolution"])226    expected_history_shape = (expected_hw[0], expected_hw[1], history)227    if tuple(train_history.shape[1:]) != expected_history_shape:228        raise ValueError(f"Invalid train history shape: {train_history.shape}")229    if tuple(test_history.shape[1:]) != expected_history_shape:230        raise ValueError(f"Invalid test history shape: {test_history.shape}")231    return TensorDataset(train_history, train_target), TensorDataset(test_history, test_target)232 233 234def seed_everything(seed: int, deterministic: bool) -> None:235    random.seed(seed)236    np.random.seed(seed)237    torch.manual_seed(seed)238    if torch.cuda.is_available():239        torch.cuda.manual_seed_all(seed)240    if hasattr(torch.backends, "cudnn"):241        torch.backends.cudnn.deterministic = deterministic242        torch.backends.cudnn.benchmark = not deterministic243    if deterministic:244        torch.use_deterministic_algorithms(True, warn_only=True)245 246 247def select_device(requested: str) -> torch.device:248    if requested == "auto":249        return torch.device("cuda" if torch.cuda.is_available() else "cpu")250    device = torch.device(requested)251    if device.type == "cuda" and not torch.cuda.is_available():252        raise RuntimeError(f"Requested {requested}, but torch reports no CUDA/DCU device")253    return device254 255 256def relative_l2_per_sample(prediction: Tensor, target: Tensor, epsilon: float) -> Tensor:257    if prediction.shape != target.shape:258        raise ValueError(f"Relative L2 shape mismatch: {prediction.shape} vs {target.shape}")259    if prediction.ndim < 2:260        raise ValueError("Relative L2 inputs must include a batch and at least one feature axis")261    difference = torch.linalg.vector_norm(262        (prediction - target).reshape(prediction.shape[0], -1), dim=1263    )264    denominator = torch.linalg.vector_norm(target.reshape(target.shape[0], -1), dim=1)265    return difference / (denominator + epsilon)266 267 268def autoregressive_rollout(269    model: nn.Module,270    history: Tensor,271    target: Tensor,272    epsilon: float,273) -> tuple[Tensor, Tensor, Tensor]:274    if history.ndim != 4 or target.ndim != 4:275        raise ValueError("history and target must be channel-last four-dimensional tensors")276    window = history277    predictions: list[Tensor] = []278    step_ratios: list[Tensor] = []279    for step in range(target.shape[-1]):280        prediction = model(window)281        expected_step_shape = (*history.shape[:-1], 1)282        if tuple(prediction.shape) != expected_step_shape:283            raise ValueError(284                f"Model returned {tuple(prediction.shape)}, expected {expected_step_shape}"285            )286        target_step = target[..., step : step + 1]287        predictions.append(prediction)288        step_ratios.append(relative_l2_per_sample(prediction, target_step, epsilon))289        window = torch.cat((window[..., 1:], prediction), dim=-1)290    rollout = torch.cat(predictions, dim=-1)291    ratios = torch.stack(step_ratios, dim=1)292    backward_loss = ratios.mean(dim=0).sum()293    return rollout, ratios, backward_loss294 295 296def build_loader(297    dataset: TensorDataset,298    batch_size: int,299    shuffle: bool,300    num_workers: int,301    pin_memory: bool,302    seed: int,303) -> DataLoader:304    generator = torch.Generator()305    generator.manual_seed(seed)306    return DataLoader(307        dataset,308        batch_size=batch_size,309        shuffle=shuffle,310        num_workers=num_workers,311        pin_memory=pin_memory,312        drop_last=False,313        generator=generator,314    )315 316 317def run_epoch(318    model: nn.Module,319    loader: DataLoader,320    device: torch.device,321    epsilon: float,322    optimizer: torch.optim.Optimizer | None,323) -> dict[str, float]:324    training = optimizer is not None325    model.train(training)326    total_samples = 0327    total_step_ratio = 0.0328    total_full_ratio = 0.0329    steps_per_sample: int | None = None330 331    context = torch.enable_grad() if training else torch.inference_mode()332    with context:333        for history, target in loader:334            history = history.to(device=device, dtype=torch.float32, non_blocking=True)335            target = target.to(device=device, dtype=torch.float32, non_blocking=True)336            if training:337                optimizer.zero_grad(set_to_none=True)338            prediction, step_ratios, backward_loss = autoregressive_rollout(339                model, history, target, epsilon340            )341            full_ratios = relative_l2_per_sample(prediction, target, epsilon)342            if not torch.isfinite(backward_loss) or not torch.isfinite(full_ratios).all():343                raise FloatingPointError("Non-finite training/evaluation loss encountered")344            if training:345                backward_loss.backward()346                for name, parameter in model.named_parameters():347                    if parameter.grad is not None and not torch.isfinite(parameter.grad).all():348                        raise FloatingPointError(f"Non-finite gradient in parameter {name}")349                optimizer.step()350 351            batch_samples = int(history.shape[0])352            total_samples += batch_samples353            total_step_ratio += float(step_ratios.detach().sum().cpu())354            total_full_ratio += float(full_ratios.detach().sum().cpu())355            steps_per_sample = int(step_ratios.shape[1])356 357    if total_samples == 0 or steps_per_sample is None:358        raise RuntimeError("DataLoader produced no batches")359    return {360        "step_loss_sum": total_step_ratio / total_samples,361        "mean_step_relative_l2": total_step_ratio / (total_samples * steps_per_sample),362        "full_relative_l2": total_full_ratio / total_samples,363        "samples": float(total_samples),364        "rollout_steps": float(steps_per_sample),365    }366 367 368def synchronize(device: torch.device) -> None:369    if device.type == "cuda" and torch.cuda.is_available():370        torch.cuda.synchronize(device)371 372 373def environment_metadata(device: torch.device) -> dict[str, Any]:374    device_name = "cpu"375    if device.type == "cuda" and torch.cuda.is_available():376        device_name = torch.cuda.get_device_name(device)377    return {378        "python": platform.python_version(),379        "pytorch": torch.__version__,380        "numpy": np.__version__,381        "scipy": scipy.__version__,382        "pyyaml": yaml.__version__,383        "device": str(device),384        "device_name": device_name,385        "torch_cuda_version": torch.version.cuda,386        "hostname": platform.node(),387    }388 389 390def atomic_write_json(path: Path, payload: dict[str, Any]) -> None:391    path.parent.mkdir(parents=True, exist_ok=True)392    temporary = path.with_suffix(path.suffix + ".tmp")393    with temporary.open("w", encoding="utf-8") as handle:394        json.dump(payload, handle, indent=2, ensure_ascii=False)395        handle.write("\n")396    os.replace(temporary, path)397 398 399def atomic_torch_save(path: Path, payload: dict[str, Any]) -> None:400    path.parent.mkdir(parents=True, exist_ok=True)401    temporary = path.with_suffix(path.suffix + ".tmp")402    torch.save(payload, temporary)403    os.replace(temporary, path)404 405 406def load_training_checkpoint(path: Path, device: torch.device) -> dict[str, Any]:407    checkpoint_path = path.expanduser().resolve()408    if not checkpoint_path.is_file():409        raise FileNotFoundError(f"Resume checkpoint does not exist: {checkpoint_path}")410    try:411        checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)412    except TypeError:413        checkpoint = torch.load(checkpoint_path, map_location=device)414    if not isinstance(checkpoint, dict):415        raise TypeError("Resume checkpoint root must be a mapping")416    required = {417        "epoch",418        "model_state_dict",419        "optimizer_state_dict",420        "scheduler_state_dict",421        "best_train_full_relative_l2",422        "monitor",423        "test_selected",424        "config",425        "run_type",426    }427    missing = sorted(required.difference(checkpoint))428    if missing:429        raise KeyError(f"Resume checkpoint is missing required keys: {missing}")430    if checkpoint["run_type"] != "formal":431        raise ValueError("Formal training can only resume a formal checkpoint")432    if checkpoint["monitor"] != "train_full_relative_l2":433        raise ValueError(f"Unexpected checkpoint monitor: {checkpoint['monitor']}")434    if checkpoint["test_selected"] is not False:435        raise ValueError("This reproduction forbids resuming a test-selected checkpoint")436    return checkpoint437 438 439def capture_rng_state(train_loader: DataLoader) -> dict[str, Any]:440    state: dict[str, Any] = {441        "python": random.getstate(),442        "numpy": np.random.get_state(),443        "torch": torch.get_rng_state(),444        "train_loader_generator": train_loader.generator.get_state(),445    }446    if torch.cuda.is_available():447        state["cuda"] = torch.cuda.get_rng_state_all()448    return state449 450 451def restore_rng_state(state: dict[str, Any], train_loader: DataLoader) -> None:452    random.setstate(state["python"])453    np.random.set_state(state["numpy"])454    torch.set_rng_state(state["torch"].cpu())455    train_loader.generator.set_state(state["train_loader_generator"].cpu())456    if torch.cuda.is_available() and "cuda" in state:457        torch.cuda.set_rng_state_all([value.cpu() for value in state["cuda"]])458 459 460def main() -> None:461    args = parse_args()462    config = load_config(args.config)463    training = config["training"]464    seed = int(training["seed"])465    seed_everything(seed, bool(training.get("deterministic", True)))466    device = select_device(args.device)467 468    epochs = int(training["epochs"] if args.epochs is None else args.epochs)469    batch_size = int(training["batch_size"] if args.batch_size is None else args.batch_size)470    horizon = int(config["data"]["horizon"])471    rollout_steps = horizon if args.rollout_steps is None else int(args.rollout_steps)472    if epochs <= 0 or batch_size <= 0:473        raise ValueError("epochs and batch_size must be positive")474    if args.run_type == "formal" and any(475        value is not None476        for value in (477            args.epochs,478            args.rollout_steps,479            args.max_train_samples,480            args.max_test_samples,481            args.output_dir,482            args.checkpoint,483        )484    ):485        raise ValueError(486            "Formal runs use the exact configured data, epochs, rollout, checkpoint, "487            "and results paths; overrides require --run-type smoke"488        )489 490    output_dir = (491        resolve_project_path(config["paths"]["results_dir"])492        if args.output_dir is None493        else args.output_dir.expanduser().resolve()494    )495    checkpoint_path = (496        resolve_project_path(config["paths"]["checkpoint"])497        if args.checkpoint is None498        else args.checkpoint.expanduser().resolve()499    )500    latest_checkpoint_path = checkpoint_path.with_name("last_model.pth")501    history_path = (502        resolve_project_path(config["paths"]["train_history"])503        if args.output_dir is None504        else output_dir / "train_history.json"505    )506    output_dir.mkdir(parents=True, exist_ok=True)507    checkpoint_path.parent.mkdir(parents=True, exist_ok=True)508 509    train_dataset, test_dataset = build_datasets(510        config,511        max_train_samples=args.max_train_samples,512        max_test_samples=args.max_test_samples,513        rollout_steps=rollout_steps,514    )515    pin_memory = bool(training.get("pin_memory", True)) and device.type == "cuda"516    train_loader = build_loader(517        train_dataset,518        batch_size,519        True,520        int(training.get("num_workers", 0)),521        pin_memory,522        seed,523    )524    test_loader = build_loader(525        test_dataset,526        batch_size,527        False,528        int(training.get("num_workers", 0)),529        pin_memory,530        seed,531    )532 533    # Move devices without forcing a global dtype conversion: the pointwise534    # parameters are float32 while spectral weights must remain complex64.535    model = build_model_from_config(config).to(device=device)536    parameter_count = sum(parameter.numel() for parameter in model.parameters())537    paper_parameter_count = int(config["paper"]["reference_parameter_count"])538    optimizer = torch.optim.Adam(539        model.parameters(),540        lr=float(training["learning_rate"]),541        weight_decay=float(training["weight_decay"]),542    )543    scheduler = torch.optim.lr_scheduler.StepLR(544        optimizer,545        step_size=int(training["scheduler_step_size"]),546        gamma=float(training["scheduler_gamma"]),547    )548    epsilon = float(training["relative_l2_epsilon"])549 550    run_started = datetime.now(timezone.utc).isoformat()551    history_payload: dict[str, Any] = {552        "schema_version": "fno-ns2d-train-history-v1",553        "run_type": args.run_type,554        "started_at": run_started,555        "completed_at": None,556        "config_path": str(args.config.expanduser().resolve()),557        "data_path": str(data_file_from_config(config)),558        "checkpoint_path": str(checkpoint_path),559        "config": deepcopy(config),560        "environment": environment_metadata(device),561        "parameter_count": parameter_count,562        "paper_parameter_count": paper_parameter_count,563        "parameter_count_difference": parameter_count - paper_parameter_count,564        "test_selected": False,565        "epochs_requested": epochs,566        "rollout_steps": rollout_steps,567        "train_samples": len(train_dataset),568        "test_samples": len(test_dataset),569        "history": [],570    }571 572    best_metric = float("inf")573    start_epoch = 1574    resume_exact_rng = True575    if args.resume is not None:576        resume_path = args.resume.expanduser().resolve()577        resume_checkpoint = load_training_checkpoint(resume_path, device)578        if resume_checkpoint["config"] != config:579            raise ValueError("Resume checkpoint configuration differs from the current YAML")580        resume_epoch = int(resume_checkpoint["epoch"])581        if not 1 <= resume_epoch < epochs:582            raise ValueError(583                f"Resume epoch must be in [1,{epochs - 1}], received {resume_epoch}"584            )585        if not history_path.is_file():586            raise FileNotFoundError(587                f"Training history required for audited resume is missing: {history_path}"588            )589        with history_path.open("r", encoding="utf-8") as handle:590            previous_history = json.load(handle)591        if not isinstance(previous_history, dict):592            raise TypeError("Existing training history root must be a mapping")593        records = previous_history.get("history")594        if not isinstance(records, list) or len(records) < resume_epoch:595            raise ValueError(596                f"Existing history has {len(records) if isinstance(records, list) else 0} "597                f"records, fewer than resume epoch {resume_epoch}"598            )599        retained_records = records[:resume_epoch]600        if int(retained_records[-1]["epoch"]) != resume_epoch:601            raise ValueError("Existing training history is not contiguous at the resume epoch")602        checkpoint_metric = float(resume_checkpoint["best_train_full_relative_l2"])603        history_best = min(float(record["train_full_relative_l2"]) for record in retained_records)604        if not np.isclose(checkpoint_metric, history_best, rtol=1e-7, atol=1e-9):605            raise ValueError(606                f"Resume checkpoint best metric {checkpoint_metric} differs from retained "607                f"history best {history_best}"608            )609 610        model.load_state_dict(resume_checkpoint["model_state_dict"], strict=True)611        optimizer.load_state_dict(resume_checkpoint["optimizer_state_dict"])612        scheduler.load_state_dict(resume_checkpoint["scheduler_state_dict"])613        if not bool(resume_checkpoint.get("scheduler_step_applied", False)):614            scheduler.step()615        rng_state = resume_checkpoint.get("rng_state")616        if isinstance(rng_state, dict):617            restore_rng_state(rng_state, train_loader)618        else:619            resume_exact_rng = False620 621        discarded_records = len(records) - resume_epoch622        history_payload = previous_history623        history_payload["history"] = retained_records624        history_payload["completed_at"] = None625        history_payload["completed"] = False626        history_payload["epochs_requested"] = epochs627        history_payload["resume_exact_rng"] = resume_exact_rng628        resume_events = history_payload.setdefault("resume_events", [])629        resume_events.append(630            {631                "resumed_at": run_started,632                "checkpoint_path": str(resume_path),633                "checkpoint_epoch": resume_epoch,634                "discarded_history_records": discarded_records,635                "exact_rng_state_restored": resume_exact_rng,636            }637        )638        best_metric = checkpoint_metric639        start_epoch = resume_epoch + 1640        atomic_write_json(history_path, history_payload)641        print(642            f"resume checkpoint={resume_path} epoch={resume_epoch} "643            f"discarded_history_records={discarded_records} "644            f"exact_rng_state_restored={'yes' if resume_exact_rng else 'no'}",645            flush=True,646        )647    print(648        f"device={device} parameters={parameter_count} "649        f"paper_parameters={paper_parameter_count} delta={parameter_count-paper_parameter_count}",650        flush=True,651    )652 653    if device.type == "cuda" and torch.cuda.is_available():654        torch.cuda.reset_peak_memory_stats(device)655    for epoch in range(start_epoch, epochs + 1):656        synchronize(device)657        epoch_start = time.perf_counter()658        lr_used = float(optimizer.param_groups[0]["lr"])659        train_metrics = run_epoch(model, train_loader, device, epsilon, optimizer)660        test_metrics = run_epoch(model, test_loader, device, epsilon, optimizer=None)661        synchronize(device)662        duration = time.perf_counter() - epoch_start663        peak_memory_bytes = (664            int(torch.cuda.max_memory_allocated(device))665            if device.type == "cuda" and torch.cuda.is_available()666            else 0667        )668 669        monitor = float(train_metrics["full_relative_l2"])670        is_best = monitor < best_metric671        if is_best:672            best_metric = monitor673            checkpoint = {674                "schema_version": "fno-ns2d-checkpoint-v1",675                "epoch": epoch,676                "model_state_dict": model.state_dict(),677                "optimizer_state_dict": optimizer.state_dict(),678                "scheduler_state_dict": scheduler.state_dict(),679                "best_train_full_relative_l2": best_metric,680                "test_full_relative_l2_at_best_epoch": float(681                    test_metrics["full_relative_l2"]682                ),683                "monitor": "train_full_relative_l2",684                "test_selected": False,685                "config": deepcopy(config),686                "seed": seed,687                "parameter_count": parameter_count,688                "paper_parameter_count": paper_parameter_count,689                "parameter_count_difference": parameter_count - paper_parameter_count,690                "run_type": args.run_type,691                "scheduler_step_applied": False,692                "rng_state": capture_rng_state(train_loader),693            }694            atomic_torch_save(checkpoint_path, checkpoint)695 696        epoch_record = {697            "epoch": epoch,698            "learning_rate": lr_used,699            "duration_seconds": duration,700            "train_step_loss_sum": float(train_metrics["step_loss_sum"]),701            "train_mean_step_relative_l2": float(702                train_metrics["mean_step_relative_l2"]703            ),704            "train_full_relative_l2": float(train_metrics["full_relative_l2"]),705            "test_mean_step_relative_l2": float(test_metrics["mean_step_relative_l2"]),706            "test_full_relative_l2": float(test_metrics["full_relative_l2"]),707            "peak_accelerator_memory_bytes": peak_memory_bytes,708            "best": is_best,709        }710        history_payload["history"].append(epoch_record)711        history_payload["best_epoch"] = next(712            record["epoch"]713            for record in reversed(history_payload["history"])714            if record["best"]715        )716        history_payload["best_train_full_relative_l2"] = best_metric717        atomic_write_json(history_path, history_payload)718        print(719            f"epoch={epoch:04d}/{epochs:04d} time={duration:.3f}s lr={lr_used:.6g} "720            f"train_step_loss_sum={train_metrics['step_loss_sum']:.8f} "721            f"train_step_rel_l2={train_metrics['mean_step_relative_l2']:.8f} "722            f"train_full_rel_l2={train_metrics['full_relative_l2']:.8f} "723            f"test_step_rel_l2={test_metrics['mean_step_relative_l2']:.8f} "724            f"test_full_rel_l2={test_metrics['full_relative_l2']:.8f} "725            f"peak_mem_gib={peak_memory_bytes / (1024 ** 3):.3f} "726            f"best={'yes' if is_best else 'no'}",727            flush=True,728        )729        scheduler.step()730        latest_checkpoint = {731            "schema_version": "fno-ns2d-training-state-v1",732            "epoch": epoch,733            "model_state_dict": model.state_dict(),734            "optimizer_state_dict": optimizer.state_dict(),735            "scheduler_state_dict": scheduler.state_dict(),736            "best_train_full_relative_l2": best_metric,737            "latest_train_full_relative_l2": float(train_metrics["full_relative_l2"]),738            "latest_test_full_relative_l2": float(test_metrics["full_relative_l2"]),739            "monitor": "train_full_relative_l2",740            "test_selected": False,741            "config": deepcopy(config),742            "seed": seed,743            "parameter_count": parameter_count,744            "paper_parameter_count": paper_parameter_count,745            "parameter_count_difference": parameter_count - paper_parameter_count,746            "run_type": args.run_type,747            "scheduler_step_applied": True,748            "rng_state": capture_rng_state(train_loader),749        }750        atomic_torch_save(latest_checkpoint_path, latest_checkpoint)751 752    history_payload["completed_at"] = datetime.now(timezone.utc).isoformat()753    history_payload["completed"] = True754    atomic_write_json(history_path, history_payload)755    print(756        f"training_complete best_epoch={history_payload['best_epoch']} "757        f"best_train_full_rel_l2={best_metric:.8f} checkpoint={checkpoint_path}",758        flush=True,759    )760 761 762if __name__ == "__main__":763    main()764