CoolFace
Modelpublic

OneScience-Group/FNO

sourceHugging Facemitupdated 1mo agoView on Hugging Face
0likes7downloads
inference.py393 linesDownload Raw Back to scripts
1#!/usr/bin/env python32"""Run strict closed-loop inference for the trained FNO-2D checkpoint."""3 4from __future__ import annotations5 6import argparse7import csv8import hashlib9import json10import os11import sys12import time13from datetime import datetime, timezone14from pathlib import Path15from typing import Any16 17import numpy as np18import torch19 20 21PROJECT_ROOT = Path(__file__).resolve().parents[1]22if str(PROJECT_ROOT) not in sys.path:23    sys.path.insert(0, str(PROJECT_ROOT))24if str(Path(__file__).resolve().parent) not in sys.path:25    sys.path.insert(0, str(Path(__file__).resolve().parent))26 27from models import build_model_from_config  # noqa: E40228from train import (  # noqa: E40229    atomic_write_json,30    build_datasets,31    build_loader,32    data_file_from_config,33    environment_metadata,34    load_config,35    resolve_project_path,36    seed_everything,37    select_device,38    synchronize,39)40 41 42def parse_args() -> argparse.Namespace:43    parser = argparse.ArgumentParser(44        description="Evaluate a trained FNO-2D checkpoint on the fixed test split."45    )46    parser.add_argument(47        "--config", type=Path, default=PROJECT_ROOT / "config" / "config.yaml"48    )49    parser.add_argument("--checkpoint", type=Path, default=None)50    parser.add_argument("--output-dir", type=Path, default=None)51    parser.add_argument("--device", default="auto")52    parser.add_argument("--batch-size", type=int, default=None)53    parser.add_argument("--max-test-samples", type=int, default=None, help="Smoke only.")54    parser.add_argument("--rollout-steps", type=int, default=None, help="Smoke only.")55    return parser.parse_args()56 57 58def load_checkpoint(path: Path, device: torch.device) -> dict[str, Any]:59    if not path.is_file():60        raise FileNotFoundError(f"Checkpoint does not exist: {path}")61    try:62        checkpoint = torch.load(path, map_location=device, weights_only=False)63    except TypeError:64        checkpoint = torch.load(path, map_location=device)65    if not isinstance(checkpoint, dict):66        raise TypeError("Checkpoint root must be a mapping")67    required = {68        "model_state_dict",69        "config",70        "epoch",71        "parameter_count",72        "monitor",73        "test_selected",74    }75    missing = sorted(required.difference(checkpoint))76    if missing:77        raise KeyError(f"Checkpoint is missing required keys: {missing}")78    if checkpoint["test_selected"] is not False:79        raise ValueError("This reproduction forbids a test-selected checkpoint")80    if checkpoint["monitor"] != "train_full_relative_l2":81        raise ValueError(f"Unexpected checkpoint monitor: {checkpoint['monitor']}")82    return checkpoint83 84 85def nested_value(mapping: dict[str, Any], dotted_key: str) -> Any:86    value: Any = mapping87    for key in dotted_key.split("."):88        value = value[key]89    return value90 91 92def validate_checkpoint_config(93    current: dict[str, Any], checkpoint_config: dict[str, Any]94) -> None:95    keys = (96        "data.key",97        "data.layout",98        "data.dtype",99        "data.expected_shape",100        "data.resolution",101        "data.ntrain",102        "data.ntest",103        "data.test_start",104        "data.history",105        "data.horizon",106        "data.normalization",107        "model.input_channels",108        "model.output_channels",109        "model.use_grid",110        "model.grid_include_endpoint",111        "model.width",112        "model.modes1",113        "model.modes2",114        "model.num_layers",115        "model.projection_width",116        "model.fft_norm",117        "training.dtype",118        "training.relative_l2_epsilon",119    )120    differences = []121    for key in keys:122        current_value = nested_value(current, key)123        checkpoint_value = nested_value(checkpoint_config, key)124        if current_value != checkpoint_value:125            differences.append(f"{key}: current={current_value!r}, checkpoint={checkpoint_value!r}")126    if differences:127        raise ValueError("Checkpoint/config mismatch:\n" + "\n".join(differences))128 129 130def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str:131    digest = hashlib.sha256()132    with path.open("rb") as handle:133        while chunk := handle.read(chunk_size):134            digest.update(chunk)135    return digest.hexdigest()136 137 138def compute_metrics(139    prediction: np.ndarray, target: np.ndarray, epsilon: float140) -> tuple[np.ndarray, np.ndarray]:141    if prediction.shape != target.shape:142        raise ValueError(f"Prediction/target mismatch: {prediction.shape} vs {target.shape}")143    if prediction.ndim != 4:144        raise ValueError(f"Expected [N,H,W,T], received {prediction.shape}")145    if not np.isfinite(prediction).all() or not np.isfinite(target).all():146        raise FloatingPointError("Prediction or target contains NaN/Inf")147 148    difference = prediction.astype(np.float64) - target.astype(np.float64)149    target64 = target.astype(np.float64)150    full_numerator = np.linalg.norm(difference.reshape(prediction.shape[0], -1), axis=1)151    full_denominator = np.linalg.norm(target64.reshape(target.shape[0], -1), axis=1)152    full = full_numerator / (full_denominator + epsilon)153 154    difference_by_time = np.moveaxis(difference, -1, 1).reshape(155        prediction.shape[0], prediction.shape[-1], -1156    )157    target_by_time = np.moveaxis(target64, -1, 1).reshape(158        target.shape[0], target.shape[-1], -1159    )160    per_lead = np.linalg.norm(difference_by_time, axis=2) / (161        np.linalg.norm(target_by_time, axis=2) + epsilon162    )163    return full, per_lead164 165 166def atomic_save_npz(path: Path, **arrays: np.ndarray) -> None:167    path.parent.mkdir(parents=True, exist_ok=True)168    temporary = path.with_suffix(path.suffix + ".tmp")169    with temporary.open("wb") as handle:170        np.savez_compressed(handle, **arrays)171    os.replace(temporary, path)172 173 174def atomic_write_csv(175    path: Path,176    sample_indices: np.ndarray,177    full_metrics: np.ndarray,178    lead_metrics: np.ndarray,179    time_values: np.ndarray,180) -> None:181    path.parent.mkdir(parents=True, exist_ok=True)182    temporary = path.with_suffix(path.suffix + ".tmp")183    header = ["sample_index", "relative_l2_full"] + [184        f"relative_l2_t{int(value)}" for value in time_values185    ]186    with temporary.open("w", encoding="utf-8", newline="") as handle:187        writer = csv.writer(handle)188        writer.writerow(header)189        for row, sample_index in enumerate(sample_indices):190            writer.writerow(191                [int(sample_index), f"{full_metrics[row]:.17g}"]192                + [f"{value:.17g}" for value in lead_metrics[row]]193            )194    os.replace(temporary, path)195 196 197def main() -> None:198    args = parse_args()199    config = load_config(args.config)200    inference = config["inference"]201    training = config["training"]202    seed = int(inference.get("seed", training["seed"]))203    seed_everything(seed, bool(training.get("deterministic", True)))204    device = select_device(args.device)205    checkpoint_path = (206        resolve_project_path(config["paths"]["checkpoint"])207        if args.checkpoint is None208        else args.checkpoint.expanduser().resolve()209    )210    output_dir = (211        resolve_project_path(config["paths"]["results_dir"])212        if args.output_dir is None213        else args.output_dir.expanduser().resolve()214    )215    output_dir.mkdir(parents=True, exist_ok=True)216    checkpoint = load_checkpoint(checkpoint_path, device)217    validate_checkpoint_config(config, checkpoint["config"])218 219    checkpoint_run_type = str(checkpoint.get("run_type", "formal"))220    if checkpoint_run_type == "formal" and (221        args.max_test_samples is not None222        or args.rollout_steps is not None223        or args.output_dir is not None224        or args.checkpoint is not None225    ):226        raise ValueError(227            "Formal inference uses the exact configured checkpoint, test split, horizon, "228            "and results path; overrides are only allowed for smoke checkpoints"229        )230    horizon = int(config["data"]["horizon"])231    rollout_steps = horizon if args.rollout_steps is None else int(args.rollout_steps)232    if not 1 <= rollout_steps <= horizon:233        raise ValueError(f"rollout_steps must be in [1,{horizon}]")234 235    _, test_dataset = build_datasets(236        config,237        max_train_samples=1,238        max_test_samples=args.max_test_samples,239        rollout_steps=rollout_steps,240    )241    batch_size = int(242        inference["batch_size"] if args.batch_size is None else args.batch_size243    )244    test_loader = build_loader(245        test_dataset,246        batch_size=batch_size,247        shuffle=False,248        num_workers=int(training.get("num_workers", 0)),249        pin_memory=bool(training.get("pin_memory", True)) and device.type == "cuda",250        seed=seed,251    )252 253    # Preserve complex64 spectral parameters while moving the model to device.254    model = build_model_from_config(config).to(device=device)255    incompatible = model.load_state_dict(checkpoint["model_state_dict"], strict=True)256    if incompatible.missing_keys or incompatible.unexpected_keys:257        raise RuntimeError(f"Strict state load failed: {incompatible}")258    parameter_count = sum(parameter.numel() for parameter in model.parameters())259    if parameter_count != int(checkpoint["parameter_count"]):260        raise ValueError(261            f"Parameter count mismatch: model={parameter_count}, "262            f"checkpoint={checkpoint['parameter_count']}"263        )264    model.eval()265 266    predictions: list[np.ndarray] = []267    targets: list[np.ndarray] = []268    synchronize(device)269    started = time.perf_counter()270    processed = 0271    with torch.inference_mode():272        for batch_number, (history, target) in enumerate(test_loader, start=1):273            history = history.to(device=device, dtype=torch.float32, non_blocking=True)274            target_device = target.to(device=device, dtype=torch.float32, non_blocking=True)275            window = history276            batch_prediction: list[torch.Tensor] = []277            for step in range(rollout_steps):278                prediction_step = model(window)279                if not torch.isfinite(prediction_step).all():280                    raise FloatingPointError(281                        f"Non-finite prediction at batch {batch_number}, step {step + 1}"282                    )283                batch_prediction.append(prediction_step)284                window = torch.cat((window[..., 1:], prediction_step), dim=-1)285            prediction = torch.cat(batch_prediction, dim=-1)286            predictions.append(prediction.cpu().numpy().astype(np.float32, copy=False))287            targets.append(target_device.cpu().numpy().astype(np.float32, copy=False))288            processed += int(history.shape[0])289            print(290                f"inference_batch={batch_number:03d}/{len(test_loader):03d} "291                f"processed={processed}/{len(test_dataset)}",292                flush=True,293            )294    synchronize(device)295    duration = time.perf_counter() - started296 297    prediction_array = np.concatenate(predictions, axis=0)298    target_array = np.concatenate(targets, axis=0)299    expected_shape = (300        len(test_dataset),301        int(config["data"]["resolution"][0]),302        int(config["data"]["resolution"][1]),303        rollout_steps,304    )305    if prediction_array.shape != expected_shape or target_array.shape != expected_shape:306        raise ValueError(307            f"Unexpected inference arrays: prediction={prediction_array.shape}, "308            f"target={target_array.shape}, expected={expected_shape}"309        )310    epsilon = float(training["relative_l2_epsilon"])311    full_metrics, lead_metrics = compute_metrics(prediction_array, target_array, epsilon)312    test_start = int(config["data"]["test_start"])313    sample_indices = np.arange(314        test_start, test_start + len(test_dataset), dtype=np.int64315    )316    configured_times = np.asarray(config["data"]["future_times"], dtype=np.float32)317    time_values = configured_times[:rollout_steps]318 319    if args.output_dir is None:320        predictions_path = resolve_project_path(config["paths"]["predictions"])321        metrics_path = resolve_project_path(config["paths"]["metrics"])322        csv_path = resolve_project_path(config["paths"]["per_sample_metrics"])323    else:324        predictions_path = output_dir / "predictions.npz"325        metrics_path = output_dir / "metrics.json"326        csv_path = output_dir / "per_sample_metrics.csv"327 328    atomic_save_npz(329        predictions_path,330        prediction=prediction_array,331        target=target_array,332        sample_indices=sample_indices,333        time_values=time_values,334    )335    atomic_write_csv(csv_path, sample_indices, full_metrics, lead_metrics, time_values)336 337    paper_metric = float(config["paper"]["reference_relative_l2"])338    mean_full = float(full_metrics.mean())339    metrics_payload: dict[str, Any] = {340        "schema_version": "fno-ns2d-metrics-v1",341        "created_at": datetime.now(timezone.utc).isoformat(),342        "run_type": checkpoint_run_type,343        "config_path": str(args.config.expanduser().resolve()),344        "checkpoint_path": str(checkpoint_path),345        "checkpoint_sha256": sha256_file(checkpoint_path),346        "checkpoint_epoch": int(checkpoint["epoch"]),347        "checkpoint_monitor": checkpoint["monitor"],348        "test_selected": bool(checkpoint["test_selected"]),349        "data_path": str(data_file_from_config(config)),350        "prediction_path": str(predictions_path),351        "per_sample_metrics_path": str(csv_path),352        "sample_count": int(len(test_dataset)),353        "prediction_shape": list(prediction_array.shape),354        "sample_indices": {"first": int(sample_indices[0]), "last": int(sample_indices[-1])},355        "time_values": [float(value) for value in time_values],356        "metric": {357            "name": "samplewise_relative_l2",358            "formula": "||prediction-target||_2/(||target||_2+epsilon), then arithmetic mean over samples",359            "epsilon": epsilon,360            "full_trajectory_mean": mean_full,361            "full_trajectory_std": float(full_metrics.std(ddof=0)),362            "mean_step_relative_l2": float(lead_metrics.mean()),363            "per_lead_mean": [float(value) for value in lead_metrics.mean(axis=0)],364            "per_lead_std": [float(value) for value in lead_metrics.std(axis=0, ddof=0)],365        },366        "paper_comparison": {367            "paper_relative_l2": paper_metric,368            "signed_difference": mean_full - paper_metric,369            "absolute_difference": abs(mean_full - paper_metric),370        },371        "parameter_count": parameter_count,372        "paper_parameter_count": int(config["paper"]["reference_parameter_count"]),373        "parameter_count_difference": parameter_count374        - int(config["paper"]["reference_parameter_count"]),375        "runtime": {376            **environment_metadata(device),377            "duration_seconds": duration,378            "batch_size": batch_size,379        },380        "assumptions": config.get("assumptions", []),381    }382    atomic_write_json(metrics_path, metrics_payload)383    print(384        f"inference_complete samples={len(test_dataset)} duration={duration:.3f}s "385        f"full_relative_l2={mean_full:.8f} paper={paper_metric:.8f} "386        f"absolute_difference={abs(mean_full-paper_metric):.8f}",387        flush=True,388    )389 390 391if __name__ == "__main__":392    main()393