CoolFace
Modelpublic

OneScience-Group/FNO

sourceHugging Facemitupdated 1mo agoView on Hugging Face
0likes7downloads
result.py506 linesDownload Raw Back to scripts
1#!/usr/bin/env python32"""Validate real FNO outputs and render paper-comparison figures."""3 4from __future__ import annotations5 6import argparse7import csv8import hashlib9import json10import os11import sys12from datetime import datetime, timezone13from pathlib import Path14from typing import Any15 16import matplotlib17 18matplotlib.use("Agg")19import matplotlib.pyplot as plt  # noqa: E40220import numpy as np  # noqa: E40221 22 23PROJECT_ROOT = Path(__file__).resolve().parents[1]24if str(PROJECT_ROOT) not in sys.path:25    sys.path.insert(0, str(PROJECT_ROOT))26if str(Path(__file__).resolve().parent) not in sys.path:27    sys.path.insert(0, str(Path(__file__).resolve().parent))28 29from inference import compute_metrics  # noqa: E40230from train import atomic_write_json, load_config, resolve_project_path  # noqa: E40231 32 33def parse_args() -> argparse.Namespace:34    parser = argparse.ArgumentParser(35        description="Validate FNO inference artifacts and generate scientific figures."36    )37    parser.add_argument(38        "--config", type=Path, default=PROJECT_ROOT / "config" / "config.yaml"39    )40    parser.add_argument("--output-dir", type=Path, default=None)41    parser.add_argument(42        "--sample-index", type=int, default=0, help="Local test-set index to visualize."43    )44    return parser.parse_args()45 46 47def read_json(path: Path) -> dict[str, Any]:48    if not path.is_file():49        raise FileNotFoundError(f"Required JSON artifact is missing: {path}")50    with path.open("r", encoding="utf-8") as handle:51        payload = json.load(handle)52    if not isinstance(payload, dict):53        raise TypeError(f"Expected a JSON mapping in {path}")54    return payload55 56 57def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str:58    digest = hashlib.sha256()59    with path.open("rb") as handle:60        while chunk := handle.read(chunk_size):61            digest.update(chunk)62    return digest.hexdigest()63 64 65def atomic_save_figure(figure: plt.Figure, path: Path, dpi: int = 300) -> None:66    path.parent.mkdir(parents=True, exist_ok=True)67    temporary = path.with_suffix(path.suffix + ".tmp")68    figure.savefig(temporary, format="png", dpi=dpi, bbox_inches="tight")69    plt.close(figure)70    os.replace(temporary, path)71    if path.stat().st_size == 0:72        raise RuntimeError(f"Generated an empty figure: {path}")73 74 75def atomic_write_text(path: Path, content: str) -> None:76    path.parent.mkdir(parents=True, exist_ok=True)77    temporary = path.with_suffix(path.suffix + ".tmp")78    with temporary.open("w", encoding="utf-8") as handle:79        handle.write(content)80    os.replace(temporary, path)81 82 83def verify_csv(84    path: Path,85    sample_indices: np.ndarray,86    full_metrics: np.ndarray,87    lead_metrics: np.ndarray,88    time_values: np.ndarray,89) -> None:90    if not path.is_file():91        raise FileNotFoundError(f"Per-sample metrics CSV is missing: {path}")92    expected_header = ["sample_index", "relative_l2_full"] + [93        f"relative_l2_t{int(value)}" for value in time_values94    ]95    with path.open("r", encoding="utf-8", newline="") as handle:96        rows = list(csv.reader(handle))97    if not rows or rows[0] != expected_header:98        raise ValueError(f"Unexpected CSV header in {path}: {rows[0] if rows else None}")99    if len(rows) - 1 != len(sample_indices):100        raise ValueError(f"Expected {len(sample_indices)} CSV rows, found {len(rows)-1}")101    for row_index, row in enumerate(rows[1:]):102        if int(row[0]) != int(sample_indices[row_index]):103            raise ValueError(f"CSV sample order mismatch at row {row_index + 2}")104        observed = np.asarray([float(value) for value in row[1:]], dtype=np.float64)105        expected = np.concatenate(106            ([full_metrics[row_index]], lead_metrics[row_index].astype(np.float64))107        )108        if not np.allclose(observed, expected, rtol=1e-12, atol=1e-12):109            raise ValueError(f"CSV metric mismatch for sample {sample_indices[row_index]}")110 111 112def validate_history(history_payload: dict[str, Any]) -> list[dict[str, Any]]:113    records = history_payload.get("history")114    if not isinstance(records, list) or not records:115        raise ValueError("Training history contains no epoch records")116    formal = history_payload.get("run_type") == "formal"117    requested = int(history_payload.get("epochs_requested", len(records)))118    if formal and (requested != 500 or len(records) != 500):119        raise ValueError(120            f"Formal paper reproduction requires 500 epochs, got requested={requested}, "121            f"records={len(records)}"122        )123    required = (124        "epoch",125        "learning_rate",126        "duration_seconds",127        "train_step_loss_sum",128        "train_mean_step_relative_l2",129        "train_full_relative_l2",130        "test_mean_step_relative_l2",131        "test_full_relative_l2",132        "best",133    )134    for position, record in enumerate(records, start=1):135        missing = [key for key in required if key not in record]136        if missing:137            raise KeyError(f"Epoch record {position} is missing {missing}")138        if int(record["epoch"]) != position:139            raise ValueError(f"Epoch sequence is not contiguous at record {position}")140        numeric = [float(record[key]) for key in required[1:-1]]141        if not np.isfinite(numeric).all():142            raise FloatingPointError(f"Non-finite training history at epoch {position}")143    return records144 145 146def make_training_figure(147    records: list[dict[str, Any]], paper_metric: float, best_epoch: int148) -> plt.Figure:149    epochs = np.asarray([record["epoch"] for record in records], dtype=np.int64)150    train_full = np.asarray(151        [record["train_full_relative_l2"] for record in records], dtype=np.float64152    )153    test_full = np.asarray(154        [record["test_full_relative_l2"] for record in records], dtype=np.float64155    )156    train_loss = np.asarray(157        [record["train_step_loss_sum"] for record in records], dtype=np.float64158    )159    train_step = np.asarray(160        [record["train_mean_step_relative_l2"] for record in records], dtype=np.float64161    )162    test_step = np.asarray(163        [record["test_mean_step_relative_l2"] for record in records], dtype=np.float64164    )165 166    figure, axes = plt.subplots(1, 2, figsize=(12.5, 4.8), constrained_layout=True)167    left = axes[0]168    left.plot(epochs, train_full, label="Train full relative L2", linewidth=1.6)169    left.plot(epochs, test_full, label="Test full relative L2", linewidth=1.6)170    left.axhline(171        paper_metric,172        color="black",173        linestyle="--",174        linewidth=1.2,175        label=f"Paper benchmark ({paper_metric:.4f})",176    )177    left.axvline(178        best_epoch,179        color="tab:green",180        linestyle=":",181        linewidth=1.2,182        label=f"Best checkpoint epoch ({best_epoch})",183    )184    if np.all(train_full > 0) and np.all(test_full > 0):185        left.set_yscale("log")186    left.set_xlabel("Epoch")187    left.set_ylabel("Full-trajectory relative L2")188    left.set_title("FNO-2D rollout error")189    left.grid(True, alpha=0.25)190    left.legend(fontsize=8)191 192    right = axes[1]193    loss_line = right.plot(194        epochs,195        train_loss,196        color="tab:blue",197        label="Train 10-step loss sum",198        linewidth=1.5,199    )200    right.set_xlabel("Epoch")201    right.set_ylabel("Summed step relative L2", color="tab:blue")202    right.tick_params(axis="y", labelcolor="tab:blue")203    right.grid(True, alpha=0.25)204    diagnostic = right.twinx()205    train_line = diagnostic.plot(206        epochs,207        train_step,208        color="tab:orange",209        label="Train mean-step relative L2",210        linewidth=1.3,211    )212    test_line = diagnostic.plot(213        epochs,214        test_step,215        color="tab:red",216        label="Test mean-step relative L2",217        linewidth=1.3,218    )219    diagnostic.set_ylabel("Mean-step relative L2")220    right.set_title("Training objective and step diagnostics")221    lines = loss_line + train_line + test_line222    right.legend(lines, [line.get_label() for line in lines], fontsize=8, loc="best")223    return figure224 225 226def representative_leads(number_of_steps: int) -> list[int]:227    if number_of_steps <= 0:228        raise ValueError("At least one rollout step is required")229    return sorted({0, number_of_steps // 2, number_of_steps - 1})230 231 232def make_rollout_figure(233    prediction: np.ndarray,234    target: np.ndarray,235    sample_indices: np.ndarray,236    time_values: np.ndarray,237    lead_metrics: np.ndarray,238    local_sample: int,239) -> plt.Figure:240    if not 0 <= local_sample < prediction.shape[0]:241        raise IndexError(242            f"sample-index {local_sample} is outside [0,{prediction.shape[0] - 1}]"243        )244    lead_indices = representative_leads(prediction.shape[-1])245    figure, axes = plt.subplots(246        len(lead_indices),247        3,248        figsize=(11.5, 3.25 * len(lead_indices)),249        squeeze=False,250        constrained_layout=True,251    )252    global_sample = int(sample_indices[local_sample])253    for row, lead_index in enumerate(lead_indices):254        truth = target[local_sample, :, :, lead_index]255        estimate = prediction[local_sample, :, :, lead_index]256        absolute_error = np.abs(estimate - truth)257        shared_limit = max(float(np.max(np.abs(truth))), float(np.max(np.abs(estimate))), 1e-12)258        error_limit = max(float(np.max(absolute_error)), 1e-12)259        time_value = int(time_values[lead_index])260        relative_error = float(lead_metrics[local_sample, lead_index])261 262        fields = (truth, estimate, absolute_error)263        titles = (264            f"Target vorticity w\nsample={global_sample}, t={time_value}",265            f"Predicted vorticity w\nrelative L2={relative_error:.5f}",266            f"Absolute error |prediction-target|\nt={time_value}",267        )268        for column, (field, title) in enumerate(zip(fields, titles)):269            axis = axes[row, column]270            if column < 2:271                image = axis.imshow(272                    field,273                    origin="lower",274                    extent=(0.0, 1.0, 0.0, 1.0),275                    interpolation="nearest",276                    cmap="RdBu_r",277                    vmin=-shared_limit,278                    vmax=shared_limit,279                )280                color_label = "Vorticity w (unit not specified)"281            else:282                image = axis.imshow(283                    field,284                    origin="lower",285                    extent=(0.0, 1.0, 0.0, 1.0),286                    interpolation="nearest",287                    cmap="magma",288                    vmin=0.0,289                    vmax=error_limit,290                )291                color_label = "Absolute error"292            axis.set_aspect("equal")293            axis.set_xlabel("x")294            axis.set_ylabel("y")295            axis.set_title(title, fontsize=9)296            colorbar = figure.colorbar(image, ax=axis, shrink=0.82)297            colorbar.set_label(color_label, fontsize=8)298    return figure299 300 301def main() -> None:302    args = parse_args()303    config = load_config(args.config)304    output_dir = (305        resolve_project_path(config["paths"]["results_dir"])306        if args.output_dir is None307        else args.output_dir.expanduser().resolve()308    )309    if args.output_dir is None:310        history_path = resolve_project_path(config["paths"]["train_history"])311        predictions_path = resolve_project_path(config["paths"]["predictions"])312        metrics_path = resolve_project_path(config["paths"]["metrics"])313        csv_path = resolve_project_path(config["paths"]["per_sample_metrics"])314        training_figure_path = resolve_project_path(config["paths"]["training_curves"])315        rollout_figure_path = resolve_project_path(config["paths"]["rollout_figure"])316        metadata_path = resolve_project_path(config["paths"]["run_metadata"])317        summary_path = resolve_project_path(config["paths"]["summary"])318    else:319        history_path = output_dir / "train_history.json"320        predictions_path = output_dir / "predictions.npz"321        metrics_path = output_dir / "metrics.json"322        csv_path = output_dir / "per_sample_metrics.csv"323        training_figure_path = output_dir / "training_curves.png"324        rollout_figure_path = output_dir / "sample_000_rollout.png"325        metadata_path = output_dir / "run_metadata.json"326        summary_path = output_dir / "summary.md"327 328    output_dir.mkdir(parents=True, exist_ok=True)329    history_payload = read_json(history_path)330    metrics_payload = read_json(metrics_path)331    if metrics_payload.get("run_type") == "formal" and args.output_dir is not None:332        raise ValueError("Formal result generation must use the configured results directory")333    if history_payload.get("run_type") != metrics_payload.get("run_type"):334        raise ValueError("Training history and inference metrics have different run types")335    records = validate_history(history_payload)336    if not predictions_path.is_file():337        raise FileNotFoundError(f"Predictions artifact is missing: {predictions_path}")338    with np.load(predictions_path, allow_pickle=False) as archive:339        required_arrays = {"prediction", "target", "sample_indices", "time_values"}340        missing_arrays = required_arrays.difference(archive.files)341        if missing_arrays:342            raise KeyError(f"Predictions NPZ is missing {sorted(missing_arrays)}")343        prediction = archive["prediction"]344        target = archive["target"]345        sample_indices = archive["sample_indices"]346        time_values = archive["time_values"]347 348    if prediction.dtype != np.float32 or target.dtype != np.float32:349        raise TypeError("Prediction and target arrays must be float32")350    if prediction.shape != target.shape or prediction.ndim != 4:351        raise ValueError(f"Invalid prediction/target shapes: {prediction.shape}, {target.shape}")352    if sample_indices.shape != (prediction.shape[0],):353        raise ValueError("sample_indices shape does not match predictions")354    if time_values.shape != (prediction.shape[-1],):355        raise ValueError("time_values shape does not match rollout horizon")356    if not np.array_equal(sample_indices, np.arange(sample_indices[0], sample_indices[0] + len(sample_indices))):357        raise ValueError("sample_indices must be unique, contiguous, and ordered")358    if not np.all(np.diff(time_values.astype(np.float64)) > 0):359        raise ValueError("time_values must be strictly increasing")360 361    formal = metrics_payload.get("run_type") == "formal"362    if formal:363        expected_shape = (364            int(config["data"]["ntest"]),365            int(config["data"]["resolution"][0]),366            int(config["data"]["resolution"][1]),367            int(config["data"]["horizon"]),368        )369        if prediction.shape != expected_shape:370            raise ValueError(f"Formal prediction shape must be {expected_shape}, got {prediction.shape}")371        expected_indices = np.arange(372            int(config["data"]["test_start"]),373            int(config["data"]["test_start"]) + int(config["data"]["ntest"]),374        )375        if not np.array_equal(sample_indices, expected_indices):376            raise ValueError("Formal sample indices do not match the fixed test split")377 378    epsilon = float(config["training"]["relative_l2_epsilon"])379    full_metrics, lead_metrics = compute_metrics(prediction, target, epsilon)380    observed_mean = float(metrics_payload["metric"]["full_trajectory_mean"])381    if not np.isclose(full_metrics.mean(), observed_mean, rtol=1e-8, atol=1e-8):382        raise ValueError(383            f"metrics.json full relative L2 mismatch: recomputed={full_metrics.mean()}, "384            f"stored={observed_mean}"385        )386    stored_leads = np.asarray(metrics_payload["metric"]["per_lead_mean"], dtype=np.float64)387    if not np.allclose(lead_metrics.mean(axis=0), stored_leads, rtol=1e-8, atol=1e-8):388        raise ValueError("metrics.json per-lead values do not match predictions")389    verify_csv(csv_path, sample_indices, full_metrics, lead_metrics, time_values)390 391    best_epoch = int(history_payload["best_epoch"])392    checkpoint_epoch = int(metrics_payload["checkpoint_epoch"])393    if best_epoch != checkpoint_epoch:394        raise ValueError(395            f"History best epoch {best_epoch} does not match checkpoint epoch {checkpoint_epoch}"396        )397    paper_metric = float(config["paper"]["reference_relative_l2"])398    training_figure = make_training_figure(records, paper_metric, best_epoch)399    atomic_save_figure(training_figure, training_figure_path, dpi=300)400    rollout_figure = make_rollout_figure(401        prediction,402        target,403        sample_indices,404        time_values,405        lead_metrics,406        args.sample_index,407    )408    atomic_save_figure(rollout_figure, rollout_figure_path, dpi=300)409 410    artifact_paths = {411        "train_history": history_path,412        "predictions": predictions_path,413        "metrics": metrics_path,414        "per_sample_metrics": csv_path,415        "training_curves": training_figure_path,416        "rollout_figure": rollout_figure_path,417    }418    artifact_metadata = {419        name: {420            "path": str(path),421            "size_bytes": path.stat().st_size,422            "sha256": sha256_file(path),423        }424        for name, path in artifact_paths.items()425    }426    main_metric = float(full_metrics.mean())427    run_metadata = {428        "schema_version": "fno-ns2d-run-metadata-v1",429        "created_at": datetime.now(timezone.utc).isoformat(),430        "run_type": metrics_payload.get("run_type"),431        "config_path": str(args.config.expanduser().resolve()),432        "data_path": metrics_payload["data_path"],433        "checkpoint_path": metrics_payload["checkpoint_path"],434        "checkpoint_epoch": checkpoint_epoch,435        "test_selected": metrics_payload["test_selected"],436        "split": {437            "train": int(history_payload.get("train_samples", 1000)),438            "validation": 0,439            "test": int(prediction.shape[0]),440        },441        "prediction_shape": list(prediction.shape),442        "normalization": config["data"]["normalization"],443        "metric_formula": metrics_payload["metric"]["formula"],444        "full_trajectory_relative_l2": main_metric,445        "paper_relative_l2": paper_metric,446        "signed_difference": main_metric - paper_metric,447        "absolute_difference": abs(main_metric - paper_metric),448        "parameter_count": metrics_payload["parameter_count"],449        "paper_parameter_count": metrics_payload["paper_parameter_count"],450        "parameter_count_difference": metrics_payload["parameter_count_difference"],451        "runtime": {**metrics_payload["runtime"], "matplotlib": matplotlib.__version__},452        "assumptions": config.get("assumptions", []),453        "conflicts": config.get("conflicts", []),454        "artifacts": artifact_metadata,455        "quality_checks": {456            "all_values_finite": True,457            "metrics_recomputed_from_npz": True,458            "json_metrics_match": True,459            "csv_metrics_match": True,460            "best_epoch_matches_checkpoint": True,461            "figures_nonempty": True,462        },463    }464    atomic_write_json(metadata_path, run_metadata)465 466    summary = f"""# FNO-2D Navier–Stokes reproduction result467 468## Summary469 470- Run type: `{metrics_payload.get('run_type')}`471- Test trajectories: {prediction.shape[0]}472- Forecast shape: `{list(prediction.shape)}`473- Mean full-trajectory relative L2: **{main_metric:.8f}**474- Paper FNO-2D reference (`ν=1e-5`, `T=20`, 1000 train): **{paper_metric:.4f}**475- Absolute difference: **{abs(main_metric-paper_metric):.8f}**476- Checkpoint epoch: {checkpoint_epoch}; selected by train full relative L2 (`test_selected=false`).477 478## Data and method479 480The model uses the fixed first 1000 trajectories for training and the final {prediction.shape[0]} trajectories for testing. Ten observed vorticity frames initialize a closed-loop rollout; every predicted frame updates the next input window. No target frame is used after initialization, and no data normalization, padding, augmentation, or PDE-residual loss is applied.481 482The reported metric is computed per sample as `||prediction-target||₂/(||target||₂+1e-12)` over the full space-time forecast and then averaged. It was recomputed directly from `predictions.npz` and cross-checked against JSON and CSV outputs.483 484## Reproducibility limitations485 486The paper does not specify the exact relative-L2 reduction, batch size, random seed, projection hidden width, coordinate-input choice, block ordering, or checkpoint-selection protocol. These choices are recorded explicitly in `config/config.yaml` and `run_metadata.json`. The paper width 32 also cannot be uniquely reconciled with the reported 414,517 parameters from the published connection details; the actual parameter count is reported rather than hidden.487 488## Artifacts489 490- `{training_figure_path.name}`: train/test rollout errors and step-loss diagnostics.491- `{rollout_figure_path.name}`: target, prediction, and absolute-error vorticity fields.492- `{predictions_path.name}`: full test prediction and target arrays.493- `{metrics_path.name}` and `{csv_path.name}`: aggregate and per-sample metrics.494- `{metadata_path.name}`: provenance, software, assumptions, hashes, and quality checks.495"""496    atomic_write_text(summary_path, summary)497    print(498        f"result_complete full_relative_l2={main_metric:.8f} "499        f"training_figure={training_figure_path} rollout_figure={rollout_figure_path}",500        flush=True,501    )502 503 504if __name__ == "__main__":505    main()506