Upload folder using huggingface_hub
Browse files- README.md +148 -0
- config/config.yaml +99 -0
- configuration.json +1 -0
- models/FNO.py +219 -0
- models/__init__.py +5 -0
- scripts/inference.py +392 -0
- scripts/result.py +505 -0
- scripts/train.py +763 -0
- weight/best_model.pth +3 -0
- weight/last_model.pth +3 -0
README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
tags:
|
| 6 |
+
- OneScience
|
| 7 |
+
- fluid-mechanics
|
| 8 |
+
- flow-field-prediction
|
| 9 |
+
- neural-operator
|
| 10 |
+
- Navier-Stokes
|
| 11 |
+
frameworks: PyTorch
|
| 12 |
+
---
|
| 13 |
+
<p align="center">
|
| 14 |
+
<strong>
|
| 15 |
+
<span style="font-size: 30px;">FNO</span>
|
| 16 |
+
</strong>
|
| 17 |
+
</p>
|
| 18 |
+
|
| 19 |
+
# Model Introduction
|
| 20 |
+
|
| 21 |
+
FNO (Fourier Neural Operator) is a class of neural operators for parameterized partial differential equations. It directly learns mappings from input functions to solution functions by parameterizing integral kernels in Fourier space. Using the OneScience skill workflow, this project independently reproduces the FNO-2D experiment for predicting the vorticity of two-dimensional incompressible Navier–Stokes flows.
|
| 22 |
+
|
| 23 |
+
Paper: [Fourier Neural Operator for Parametric Partial Differential Equations](https://arxiv.org/abs/2010.08895)
|
| 24 |
+
|
| 25 |
+
# Model Description
|
| 26 |
+
|
| 27 |
+
This implementation takes 10 consecutive 64 × 64 vorticity fields as input and autoregressively predicts the next 10 frames. It first maps the historical fields and two-dimensional periodic coordinates into a latent space of width 32, then applies four Fourier layers. Each layer retains 12 Fourier modes along each spatial dimension, adds a 1 × 1 local convolution to the spectral convolution, and applies BatchNorm and ReLU. A 32 → 128 → 1 projection head generates the vorticity field for the next time step. Each prediction is appended to the input window for closed-loop rollout inference.
|
| 28 |
+
|
| 29 |
+
## Use Cases
|
| 30 |
+
|
| 31 |
+
| Use case | Description |
|
| 32 |
+
| --- | --- |
|
| 33 |
+
| Parameterized PDE operator learning | Learns mappings from PDE parameters, coefficient fields, or initial conditions to solution fields, especially when a PDE must be solved repeatedly for many parameter settings. |
|
| 34 |
+
| Burgers' equation prediction | Predicts future states from initial conditions for the one-dimensional Burgers' equation, demonstrating operator learning for nonlinear evolution equations. |
|
| 35 |
+
| Darcy flow prediction | Predicts steady-state solutions from two-dimensional diffusion or permeability coefficient fields for applications such as porous-media flow and groundwater seepage. |
|
| 36 |
+
| Navier–Stokes flow prediction | Autoregressively predicts the evolution of two-dimensional incompressible flow from historical vorticity fields. |
|
| 37 |
+
|
| 38 |
+
# Usage
|
| 39 |
+
|
| 40 |
+
## 1. Using OneCode
|
| 41 |
+
|
| 42 |
+
Try intelligent, one-click AI4S programming in the OneCode online environment:
|
| 43 |
+
|
| 44 |
+
[Try intelligent, one-click AI4S programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
|
| 45 |
+
|
| 46 |
+
## 2. Manual Installation and Usage
|
| 47 |
+
|
| 48 |
+
**Hardware requirements**
|
| 49 |
+
|
| 50 |
+
- A GPU or DCU is recommended.
|
| 51 |
+
- A CPU can be used for import checks and small-scale connectivity tests, but full training and inference will be slow.
|
| 52 |
+
- DCU users must install DTK in advance. DTK 25.04.2 or later, or the OneScience-recommended version for the current cluster, is recommended.
|
| 53 |
+
|
| 54 |
+
### Download the Model Package
|
| 55 |
+
|
| 56 |
+
```bash
|
| 57 |
+
modelscope download --model OneScience/FNO --local_dir ./FNO
|
| 58 |
+
cd FNO
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
### Set Up the Runtime Environment
|
| 62 |
+
|
| 63 |
+
**DCU environment**
|
| 64 |
+
|
| 65 |
+
```bash
|
| 66 |
+
# Activate DTK and Conda first
|
| 67 |
+
conda create -n onescience311 python=3.11 -y
|
| 68 |
+
conda activate onescience311
|
| 69 |
+
# Installation with uv is also supported
|
| 70 |
+
pip install onescience[cfd-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
**GPU environment**
|
| 74 |
+
|
| 75 |
+
```bash
|
| 76 |
+
# Activate Conda first
|
| 77 |
+
conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
|
| 78 |
+
conda activate onescience311
|
| 79 |
+
# Installation with uv is also supported
|
| 80 |
+
pip install onescience[cfd-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
### Training Data
|
| 84 |
+
|
| 85 |
+
The experiment uses `NavierStokes_V1e-5_N1200_T20.mat`, whose shape is `[N,H,W,T]=[1200,64,64,20]`. For each trajectory, the first 10 frames are inputs and the final 10 frames are prediction targets. The first 1,000 trajectories are used for training and the remaining 200 for testing. There is no separate validation set, and no normalization is applied.
|
| 86 |
+
|
| 87 |
+
Download the data with:
|
| 88 |
+
|
| 89 |
+
```bash
|
| 90 |
+
modelscope download --dataset OneScience/fno --local_dir ./data
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
After downloading, set `data.root` in `config/config.yaml` to the data directory and confirm that `data.file` matches the MAT filename above. The training script strictly validates the field name, shape, dtype, and finite values.
|
| 94 |
+
|
| 95 |
+
### Training
|
| 96 |
+
|
| 97 |
+
The default configuration corresponds to the FNO-2D experiment with `ν=1e-5` and `T=20` in the paper. It trains for 500 epochs with a batch size of 20, uses Adam with an initial learning rate of `1e-3`, and halves the learning rate every 100 epochs.
|
| 98 |
+
|
| 99 |
+
```bash
|
| 100 |
+
python scripts/train.py --config config/config.yaml --device auto
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
The weights with the best full-trajectory relative L2 error on the training set are saved to `weight/best_model.pth`. The latest complete training state from every epoch is saved to `weight/last_model.pth`, and the training history is written to `results/train_history.json`.
|
| 104 |
+
|
| 105 |
+
### Trained Weights
|
| 106 |
+
|
| 107 |
+
`weight/best_model.pth` contains the best weights from the full training run and can be used directly for inference.
|
| 108 |
+
|
| 109 |
+
### Inference
|
| 110 |
+
|
| 111 |
+
Before running inference, make sure the configured data path is valid and `weight/best_model.pth` exists. The standard inference run uses the 200 configured test trajectories and a 10-step closed-loop rollout, printing batch progress and the final relative L2 error in real time:
|
| 112 |
+
|
| 113 |
+
```bash
|
| 114 |
+
python scripts/inference.py --config config/config.yaml
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
Inference outputs are saved as:
|
| 118 |
+
|
| 119 |
+
- `results/predictions.npz`: predictions, ground truth, sample indices, and prediction times;
|
| 120 |
+
- `results/metrics.json`: overall and per-step metrics, including comparisons with the reference values from the paper;
|
| 121 |
+
- `results/per_sample_metrics.csv`: per-sample relative L2 errors.
|
| 122 |
+
|
| 123 |
+
### Evaluation and Visualization
|
| 124 |
+
|
| 125 |
+
After training and inference, run:
|
| 126 |
+
|
| 127 |
+
```bash
|
| 128 |
+
python scripts/result.py --config config/config.yaml --sample-index 0
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
The script recomputes metrics from `predictions.npz`, cross-checks the JSON and CSV outputs, best epoch, and prediction shape, and then generates:
|
| 132 |
+
|
| 133 |
+
- `results/training_curves.png`: training/test errors and training-objective curves;
|
| 134 |
+
- `results/sample_000_rollout.png`: ground truth, predictions, and absolute errors at `t=11, 16, 20` for a representative sample;
|
| 135 |
+
- `results/run_metadata.json`: configuration, runtime environment, assumptions, file hashes, and quality checks;
|
| 136 |
+
- `results/summary.md`: a summary of experimental results.
|
| 137 |
+
|
| 138 |
+
# Official OneScience Resources
|
| 139 |
+
|
| 140 |
+
| Platform | OneScience Main Repository | Skills Repository |
|
| 141 |
+
| --- | --- | --- |
|
| 142 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 143 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 144 |
+
|
| 145 |
+
# Citation and License
|
| 146 |
+
|
| 147 |
+
- Paper: [Fourier Neural Operator for Parametric Partial Differential Equations](https://arxiv.org/abs/2010.08895)
|
| 148 |
+
- The code in this model package is licensed under the MIT License. Use of the model weights is also subject to the licenses and applicable terms of the training data and third-party dependencies.
|
config/config.yaml
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
paper:
|
| 2 |
+
title: "Fourier Neural Operator for Parametric Partial Differential Equations"
|
| 3 |
+
arxiv: "2010.08895"
|
| 4 |
+
experiment: "FNO-2D Navier-Stokes, nu=1e-5, T=20"
|
| 5 |
+
viscosity: 1.0e-5
|
| 6 |
+
reference_relative_l2: 0.1556
|
| 7 |
+
reference_parameter_count: 414517
|
| 8 |
+
reference_epoch_seconds_v100: 127.80
|
| 9 |
+
|
| 10 |
+
data:
|
| 11 |
+
root: "/public/share/sugonhpcapp01/onestore/onedatasets/FNO_data"
|
| 12 |
+
file: "NavierStokes_V1e-5_N1200_T20.mat"
|
| 13 |
+
key: "u"
|
| 14 |
+
layout: "N,H,W,T"
|
| 15 |
+
dtype: "float32"
|
| 16 |
+
expected_shape: [1200, 64, 64, 20]
|
| 17 |
+
resolution: [64, 64]
|
| 18 |
+
ntrain: 1000
|
| 19 |
+
ntest: 200
|
| 20 |
+
train_start: 0
|
| 21 |
+
test_start: 1000
|
| 22 |
+
history: 10
|
| 23 |
+
horizon: 10
|
| 24 |
+
recording_interval: 1.0
|
| 25 |
+
future_times: [11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
|
| 26 |
+
normalization: "none"
|
| 27 |
+
|
| 28 |
+
model:
|
| 29 |
+
name: "FNO2d"
|
| 30 |
+
input_channels: 10
|
| 31 |
+
output_channels: 1
|
| 32 |
+
use_grid: true
|
| 33 |
+
grid_channels: 2
|
| 34 |
+
grid_include_endpoint: false
|
| 35 |
+
width: 32
|
| 36 |
+
modes1: 12
|
| 37 |
+
modes2: 12
|
| 38 |
+
num_layers: 4
|
| 39 |
+
projection_width: 128
|
| 40 |
+
activation: "relu"
|
| 41 |
+
normalization: "batch_norm"
|
| 42 |
+
block_order: "relu(batch_norm(spectral_plus_pointwise))"
|
| 43 |
+
fft_norm: "backward"
|
| 44 |
+
spectral_init: "scaled_uniform_complex"
|
| 45 |
+
|
| 46 |
+
training:
|
| 47 |
+
epochs: 500
|
| 48 |
+
batch_size: 20
|
| 49 |
+
optimizer: "adam"
|
| 50 |
+
learning_rate: 0.001
|
| 51 |
+
weight_decay: 0.0
|
| 52 |
+
scheduler: "step_lr"
|
| 53 |
+
scheduler_step_size: 100
|
| 54 |
+
scheduler_gamma: 0.5
|
| 55 |
+
seed: 0
|
| 56 |
+
dtype: "float32"
|
| 57 |
+
amp: false
|
| 58 |
+
gradient_clipping: null
|
| 59 |
+
ema: false
|
| 60 |
+
distributed: "single"
|
| 61 |
+
num_workers: 0
|
| 62 |
+
pin_memory: true
|
| 63 |
+
deterministic: true
|
| 64 |
+
relative_l2_epsilon: 1.0e-12
|
| 65 |
+
train_rollout_steps: 10
|
| 66 |
+
evaluation_rollout_steps: 10
|
| 67 |
+
checkpoint_monitor: "train_full_relative_l2"
|
| 68 |
+
checkpoint_mode: "min"
|
| 69 |
+
evaluate_test_every_epoch: true
|
| 70 |
+
|
| 71 |
+
inference:
|
| 72 |
+
batch_size: 20
|
| 73 |
+
seed: 0
|
| 74 |
+
dtype: "float32"
|
| 75 |
+
rollout_steps: 10
|
| 76 |
+
|
| 77 |
+
paths:
|
| 78 |
+
checkpoint: "weight/best_model.pth"
|
| 79 |
+
results_dir: "results"
|
| 80 |
+
train_history: "results/train_history.json"
|
| 81 |
+
predictions: "results/predictions.npz"
|
| 82 |
+
metrics: "results/metrics.json"
|
| 83 |
+
per_sample_metrics: "results/per_sample_metrics.csv"
|
| 84 |
+
training_curves: "results/training_curves.png"
|
| 85 |
+
rollout_figure: "results/sample_000_rollout.png"
|
| 86 |
+
run_metadata: "results/run_metadata.json"
|
| 87 |
+
summary: "results/summary.md"
|
| 88 |
+
|
| 89 |
+
assumptions:
|
| 90 |
+
- "The paper does not specify a validation split; the best checkpoint is selected using train full-trajectory relative L2, never test error."
|
| 91 |
+
- "The paper does not specify batch size or seed; batch_size=20 and seed=0 are explicit engineering assumptions."
|
| 92 |
+
- "The paper does not define the exact relative-L2 reduction; ratios are computed per sample and then averaged with epsilon=1e-12."
|
| 93 |
+
- "The paper does not specify the projection hidden width; projection_width=128 is configurable."
|
| 94 |
+
- "Coordinate-grid input is configurable and enabled; the periodic grid excludes the duplicated endpoint."
|
| 95 |
+
- "The block ordering is ReLU(BatchNorm(spectral + pointwise)); the paper states ReLU and batch normalization but not their exact order."
|
| 96 |
+
- "Adam uses weight_decay=0.0 because the paper does not state an additional weight-decay regularizer."
|
| 97 |
+
|
| 98 |
+
conflicts:
|
| 99 |
+
- "The paper states d_v=32 but reports 414,517 parameters without enough connection details to reproduce both uniquely. Width 32 takes precedence and the actual parameter count must be reported."
|
configuration.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"framework":"Jax","task":"other"}
|
models/FNO.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Paper-driven Fourier Neural Operator for 2-D Navier--Stokes rollout.
|
| 2 |
+
|
| 3 |
+
This is an independent implementation of Equations (2), (4), and (5) in
|
| 4 |
+
arXiv:2010.08895. It does not copy the authors' repository implementation.
|
| 5 |
+
The public interface is channel-last because the ten history frames are the
|
| 6 |
+
input function features; Fourier blocks operate channel-first internally.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from collections.abc import Mapping
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
import torch
|
| 15 |
+
from torch import Tensor, nn
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class SpectralConv2d(nn.Module):
|
| 19 |
+
"""Truncated 2-D Fourier integral operator on a periodic grid."""
|
| 20 |
+
|
| 21 |
+
def __init__(
|
| 22 |
+
self,
|
| 23 |
+
in_channels: int,
|
| 24 |
+
out_channels: int,
|
| 25 |
+
modes1: int,
|
| 26 |
+
modes2: int,
|
| 27 |
+
fft_norm: str = "backward",
|
| 28 |
+
) -> None:
|
| 29 |
+
super().__init__()
|
| 30 |
+
if min(in_channels, out_channels, modes1, modes2) <= 0:
|
| 31 |
+
raise ValueError("channels and retained Fourier modes must be positive")
|
| 32 |
+
if fft_norm not in {"backward", "forward", "ortho"}:
|
| 33 |
+
raise ValueError(f"Unsupported FFT normalization: {fft_norm}")
|
| 34 |
+
|
| 35 |
+
self.in_channels = int(in_channels)
|
| 36 |
+
self.out_channels = int(out_channels)
|
| 37 |
+
self.modes1 = int(modes1)
|
| 38 |
+
self.modes2 = int(modes2)
|
| 39 |
+
self.fft_norm = fft_norm
|
| 40 |
+
|
| 41 |
+
# MISSING in the paper: exact complex-weight initialization. The
|
| 42 |
+
# scale is explicit and seed-controlled by the caller's torch seed.
|
| 43 |
+
scale = 1.0 / (self.in_channels * self.out_channels)
|
| 44 |
+
shape = (self.in_channels, self.out_channels, self.modes1, self.modes2)
|
| 45 |
+
self.weight_positive = nn.Parameter(
|
| 46 |
+
scale * torch.complex(torch.rand(shape), torch.rand(shape))
|
| 47 |
+
)
|
| 48 |
+
self.weight_negative = nn.Parameter(
|
| 49 |
+
scale * torch.complex(torch.rand(shape), torch.rand(shape))
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
@staticmethod
|
| 53 |
+
def _multiply_modes(inputs: Tensor, weights: Tensor) -> Tensor:
|
| 54 |
+
return torch.einsum("bixy,ioxy->boxy", inputs, weights)
|
| 55 |
+
|
| 56 |
+
def forward(self, inputs: Tensor) -> Tensor:
|
| 57 |
+
if inputs.ndim != 4:
|
| 58 |
+
raise ValueError(f"SpectralConv2d expects [B,C,H,W], got {inputs.shape}")
|
| 59 |
+
batch, channels, height, width = inputs.shape
|
| 60 |
+
if channels != self.in_channels:
|
| 61 |
+
raise ValueError(
|
| 62 |
+
f"Expected {self.in_channels} channels, received {channels}"
|
| 63 |
+
)
|
| 64 |
+
if 2 * self.modes1 > height:
|
| 65 |
+
raise ValueError(
|
| 66 |
+
f"modes1={self.modes1} overlaps positive/negative bands for H={height}"
|
| 67 |
+
)
|
| 68 |
+
if self.modes2 > width // 2 + 1:
|
| 69 |
+
raise ValueError(
|
| 70 |
+
f"modes2={self.modes2} exceeds rFFT width {width // 2 + 1}"
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
spectrum = torch.fft.rfft2(inputs, norm=self.fft_norm)
|
| 74 |
+
output_spectrum = torch.zeros(
|
| 75 |
+
batch,
|
| 76 |
+
self.out_channels,
|
| 77 |
+
height,
|
| 78 |
+
width // 2 + 1,
|
| 79 |
+
device=inputs.device,
|
| 80 |
+
dtype=spectrum.dtype,
|
| 81 |
+
)
|
| 82 |
+
positive_weight = self.weight_positive.to(dtype=spectrum.dtype)
|
| 83 |
+
negative_weight = self.weight_negative.to(dtype=spectrum.dtype)
|
| 84 |
+
output_spectrum[:, :, : self.modes1, : self.modes2] = self._multiply_modes(
|
| 85 |
+
spectrum[:, :, : self.modes1, : self.modes2], positive_weight
|
| 86 |
+
)
|
| 87 |
+
output_spectrum[:, :, -self.modes1 :, : self.modes2] = self._multiply_modes(
|
| 88 |
+
spectrum[:, :, -self.modes1 :, : self.modes2], negative_weight
|
| 89 |
+
)
|
| 90 |
+
return torch.fft.irfft2(
|
| 91 |
+
output_spectrum, s=(height, width), norm=self.fft_norm
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class FourierBlock2d(nn.Module):
|
| 96 |
+
"""One paper Fourier layer with local W, batch norm, and ReLU."""
|
| 97 |
+
|
| 98 |
+
def __init__(self, width: int, modes1: int, modes2: int, fft_norm: str) -> None:
|
| 99 |
+
super().__init__()
|
| 100 |
+
self.spectral = SpectralConv2d(width, width, modes1, modes2, fft_norm)
|
| 101 |
+
self.pointwise = nn.Conv2d(width, width, kernel_size=1)
|
| 102 |
+
self.batch_norm = nn.BatchNorm2d(width)
|
| 103 |
+
self.activation = nn.ReLU()
|
| 104 |
+
|
| 105 |
+
def forward(self, inputs: Tensor) -> Tensor:
|
| 106 |
+
return self.activation(
|
| 107 |
+
self.batch_norm(self.spectral(inputs) + self.pointwise(inputs))
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class FNO2d(nn.Module):
|
| 112 |
+
"""Four-layer FNO-2D mapping ten vorticity frames to the next frame."""
|
| 113 |
+
|
| 114 |
+
def __init__(
|
| 115 |
+
self,
|
| 116 |
+
input_channels: int = 10,
|
| 117 |
+
output_channels: int = 1,
|
| 118 |
+
width: int = 32,
|
| 119 |
+
modes1: int = 12,
|
| 120 |
+
modes2: int = 12,
|
| 121 |
+
num_layers: int = 4,
|
| 122 |
+
projection_width: int = 128,
|
| 123 |
+
use_grid: bool = True,
|
| 124 |
+
grid_include_endpoint: bool = False,
|
| 125 |
+
expected_resolution: tuple[int, int] = (64, 64),
|
| 126 |
+
fft_norm: str = "backward",
|
| 127 |
+
) -> None:
|
| 128 |
+
super().__init__()
|
| 129 |
+
if num_layers != 4:
|
| 130 |
+
raise ValueError(
|
| 131 |
+
f"The paper reproduction requires four Fourier layers, got {num_layers}"
|
| 132 |
+
)
|
| 133 |
+
if len(expected_resolution) != 2 or min(expected_resolution) <= 0:
|
| 134 |
+
raise ValueError("expected_resolution must contain two positive dimensions")
|
| 135 |
+
if min(input_channels, output_channels, width, projection_width) <= 0:
|
| 136 |
+
raise ValueError("model widths and channel counts must be positive")
|
| 137 |
+
|
| 138 |
+
self.input_channels = int(input_channels)
|
| 139 |
+
self.output_channels = int(output_channels)
|
| 140 |
+
self.width = int(width)
|
| 141 |
+
self.modes1 = int(modes1)
|
| 142 |
+
self.modes2 = int(modes2)
|
| 143 |
+
self.num_layers = int(num_layers)
|
| 144 |
+
self.projection_width = int(projection_width)
|
| 145 |
+
self.use_grid = bool(use_grid)
|
| 146 |
+
self.grid_include_endpoint = bool(grid_include_endpoint)
|
| 147 |
+
self.expected_resolution = tuple(int(value) for value in expected_resolution)
|
| 148 |
+
|
| 149 |
+
lifting_channels = self.input_channels + (2 if self.use_grid else 0)
|
| 150 |
+
self.lifting = nn.Linear(lifting_channels, self.width)
|
| 151 |
+
self.fourier_blocks = nn.ModuleList(
|
| 152 |
+
[
|
| 153 |
+
FourierBlock2d(self.width, self.modes1, self.modes2, fft_norm)
|
| 154 |
+
for _ in range(self.num_layers)
|
| 155 |
+
]
|
| 156 |
+
)
|
| 157 |
+
self.projection_hidden = nn.Linear(self.width, self.projection_width)
|
| 158 |
+
self.projection_activation = nn.ReLU()
|
| 159 |
+
self.projection_output = nn.Linear(
|
| 160 |
+
self.projection_width, self.output_channels
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
def _grid(self, batch: int, height: int, width: int, inputs: Tensor) -> Tensor:
|
| 164 |
+
if self.grid_include_endpoint:
|
| 165 |
+
x = torch.linspace(0.0, 1.0, height, device=inputs.device, dtype=inputs.dtype)
|
| 166 |
+
y = torch.linspace(0.0, 1.0, width, device=inputs.device, dtype=inputs.dtype)
|
| 167 |
+
else:
|
| 168 |
+
x = torch.arange(height, device=inputs.device, dtype=inputs.dtype) / height
|
| 169 |
+
y = torch.arange(width, device=inputs.device, dtype=inputs.dtype) / width
|
| 170 |
+
grid_x, grid_y = torch.meshgrid(x, y, indexing="ij")
|
| 171 |
+
grid = torch.stack((grid_x, grid_y), dim=-1)
|
| 172 |
+
return grid.unsqueeze(0).expand(batch, -1, -1, -1)
|
| 173 |
+
|
| 174 |
+
def forward(self, inputs: Tensor) -> Tensor:
|
| 175 |
+
if inputs.ndim != 4:
|
| 176 |
+
raise ValueError(f"FNO2d expects [B,H,W,T_history], got {inputs.shape}")
|
| 177 |
+
batch, height, width, features = inputs.shape
|
| 178 |
+
if features != self.input_channels:
|
| 179 |
+
raise ValueError(
|
| 180 |
+
f"Expected {self.input_channels} history channels, received {features}"
|
| 181 |
+
)
|
| 182 |
+
if (height, width) != self.expected_resolution:
|
| 183 |
+
raise ValueError(
|
| 184 |
+
f"Expected resolution {self.expected_resolution}, received {(height, width)}"
|
| 185 |
+
)
|
| 186 |
+
if not inputs.is_floating_point():
|
| 187 |
+
raise TypeError(f"FNO2d expects floating-point input, got {inputs.dtype}")
|
| 188 |
+
|
| 189 |
+
lifted_inputs = inputs
|
| 190 |
+
if self.use_grid:
|
| 191 |
+
lifted_inputs = torch.cat(
|
| 192 |
+
(inputs, self._grid(batch, height, width, inputs)), dim=-1
|
| 193 |
+
)
|
| 194 |
+
hidden = self.lifting(lifted_inputs).permute(0, 3, 1, 2).contiguous()
|
| 195 |
+
for block in self.fourier_blocks:
|
| 196 |
+
hidden = block(hidden)
|
| 197 |
+
hidden = hidden.permute(0, 2, 3, 1).contiguous()
|
| 198 |
+
hidden = self.projection_activation(self.projection_hidden(hidden))
|
| 199 |
+
return self.projection_output(hidden)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def build_model_from_config(config: Mapping[str, Any]) -> FNO2d:
|
| 203 |
+
"""Construct the exact paper-reproduction model from a parsed YAML mapping."""
|
| 204 |
+
model = config["model"]
|
| 205 |
+
data = config["data"]
|
| 206 |
+
resolution = tuple(int(value) for value in data["resolution"])
|
| 207 |
+
return FNO2d(
|
| 208 |
+
input_channels=int(model["input_channels"]),
|
| 209 |
+
output_channels=int(model["output_channels"]),
|
| 210 |
+
width=int(model["width"]),
|
| 211 |
+
modes1=int(model["modes1"]),
|
| 212 |
+
modes2=int(model["modes2"]),
|
| 213 |
+
num_layers=int(model["num_layers"]),
|
| 214 |
+
projection_width=int(model["projection_width"]),
|
| 215 |
+
use_grid=bool(model["use_grid"]),
|
| 216 |
+
grid_include_endpoint=bool(model.get("grid_include_endpoint", False)),
|
| 217 |
+
expected_resolution=resolution,
|
| 218 |
+
fft_norm=str(model.get("fft_norm", "backward")),
|
| 219 |
+
)
|
models/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FNO-2D model exports."""
|
| 2 |
+
|
| 3 |
+
from .FNO import FNO2d, SpectralConv2d, build_model_from_config
|
| 4 |
+
|
| 5 |
+
__all__ = ["FNO2d", "SpectralConv2d", "build_model_from_config"]
|
scripts/inference.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Run strict closed-loop inference for the trained FNO-2D checkpoint."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import csv
|
| 8 |
+
import hashlib
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
import sys
|
| 12 |
+
import time
|
| 13 |
+
from datetime import datetime, timezone
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
import torch
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 22 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 23 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 24 |
+
if str(Path(__file__).resolve().parent) not in sys.path:
|
| 25 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 26 |
+
|
| 27 |
+
from models import build_model_from_config # noqa: E402
|
| 28 |
+
from train import ( # noqa: E402
|
| 29 |
+
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 |
+
|
| 42 |
+
def 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 |
+
|
| 58 |
+
def 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 checkpoint
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def nested_value(mapping: dict[str, Any], dotted_key: str) -> Any:
|
| 86 |
+
value: Any = mapping
|
| 87 |
+
for key in dotted_key.split("."):
|
| 88 |
+
value = value[key]
|
| 89 |
+
return value
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def 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 |
+
|
| 130 |
+
def 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 |
+
|
| 138 |
+
def compute_metrics(
|
| 139 |
+
prediction: np.ndarray, target: np.ndarray, epsilon: float
|
| 140 |
+
) -> 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], -1
|
| 156 |
+
)
|
| 157 |
+
target_by_time = np.moveaxis(target64, -1, 1).reshape(
|
| 158 |
+
target.shape[0], target.shape[-1], -1
|
| 159 |
+
)
|
| 160 |
+
per_lead = np.linalg.norm(difference_by_time, axis=2) / (
|
| 161 |
+
np.linalg.norm(target_by_time, axis=2) + epsilon
|
| 162 |
+
)
|
| 163 |
+
return full, per_lead
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def 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 |
+
|
| 174 |
+
def 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_values
|
| 185 |
+
]
|
| 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 |
+
|
| 197 |
+
def 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 None
|
| 208 |
+
else args.checkpoint.expanduser().resolve()
|
| 209 |
+
)
|
| 210 |
+
output_dir = (
|
| 211 |
+
resolve_project_path(config["paths"]["results_dir"])
|
| 212 |
+
if args.output_dir is None
|
| 213 |
+
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 None
|
| 222 |
+
or args.rollout_steps is not None
|
| 223 |
+
or args.output_dir is not None
|
| 224 |
+
or args.checkpoint is not None
|
| 225 |
+
):
|
| 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_size
|
| 243 |
+
)
|
| 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 = 0
|
| 271 |
+
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 = history
|
| 276 |
+
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() - started
|
| 296 |
+
|
| 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.int64
|
| 315 |
+
)
|
| 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_count
|
| 374 |
+
- 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 |
+
|
| 391 |
+
if __name__ == "__main__":
|
| 392 |
+
main()
|
scripts/result.py
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Validate real FNO outputs and render paper-comparison figures."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import csv
|
| 8 |
+
import hashlib
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
import sys
|
| 12 |
+
from datetime import datetime, timezone
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
import matplotlib
|
| 17 |
+
|
| 18 |
+
matplotlib.use("Agg")
|
| 19 |
+
import matplotlib.pyplot as plt # noqa: E402
|
| 20 |
+
import numpy as np # noqa: E402
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 24 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 25 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 26 |
+
if str(Path(__file__).resolve().parent) not in sys.path:
|
| 27 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 28 |
+
|
| 29 |
+
from inference import compute_metrics # noqa: E402
|
| 30 |
+
from train import atomic_write_json, load_config, resolve_project_path # noqa: E402
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def 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 |
+
|
| 47 |
+
def 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 payload
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def 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 |
+
|
| 65 |
+
def 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 |
+
|
| 75 |
+
def 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 |
+
|
| 83 |
+
def 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_values
|
| 94 |
+
]
|
| 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 |
+
|
| 112 |
+
def 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 records
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def make_training_figure(
|
| 147 |
+
records: list[dict[str, Any]], paper_metric: float, best_epoch: int
|
| 148 |
+
) -> 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.float64
|
| 152 |
+
)
|
| 153 |
+
test_full = np.asarray(
|
| 154 |
+
[record["test_full_relative_l2"] for record in records], dtype=np.float64
|
| 155 |
+
)
|
| 156 |
+
train_loss = np.asarray(
|
| 157 |
+
[record["train_step_loss_sum"] for record in records], dtype=np.float64
|
| 158 |
+
)
|
| 159 |
+
train_step = np.asarray(
|
| 160 |
+
[record["train_mean_step_relative_l2"] for record in records], dtype=np.float64
|
| 161 |
+
)
|
| 162 |
+
test_step = np.asarray(
|
| 163 |
+
[record["test_mean_step_relative_l2"] for record in records], dtype=np.float64
|
| 164 |
+
)
|
| 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_line
|
| 222 |
+
right.legend(lines, [line.get_label() for line in lines], fontsize=8, loc="best")
|
| 223 |
+
return figure
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def 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 |
+
|
| 232 |
+
def 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 figure
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def 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 None
|
| 307 |
+
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 result
|
| 467 |
+
|
| 468 |
+
## Summary
|
| 469 |
+
|
| 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 method
|
| 479 |
+
|
| 480 |
+
The 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 |
+
|
| 482 |
+
The 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 limitations
|
| 485 |
+
|
| 486 |
+
The 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 |
+
## Artifacts
|
| 489 |
+
|
| 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 |
+
|
| 504 |
+
if __name__ == "__main__":
|
| 505 |
+
main()
|
scripts/train.py
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Train the paper-specified recurrent FNO-2D Navier--Stokes model."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import platform
|
| 10 |
+
import random
|
| 11 |
+
import sys
|
| 12 |
+
import time
|
| 13 |
+
from copy import deepcopy
|
| 14 |
+
from datetime import datetime, timezone
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
import scipy
|
| 20 |
+
import scipy.io
|
| 21 |
+
import torch
|
| 22 |
+
import yaml
|
| 23 |
+
from torch import Tensor, nn
|
| 24 |
+
from torch.utils.data import DataLoader, TensorDataset
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 28 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 29 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 30 |
+
|
| 31 |
+
from models import build_model_from_config # noqa: E402
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def 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 |
+
|
| 72 |
+
def 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 config
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def 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 |
+
|
| 162 |
+
def 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 |
+
|
| 167 |
+
def 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 path
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def 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 trajectories
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def 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 |
+
|
| 234 |
+
def 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 = deterministic
|
| 242 |
+
torch.backends.cudnn.benchmark = not deterministic
|
| 243 |
+
if deterministic:
|
| 244 |
+
torch.use_deterministic_algorithms(True, warn_only=True)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def 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 device
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def 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=1
|
| 263 |
+
)
|
| 264 |
+
denominator = torch.linalg.vector_norm(target.reshape(target.shape[0], -1), dim=1)
|
| 265 |
+
return difference / (denominator + epsilon)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def 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 = history
|
| 277 |
+
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_loss
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def 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 |
+
|
| 317 |
+
def 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 None
|
| 325 |
+
model.train(training)
|
| 326 |
+
total_samples = 0
|
| 327 |
+
total_step_ratio = 0.0
|
| 328 |
+
total_full_ratio = 0.0
|
| 329 |
+
steps_per_sample: int | None = None
|
| 330 |
+
|
| 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, epsilon
|
| 340 |
+
)
|
| 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_samples
|
| 353 |
+
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 |
+
|
| 368 |
+
def synchronize(device: torch.device) -> None:
|
| 369 |
+
if device.type == "cuda" and torch.cuda.is_available():
|
| 370 |
+
torch.cuda.synchronize(device)
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def 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 |
+
|
| 390 |
+
def 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 |
+
|
| 399 |
+
def 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 |
+
|
| 406 |
+
def 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 checkpoint
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
def 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 state
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def 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 |
+
|
| 460 |
+
def 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 None
|
| 476 |
+
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 None
|
| 493 |
+
else args.output_dir.expanduser().resolve()
|
| 494 |
+
)
|
| 495 |
+
checkpoint_path = (
|
| 496 |
+
resolve_project_path(config["paths"]["checkpoint"])
|
| 497 |
+
if args.checkpoint is None
|
| 498 |
+
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 None
|
| 504 |
+
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 pointwise
|
| 534 |
+
# 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 = 1
|
| 574 |
+
resume_exact_rng = True
|
| 575 |
+
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 = False
|
| 620 |
+
|
| 621 |
+
discarded_records = len(records) - resume_epoch
|
| 622 |
+
history_payload = previous_history
|
| 623 |
+
history_payload["history"] = retained_records
|
| 624 |
+
history_payload["completed_at"] = None
|
| 625 |
+
history_payload["completed"] = False
|
| 626 |
+
history_payload["epochs_requested"] = epochs
|
| 627 |
+
history_payload["resume_exact_rng"] = resume_exact_rng
|
| 628 |
+
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_metric
|
| 639 |
+
start_epoch = resume_epoch + 1
|
| 640 |
+
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_start
|
| 663 |
+
peak_memory_bytes = (
|
| 664 |
+
int(torch.cuda.max_memory_allocated(device))
|
| 665 |
+
if device.type == "cuda" and torch.cuda.is_available()
|
| 666 |
+
else 0
|
| 667 |
+
)
|
| 668 |
+
|
| 669 |
+
monitor = float(train_metrics["full_relative_l2"])
|
| 670 |
+
is_best = monitor < best_metric
|
| 671 |
+
if is_best:
|
| 672 |
+
best_metric = monitor
|
| 673 |
+
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_metric
|
| 717 |
+
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"] = True
|
| 754 |
+
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 |
+
|
| 762 |
+
if __name__ == "__main__":
|
| 763 |
+
main()
|
weight/best_model.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4a99344d653c72dd75cda2625181964c9f83ce3b2b4ad132aa2373a0a86ba883
|
| 3 |
+
size 28489073
|
weight/last_model.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f51c51bb0b78668aaff0a530ed5ed699a4562c0cdd2577c83a4770a6105f56d0
|
| 3 |
+
size 28489137
|