Upload folder using huggingface_hub
Browse files- README.md +142 -0
- config/config.yaml +81 -0
- configuration.json +1 -0
- models/FNO.py +437 -0
- models/__init__.py +5 -0
- scripts/__init__.py +1 -0
- scripts/common.py +269 -0
- scripts/inference.py +231 -0
- scripts/result.py +175 -0
- scripts/train.py +331 -0
- weight/best_model.pth +3 -0
README.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
tags:
|
| 6 |
+
- OneScience
|
| 7 |
+
- fluid-mechanics
|
| 8 |
+
- flow-field-prediction
|
| 9 |
+
- convolutional-neural-operator
|
| 10 |
+
frameworks: PyTorch
|
| 11 |
+
---
|
| 12 |
+
<p align="center">
|
| 13 |
+
<strong>
|
| 14 |
+
<span style="font-size: 30px;">CNO</span>
|
| 15 |
+
</strong>
|
| 16 |
+
</p>
|
| 17 |
+
|
| 18 |
+
# Model Introduction
|
| 19 |
+
|
| 20 |
+
CNO (Convolutional Neural Operator) is a neural operator model for learning partial differential equation (PDE) operators, proposed by **Bogdan Raonić et al.** and published at **NeurIPS 2023**. CNO combines convolutional neural networks with operator learning in continuous function spaces. Its anti-aliased activations and filtered upsampling and downsampling operations reduce errors caused by discretization and changes in resolution. The model can directly predict PDE solutions from initial conditions, source terms, or parameter fields. Using the OneScience skill workflow, this project independently reproduces the CNO experiment that predicts the horizontal velocity component of the two-dimensional incompressible Navier–Stokes equations from \(t=0\) to \(T=1\).
|
| 21 |
+
|
| 22 |
+
Paper: [Convolutional Neural Operators for Robust and Accurate Learning of PDEs](https://arxiv.org/abs/2302.01178)
|
| 23 |
+
|
| 24 |
+
# Model Description
|
| 25 |
+
|
| 26 |
+
CNO uses a **U-Net-like multiscale encoder-decoder architecture** that combines local convolutions, skip connections, and filtered upsampling and downsampling to learn function-to-function mappings for PDEs. Its key feature is an **anti-aliasing mechanism** in nonlinear activations and scale transformations. This reduces errors caused by discretization and resolution changes, better preserves the properties of continuous operators, and improves both operator-learning accuracy and cross-resolution robustness.
|
| 27 |
+
|
| 28 |
+
## Use Cases
|
| 29 |
+
|
| 30 |
+
| Use case | Description |
|
| 31 |
+
| --- | --- |
|
| 32 |
+
| PDE solution-operator learning | Learns mappings from initial conditions, source terms, or parameter fields to PDE solutions. |
|
| 33 |
+
| Fluid-dynamics prediction | Provides fast surrogate predictions for problems such as Navier–Stokes and compressible Euler equations. |
|
| 34 |
+
| Multiscale physical-field modeling | Handles complex PDE solutions containing both low- and high-frequency spatial structures. |
|
| 35 |
+
| Cross-resolution prediction | Supports inference at different spatial discretization resolutions to evaluate resolution generalization. |
|
| 36 |
+
| ID/OOD generalization | Evaluates model robustness under in-distribution and out-of-distribution PDE parameters or physical conditions. |
|
| 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/CNO --local_dir ./CNO
|
| 58 |
+
cd CNO
|
| 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 two-dimensional incompressible Navier–Stokes data from the RPB_CNO dataset. The training file is `NavierStokes_64x64_IN.h5`. Each sample contains:
|
| 86 |
+
|
| 87 |
+
- `input`: the horizontal velocity component at \(t=0\), with shape `(64, 64)`;
|
| 88 |
+
- `output`: the horizontal velocity component at \(T=1\), with shape `(64, 64)`.
|
| 89 |
+
|
| 90 |
+
A channel dimension is added after loading, so both inputs and labels in each training batch have shape `(batch_size, 1, 64, 64)`.
|
| 91 |
+
|
| 92 |
+
The current configuration uses 750 training samples, 128 validation samples, and 128 in-distribution test samples. The out-of-distribution test set contains 128 samples from `NavierStokes_128x128_OUT.h5`.
|
| 93 |
+
|
| 94 |
+
Download the data with:
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
modelscope download --dataset OneScience/RPB_CNO --local_dir ./data
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
After downloading, set `paths.data_dir` in `config/config.yaml` to the directory containing the HDF5 files.
|
| 101 |
+
|
| 102 |
+
### Training
|
| 103 |
+
|
| 104 |
+
The default configuration corresponds to the two-dimensional incompressible Navier–Stokes experiment in the paper, with spectral viscosity approximately \(\nu=4\times10^{-4}\). The model learns the mapping from the initial horizontal velocity component at \(t=0\) to the velocity component at \(T=1\).
|
| 105 |
+
|
| 106 |
+
```bash
|
| 107 |
+
python scripts/train.py --config config/config.yaml --device auto
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
The complete training state with the lowest median relative L1 error in physical space on the validation set is saved to `weight/best_model.pth`. It includes the model, optimizer, learning-rate scheduler, and normalization parameters.
|
| 111 |
+
|
| 112 |
+
### Trained Weights
|
| 113 |
+
|
| 114 |
+
`weight/best_model.pth` contains the best weights from the full training run and can be used directly for inference.
|
| 115 |
+
|
| 116 |
+
### Inference
|
| 117 |
+
|
| 118 |
+
Before running inference, make sure the configured data path is valid and `weight/best_model.pth` exists. The model performs a single-step prediction from the initial horizontal velocity component at \(t=0\) to the velocity component at \(T=1\). It does not perform closed-loop multistep or trajectory rollout prediction. The default inference batch size is 16.
|
| 119 |
+
|
| 120 |
+
```bash
|
| 121 |
+
python scripts/inference.py --config config/config.yaml
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
### Evaluation and Visualization
|
| 125 |
+
|
| 126 |
+
After training and inference, run:
|
| 127 |
+
|
| 128 |
+
```bash
|
| 129 |
+
python scripts/result.py --config config/config.yaml --sample-index 0
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
# Official OneScience Resources
|
| 133 |
+
|
| 134 |
+
| Platform | OneScience Main Repository | Skills Repository |
|
| 135 |
+
| --- | --- | --- |
|
| 136 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 137 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 138 |
+
|
| 139 |
+
# Citation and License
|
| 140 |
+
|
| 141 |
+
- Original paper: [Convolutional Neural Operators for Robust and Accurate Learning of PDEs](https://arxiv.org/abs/2302.01178)
|
| 142 |
+
- This project is an independent reproduction of the CNO paper. The official implementation is licensed under the MIT License. The project code, model weights, training data, and third-party dependencies remain subject to their respective license terms.
|
config/config.yaml
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Paper-faithful configuration for arXiv:2302.01178, Table 12 and Appendix C.
|
| 2 |
+
experiment:
|
| 3 |
+
name: cno_navier_stokes_2d
|
| 4 |
+
paper: https://arxiv.org/pdf/2302.01178
|
| 5 |
+
seed: 0 # Paper-unspecified, explicit reproducibility choice.
|
| 6 |
+
deterministic: true
|
| 7 |
+
|
| 8 |
+
paths:
|
| 9 |
+
data_dir: /public/share/sugonhpcapp01/onestore/onedatasets/RPB_CNO/data
|
| 10 |
+
train_file: NavierStokes_64x64_IN.h5
|
| 11 |
+
id_test_file: NavierStokes_64x64_IN.h5
|
| 12 |
+
ood_test_file: NavierStokes_128x128_OUT.h5
|
| 13 |
+
checkpoint: weight/best_model.pth
|
| 14 |
+
results_dir: results
|
| 15 |
+
|
| 16 |
+
data:
|
| 17 |
+
input_key: input
|
| 18 |
+
output_key: output
|
| 19 |
+
train:
|
| 20 |
+
start: 0
|
| 21 |
+
stop: 750
|
| 22 |
+
validation:
|
| 23 |
+
start: 768
|
| 24 |
+
stop: 896
|
| 25 |
+
test_id:
|
| 26 |
+
start: 896
|
| 27 |
+
stop: 1024
|
| 28 |
+
test_ood:
|
| 29 |
+
start: 0
|
| 30 |
+
stop: 128
|
| 31 |
+
|
| 32 |
+
# The paper requires [0,1] training normalization and reuse at test time but
|
| 33 |
+
# does not publish its constants. These extrema were measured once from the
|
| 34 |
+
# supplied 64x64 ID benchmark and are fixed for every split, including OOD.
|
| 35 |
+
normalization:
|
| 36 |
+
source: supplied_64x64_id_benchmark
|
| 37 |
+
input_min: -1.4294605255126953
|
| 38 |
+
input_max: 1.4294605255126953
|
| 39 |
+
output_min: -2.0383081436157227
|
| 40 |
+
output_max: 2.0602376461029053
|
| 41 |
+
epsilon: 1.0e-12
|
| 42 |
+
|
| 43 |
+
model:
|
| 44 |
+
in_channels: 1
|
| 45 |
+
out_channels: 1
|
| 46 |
+
base_width: 32 # d_e in Table 12.
|
| 47 |
+
levels: 3 # M in Table 12.
|
| 48 |
+
bottleneck_residual_blocks: 8
|
| 49 |
+
intermediate_residual_blocks: 1
|
| 50 |
+
kernel_size: 3
|
| 51 |
+
latent_channels: 64 # Paper-unspecified lift/project internal width.
|
| 52 |
+
activation_upsampling_factor: 2 # N_sigma.
|
| 53 |
+
filter_taps: 12 # N_tap.
|
| 54 |
+
filter_half_width: 0.8 # c_h.
|
| 55 |
+
cutoff_denominator: 2.0001
|
| 56 |
+
leaky_relu_slope: 0.2 # Paper-unspecified, official supplemental fact.
|
| 57 |
+
|
| 58 |
+
training:
|
| 59 |
+
epochs: 1000
|
| 60 |
+
batch_size: 32
|
| 61 |
+
num_workers: 4
|
| 62 |
+
optimizer: Adam
|
| 63 |
+
learning_rate: 0.001
|
| 64 |
+
weight_decay: 1.0e-10
|
| 65 |
+
scheduler: StepLR
|
| 66 |
+
scheduler_step_size: 1
|
| 67 |
+
scheduler_gamma: 0.98
|
| 68 |
+
early_stopping_patience: 50
|
| 69 |
+
log_interval: 5
|
| 70 |
+
device: auto
|
| 71 |
+
|
| 72 |
+
inference:
|
| 73 |
+
batch_size: 16
|
| 74 |
+
num_workers: 2
|
| 75 |
+
device: auto
|
| 76 |
+
metric_epsilon: 1.0e-12
|
| 77 |
+
|
| 78 |
+
paper_reference:
|
| 79 |
+
metric: relative_median_l1_percent
|
| 80 |
+
id: 2.76
|
| 81 |
+
ood: 7.04
|
configuration.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"framework":"Pytorch","task":"other"}
|
models/FNO.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Convolutional Neural Operator used by the CNO Navier--Stokes experiment.
|
| 2 |
+
|
| 3 |
+
The filename is fixed by the reproduction request. This module implements a
|
| 4 |
+
CNO, not a Fourier Neural Operator. The implementation is written from the
|
| 5 |
+
architecture and filter description in Sections 2 and C.1.4 of arXiv:2302.01178.
|
| 6 |
+
No source code from the authors' repository is included here.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import Literal
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
from torch import Tensor, nn
|
| 15 |
+
import torch.nn.functional as F
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _design_windowed_sinc(
|
| 19 |
+
num_taps: int,
|
| 20 |
+
resample_factor: int,
|
| 21 |
+
cutoff_denominator: float,
|
| 22 |
+
half_width: float,
|
| 23 |
+
) -> Tensor:
|
| 24 |
+
"""Construct a finite, symmetric low-pass windowed-sinc filter.
|
| 25 |
+
|
| 26 |
+
The paper fixes ``N_tap=12``, ``c_h=0.8`` and a cutoff arbitrarily close
|
| 27 |
+
to the target Nyquist frequency, ``s/2.0001``. At a high-rate grid used
|
| 28 |
+
for factor-r resampling, this corresponds to a normalized cutoff of
|
| 29 |
+
``1 / (r * 2.0001)`` cycles per sample. The exact finite window is not
|
| 30 |
+
specified in the paper; a Kaiser window is used as an explicit,
|
| 31 |
+
configurable approximation to ``scipy.signal.firwin``.
|
| 32 |
+
"""
|
| 33 |
+
if num_taps < 2:
|
| 34 |
+
raise ValueError(f"num_taps must be at least 2, got {num_taps}")
|
| 35 |
+
if resample_factor < 1:
|
| 36 |
+
raise ValueError("resample_factor must be positive")
|
| 37 |
+
if cutoff_denominator <= 2.0:
|
| 38 |
+
raise ValueError("cutoff_denominator must be greater than 2")
|
| 39 |
+
if half_width <= 0:
|
| 40 |
+
raise ValueError("half_width must be positive")
|
| 41 |
+
|
| 42 |
+
dtype = torch.float64
|
| 43 |
+
positions = torch.arange(num_taps, dtype=dtype) - (num_taps - 1) / 2
|
| 44 |
+
cutoff = 1.0 / (resample_factor * cutoff_denominator)
|
| 45 |
+
ideal = 2.0 * cutoff * torch.sinc(2.0 * cutoff * positions)
|
| 46 |
+
|
| 47 |
+
# c_h=0.8 maps to a conventional beta=8.6 window. Keeping the relation
|
| 48 |
+
# explicit makes the paper-unspecified window choice auditable.
|
| 49 |
+
beta = 8.6 * half_width / 0.8
|
| 50 |
+
window = torch.kaiser_window(num_taps, periodic=False, beta=beta, dtype=dtype)
|
| 51 |
+
kernel = ideal * window
|
| 52 |
+
kernel = kernel / kernel.sum()
|
| 53 |
+
return kernel.to(torch.float32)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class FixedSincResample2d(nn.Module):
|
| 57 |
+
"""Separable periodic 2-D windowed-sinc up/downsampling."""
|
| 58 |
+
|
| 59 |
+
def __init__(
|
| 60 |
+
self,
|
| 61 |
+
factor: int = 2,
|
| 62 |
+
num_taps: int = 12,
|
| 63 |
+
cutoff_denominator: float = 2.0001,
|
| 64 |
+
half_width: float = 0.8,
|
| 65 |
+
) -> None:
|
| 66 |
+
super().__init__()
|
| 67 |
+
if factor < 1:
|
| 68 |
+
raise ValueError("factor must be positive")
|
| 69 |
+
self.factor = int(factor)
|
| 70 |
+
kernel = _design_windowed_sinc(
|
| 71 |
+
num_taps=num_taps,
|
| 72 |
+
resample_factor=factor,
|
| 73 |
+
cutoff_denominator=cutoff_denominator,
|
| 74 |
+
half_width=half_width,
|
| 75 |
+
)
|
| 76 |
+
self.register_buffer("kernel", kernel, persistent=True)
|
| 77 |
+
|
| 78 |
+
def _filter(self, x: Tensor, gain: float = 1.0) -> Tensor:
|
| 79 |
+
if x.ndim != 4:
|
| 80 |
+
raise ValueError(f"expected BCHW input, got shape {tuple(x.shape)}")
|
| 81 |
+
channels = x.shape[1]
|
| 82 |
+
kernel = self.kernel.to(device=x.device, dtype=x.dtype)
|
| 83 |
+
taps = int(kernel.numel())
|
| 84 |
+
pad_left = (taps - 1) // 2
|
| 85 |
+
pad_right = taps - 1 - pad_left
|
| 86 |
+
|
| 87 |
+
weight_x = (kernel * gain).view(1, 1, 1, taps).repeat(channels, 1, 1, 1)
|
| 88 |
+
x = F.pad(x, (pad_left, pad_right, 0, 0), mode="circular")
|
| 89 |
+
x = F.conv2d(x, weight_x, groups=channels)
|
| 90 |
+
|
| 91 |
+
weight_y = kernel.view(1, 1, taps, 1).repeat(channels, 1, 1, 1)
|
| 92 |
+
x = F.pad(x, (0, 0, pad_left, pad_right), mode="circular")
|
| 93 |
+
return F.conv2d(x, weight_y, groups=channels)
|
| 94 |
+
|
| 95 |
+
def upsample(self, x: Tensor) -> Tensor:
|
| 96 |
+
if self.factor == 1:
|
| 97 |
+
return x
|
| 98 |
+
batch, channels, height, width = x.shape
|
| 99 |
+
up = x.new_zeros(batch, channels, height * self.factor, width * self.factor)
|
| 100 |
+
up[..., :: self.factor, :: self.factor] = x
|
| 101 |
+
return self._filter(up, gain=float(self.factor * self.factor))
|
| 102 |
+
|
| 103 |
+
def downsample(self, x: Tensor) -> Tensor:
|
| 104 |
+
if self.factor == 1:
|
| 105 |
+
return x
|
| 106 |
+
if x.shape[-2] % self.factor or x.shape[-1] % self.factor:
|
| 107 |
+
raise ValueError(
|
| 108 |
+
f"spatial shape {tuple(x.shape[-2:])} is not divisible by {self.factor}"
|
| 109 |
+
)
|
| 110 |
+
return self._filter(x)[..., :: self.factor, :: self.factor]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class BandlimitedActivation(nn.Module):
|
| 114 |
+
"""Paper Eq. (2.6): upsample, activate, then low-pass/downsample."""
|
| 115 |
+
|
| 116 |
+
def __init__(
|
| 117 |
+
self,
|
| 118 |
+
upsampling_factor: int = 2,
|
| 119 |
+
num_taps: int = 12,
|
| 120 |
+
cutoff_denominator: float = 2.0001,
|
| 121 |
+
half_width: float = 0.8,
|
| 122 |
+
negative_slope: float = 0.2,
|
| 123 |
+
) -> None:
|
| 124 |
+
super().__init__()
|
| 125 |
+
self.negative_slope = float(negative_slope)
|
| 126 |
+
self.resampler = FixedSincResample2d(
|
| 127 |
+
factor=upsampling_factor,
|
| 128 |
+
num_taps=num_taps,
|
| 129 |
+
cutoff_denominator=cutoff_denominator,
|
| 130 |
+
half_width=half_width,
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
def forward(self, x: Tensor) -> Tensor:
|
| 134 |
+
x = self.resampler.upsample(x)
|
| 135 |
+
x = F.leaky_relu(x, negative_slope=self.negative_slope)
|
| 136 |
+
return self.resampler.downsample(x)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _periodic_conv(in_channels: int, out_channels: int, kernel_size: int) -> nn.Conv2d:
|
| 140 |
+
if kernel_size % 2 != 1:
|
| 141 |
+
raise ValueError("CNO convolution kernel_size must be odd")
|
| 142 |
+
return nn.Conv2d(
|
| 143 |
+
in_channels,
|
| 144 |
+
out_channels,
|
| 145 |
+
kernel_size=kernel_size,
|
| 146 |
+
padding=kernel_size // 2,
|
| 147 |
+
padding_mode="circular",
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class CNOBlock(nn.Module):
|
| 152 |
+
"""Physical-space convolution followed by bandlimited activation/resampling."""
|
| 153 |
+
|
| 154 |
+
def __init__(
|
| 155 |
+
self,
|
| 156 |
+
in_channels: int,
|
| 157 |
+
out_channels: int,
|
| 158 |
+
kernel_size: int,
|
| 159 |
+
resample: Literal["same", "up", "down"],
|
| 160 |
+
activation_kwargs: dict,
|
| 161 |
+
batch_norm: bool = True,
|
| 162 |
+
) -> None:
|
| 163 |
+
super().__init__()
|
| 164 |
+
self.resample = resample
|
| 165 |
+
self.conv = _periodic_conv(in_channels, out_channels, kernel_size)
|
| 166 |
+
self.norm = nn.BatchNorm2d(out_channels) if batch_norm else nn.Identity()
|
| 167 |
+
self.activation = BandlimitedActivation(**activation_kwargs)
|
| 168 |
+
self.resampler = FixedSincResample2d(
|
| 169 |
+
factor=activation_kwargs["upsampling_factor"],
|
| 170 |
+
num_taps=activation_kwargs["num_taps"],
|
| 171 |
+
cutoff_denominator=activation_kwargs["cutoff_denominator"],
|
| 172 |
+
half_width=activation_kwargs["half_width"],
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
def forward(self, x: Tensor) -> Tensor:
|
| 176 |
+
x = self.activation(self.norm(self.conv(x)))
|
| 177 |
+
if self.resample == "down":
|
| 178 |
+
return self.resampler.downsample(x)
|
| 179 |
+
if self.resample == "up":
|
| 180 |
+
return self.resampler.upsample(x)
|
| 181 |
+
return x
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
class ResidualBlock(nn.Module):
|
| 185 |
+
"""Paper Eq. (2.7): identity plus K o Sigma o K."""
|
| 186 |
+
|
| 187 |
+
def __init__(self, channels: int, kernel_size: int, activation_kwargs: dict) -> None:
|
| 188 |
+
super().__init__()
|
| 189 |
+
self.conv1 = _periodic_conv(channels, channels, kernel_size)
|
| 190 |
+
self.norm1 = nn.BatchNorm2d(channels)
|
| 191 |
+
self.activation = BandlimitedActivation(**activation_kwargs)
|
| 192 |
+
self.conv2 = _periodic_conv(channels, channels, kernel_size)
|
| 193 |
+
self.norm2 = nn.BatchNorm2d(channels)
|
| 194 |
+
|
| 195 |
+
def forward(self, x: Tensor) -> Tensor:
|
| 196 |
+
residual = self.norm1(self.conv1(x))
|
| 197 |
+
residual = self.activation(residual)
|
| 198 |
+
residual = self.norm2(self.conv2(residual))
|
| 199 |
+
return x + residual
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
class InvariantBlock(nn.Module):
|
| 203 |
+
"""Paper Eq. (2.8): Sigma o K at an unchanged representation."""
|
| 204 |
+
|
| 205 |
+
def __init__(
|
| 206 |
+
self,
|
| 207 |
+
in_channels: int,
|
| 208 |
+
out_channels: int,
|
| 209 |
+
kernel_size: int,
|
| 210 |
+
activation_kwargs: dict,
|
| 211 |
+
) -> None:
|
| 212 |
+
super().__init__()
|
| 213 |
+
self.conv = _periodic_conv(in_channels, out_channels, kernel_size)
|
| 214 |
+
self.norm = nn.BatchNorm2d(out_channels)
|
| 215 |
+
self.activation = BandlimitedActivation(**activation_kwargs)
|
| 216 |
+
|
| 217 |
+
def forward(self, x: Tensor) -> Tensor:
|
| 218 |
+
return self.activation(self.norm(self.conv(x)))
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
class LiftProjectBlock(nn.Module):
|
| 222 |
+
"""Two convolutions with no BatchNorm, as specified for lift/project."""
|
| 223 |
+
|
| 224 |
+
def __init__(
|
| 225 |
+
self,
|
| 226 |
+
in_channels: int,
|
| 227 |
+
out_channels: int,
|
| 228 |
+
latent_channels: int,
|
| 229 |
+
kernel_size: int,
|
| 230 |
+
activation_kwargs: dict,
|
| 231 |
+
) -> None:
|
| 232 |
+
super().__init__()
|
| 233 |
+
self.conv1 = _periodic_conv(in_channels, latent_channels, kernel_size)
|
| 234 |
+
self.activation = BandlimitedActivation(**activation_kwargs)
|
| 235 |
+
self.conv2 = _periodic_conv(latent_channels, out_channels, kernel_size)
|
| 236 |
+
|
| 237 |
+
def forward(self, x: Tensor) -> Tensor:
|
| 238 |
+
return self.conv2(self.activation(self.conv1(x)))
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def _residual_stack(
|
| 242 |
+
channels: int,
|
| 243 |
+
count: int,
|
| 244 |
+
kernel_size: int,
|
| 245 |
+
activation_kwargs: dict,
|
| 246 |
+
) -> nn.Module:
|
| 247 |
+
if count == 0:
|
| 248 |
+
return nn.Identity()
|
| 249 |
+
return nn.Sequential(
|
| 250 |
+
*[
|
| 251 |
+
ResidualBlock(channels, kernel_size, activation_kwargs)
|
| 252 |
+
for _ in range(count)
|
| 253 |
+
]
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
class CNO2d(nn.Module):
|
| 258 |
+
"""Operator U-Net CNO for the paper's 2-D Navier--Stokes experiment."""
|
| 259 |
+
|
| 260 |
+
def __init__(
|
| 261 |
+
self,
|
| 262 |
+
in_channels: int = 1,
|
| 263 |
+
out_channels: int = 1,
|
| 264 |
+
base_width: int = 32,
|
| 265 |
+
levels: int = 3,
|
| 266 |
+
bottleneck_residual_blocks: int = 8,
|
| 267 |
+
intermediate_residual_blocks: int = 1,
|
| 268 |
+
kernel_size: int = 3,
|
| 269 |
+
latent_channels: int = 64,
|
| 270 |
+
activation_upsampling_factor: int = 2,
|
| 271 |
+
filter_taps: int = 12,
|
| 272 |
+
filter_half_width: float = 0.8,
|
| 273 |
+
cutoff_denominator: float = 2.0001,
|
| 274 |
+
leaky_relu_slope: float = 0.2,
|
| 275 |
+
) -> None:
|
| 276 |
+
super().__init__()
|
| 277 |
+
if levels < 1:
|
| 278 |
+
raise ValueError("levels must be positive")
|
| 279 |
+
if base_width % 2:
|
| 280 |
+
raise ValueError("base_width must be even because lift width is d_e/2")
|
| 281 |
+
|
| 282 |
+
self.in_channels = int(in_channels)
|
| 283 |
+
self.out_channels = int(out_channels)
|
| 284 |
+
self.base_width = int(base_width)
|
| 285 |
+
self.levels = int(levels)
|
| 286 |
+
self.required_divisor = 2**levels
|
| 287 |
+
lift_width = base_width // 2
|
| 288 |
+
encoder_widths = [base_width * (2**index) for index in range(levels)]
|
| 289 |
+
activation_kwargs = {
|
| 290 |
+
"upsampling_factor": activation_upsampling_factor,
|
| 291 |
+
"num_taps": filter_taps,
|
| 292 |
+
"cutoff_denominator": cutoff_denominator,
|
| 293 |
+
"half_width": filter_half_width,
|
| 294 |
+
"negative_slope": leaky_relu_slope,
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
self.lift = LiftProjectBlock(
|
| 298 |
+
in_channels,
|
| 299 |
+
lift_width,
|
| 300 |
+
latent_channels,
|
| 301 |
+
kernel_size,
|
| 302 |
+
activation_kwargs,
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
down_blocks: list[nn.Module] = []
|
| 306 |
+
intermediate_blocks: list[nn.Module] = []
|
| 307 |
+
current_width = lift_width
|
| 308 |
+
for level, next_width in enumerate(encoder_widths):
|
| 309 |
+
down_blocks.append(
|
| 310 |
+
CNOBlock(
|
| 311 |
+
current_width,
|
| 312 |
+
next_width,
|
| 313 |
+
kernel_size,
|
| 314 |
+
"down",
|
| 315 |
+
activation_kwargs,
|
| 316 |
+
)
|
| 317 |
+
)
|
| 318 |
+
# The bottleneck has its own N_res,b stack; N_res,i belongs to the
|
| 319 |
+
# genuinely intermediate resolutions only.
|
| 320 |
+
count = intermediate_residual_blocks if level < levels - 1 else 0
|
| 321 |
+
intermediate_blocks.append(
|
| 322 |
+
_residual_stack(next_width, count, kernel_size, activation_kwargs)
|
| 323 |
+
)
|
| 324 |
+
current_width = next_width
|
| 325 |
+
self.encoder = nn.ModuleList(down_blocks)
|
| 326 |
+
self.encoder_residuals = nn.ModuleList(intermediate_blocks)
|
| 327 |
+
|
| 328 |
+
self.bottleneck = _residual_stack(
|
| 329 |
+
encoder_widths[-1],
|
| 330 |
+
bottleneck_residual_blocks,
|
| 331 |
+
kernel_size,
|
| 332 |
+
activation_kwargs,
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
pre_patch: list[nn.Module] = []
|
| 336 |
+
post_patch: list[nn.Module] = []
|
| 337 |
+
up_blocks: list[nn.Module] = []
|
| 338 |
+
decoder_current = encoder_widths[-1]
|
| 339 |
+
output_widths = list(reversed([lift_width] + encoder_widths[:-1]))
|
| 340 |
+
for next_width in output_widths:
|
| 341 |
+
pre_patch.append(
|
| 342 |
+
InvariantBlock(
|
| 343 |
+
decoder_current,
|
| 344 |
+
decoder_current,
|
| 345 |
+
kernel_size,
|
| 346 |
+
activation_kwargs,
|
| 347 |
+
)
|
| 348 |
+
)
|
| 349 |
+
post_patch.append(
|
| 350 |
+
InvariantBlock(
|
| 351 |
+
decoder_current * 2,
|
| 352 |
+
decoder_current,
|
| 353 |
+
kernel_size,
|
| 354 |
+
activation_kwargs,
|
| 355 |
+
)
|
| 356 |
+
)
|
| 357 |
+
up_blocks.append(
|
| 358 |
+
CNOBlock(
|
| 359 |
+
decoder_current,
|
| 360 |
+
next_width,
|
| 361 |
+
kernel_size,
|
| 362 |
+
"up",
|
| 363 |
+
activation_kwargs,
|
| 364 |
+
)
|
| 365 |
+
)
|
| 366 |
+
decoder_current = next_width
|
| 367 |
+
self.decoder_pre_patch = nn.ModuleList(pre_patch)
|
| 368 |
+
self.decoder_post_patch = nn.ModuleList(post_patch)
|
| 369 |
+
self.decoder = nn.ModuleList(up_blocks)
|
| 370 |
+
|
| 371 |
+
self.project = LiftProjectBlock(
|
| 372 |
+
lift_width * 2,
|
| 373 |
+
out_channels,
|
| 374 |
+
latent_channels,
|
| 375 |
+
kernel_size,
|
| 376 |
+
activation_kwargs,
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
def forward(self, x: Tensor) -> Tensor:
|
| 380 |
+
if x.ndim != 4:
|
| 381 |
+
raise ValueError(f"CNO2d expects BCHW input, got {tuple(x.shape)}")
|
| 382 |
+
if x.shape[1] != self.in_channels:
|
| 383 |
+
raise ValueError(
|
| 384 |
+
f"expected {self.in_channels} channels, got {x.shape[1]}"
|
| 385 |
+
)
|
| 386 |
+
height, width = x.shape[-2:]
|
| 387 |
+
if height % self.required_divisor or width % self.required_divisor:
|
| 388 |
+
raise ValueError(
|
| 389 |
+
f"spatial shape {(height, width)} must be divisible by "
|
| 390 |
+
f"2**levels={self.required_divisor}"
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
lifted = self.lift(x)
|
| 394 |
+
encoded = lifted
|
| 395 |
+
skips: list[Tensor] = []
|
| 396 |
+
for down, residuals in zip(self.encoder, self.encoder_residuals):
|
| 397 |
+
encoded = residuals(down(encoded))
|
| 398 |
+
skips.append(encoded)
|
| 399 |
+
|
| 400 |
+
decoded = self.bottleneck(encoded)
|
| 401 |
+
for pre, post, up, skip in zip(
|
| 402 |
+
self.decoder_pre_patch,
|
| 403 |
+
self.decoder_post_patch,
|
| 404 |
+
self.decoder,
|
| 405 |
+
reversed(skips),
|
| 406 |
+
):
|
| 407 |
+
decoded = pre(decoded)
|
| 408 |
+
if decoded.shape[-2:] != skip.shape[-2:]:
|
| 409 |
+
raise RuntimeError(
|
| 410 |
+
"decoder/skip spatial mismatch before patching: "
|
| 411 |
+
f"{tuple(decoded.shape)} versus {tuple(skip.shape)}"
|
| 412 |
+
)
|
| 413 |
+
decoded = post(torch.cat((decoded, skip), dim=1))
|
| 414 |
+
decoded = up(decoded)
|
| 415 |
+
|
| 416 |
+
if decoded.shape[-2:] != lifted.shape[-2:]:
|
| 417 |
+
raise RuntimeError(
|
| 418 |
+
f"final decoder/lift mismatch: {decoded.shape} versus {lifted.shape}"
|
| 419 |
+
)
|
| 420 |
+
output = self.project(torch.cat((decoded, lifted), dim=1))
|
| 421 |
+
if output.shape[-2:] != (height, width):
|
| 422 |
+
raise RuntimeError(
|
| 423 |
+
f"CNO changed output grid from {(height, width)} to {output.shape[-2:]}"
|
| 424 |
+
)
|
| 425 |
+
return output
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def build_model(model_config: dict) -> CNO2d:
|
| 429 |
+
"""Build a CNO2d from the ``model`` section of config.yaml."""
|
| 430 |
+
return CNO2d(**model_config)
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def count_trainable_parameters(model: nn.Module) -> int:
|
| 434 |
+
return sum(parameter.numel() for parameter in model.parameters() if parameter.requires_grad)
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
__all__ = ["CNO2d", "build_model", "count_trainable_parameters"]
|
models/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model package for the CNO reproduction."""
|
| 2 |
+
|
| 3 |
+
from .FNO import CNO2d, build_model, count_trainable_parameters
|
| 4 |
+
|
| 5 |
+
__all__ = ["CNO2d", "build_model", "count_trainable_parameters"]
|
scripts/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Executable and shared utilities for the CNO reproduction."""
|
scripts/common.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared data, normalization, metric, and serialization utilities."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import random
|
| 8 |
+
import tempfile
|
| 9 |
+
from dataclasses import asdict, dataclass
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any, Sequence
|
| 12 |
+
|
| 13 |
+
import h5py
|
| 14 |
+
import numpy as np
|
| 15 |
+
import torch
|
| 16 |
+
from torch import Tensor
|
| 17 |
+
from torch.utils.data import Dataset
|
| 18 |
+
import yaml
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def load_config(path: str | Path) -> dict[str, Any]:
|
| 25 |
+
config_path = Path(path).expanduser().resolve()
|
| 26 |
+
if not config_path.is_file():
|
| 27 |
+
raise FileNotFoundError(f"configuration file not found: {config_path}")
|
| 28 |
+
with config_path.open("r", encoding="utf-8") as handle:
|
| 29 |
+
config = yaml.safe_load(handle)
|
| 30 |
+
if not isinstance(config, dict):
|
| 31 |
+
raise ValueError(f"configuration root must be a mapping: {config_path}")
|
| 32 |
+
for section in ("experiment", "paths", "data", "normalization", "model"):
|
| 33 |
+
if section not in config:
|
| 34 |
+
raise KeyError(f"missing required config section: {section}")
|
| 35 |
+
return config
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def project_path(path: str | Path) -> Path:
|
| 39 |
+
candidate = Path(path).expanduser()
|
| 40 |
+
return candidate.resolve() if candidate.is_absolute() else (PROJECT_ROOT / candidate).resolve()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def data_file(config: dict[str, Any], filename_key: str) -> Path:
|
| 44 |
+
directory = Path(config["paths"]["data_dir"]).expanduser()
|
| 45 |
+
path = (directory / config["paths"][filename_key]).resolve()
|
| 46 |
+
if not path.is_file():
|
| 47 |
+
raise FileNotFoundError(f"data file not found: {path}")
|
| 48 |
+
return path
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def numeric_sample_ids(split: dict[str, int]) -> list[int]:
|
| 52 |
+
start, stop = int(split["start"]), int(split["stop"])
|
| 53 |
+
if start < 0 or stop <= start:
|
| 54 |
+
raise ValueError(f"invalid half-open sample range [{start}, {stop})")
|
| 55 |
+
return list(range(start, stop))
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def set_reproducibility(seed: int, deterministic: bool = True) -> None:
|
| 59 |
+
random.seed(seed)
|
| 60 |
+
np.random.seed(seed)
|
| 61 |
+
torch.manual_seed(seed)
|
| 62 |
+
if torch.cuda.is_available():
|
| 63 |
+
torch.cuda.manual_seed_all(seed)
|
| 64 |
+
if deterministic:
|
| 65 |
+
torch.backends.cudnn.benchmark = False
|
| 66 |
+
torch.backends.cudnn.deterministic = True
|
| 67 |
+
try:
|
| 68 |
+
torch.use_deterministic_algorithms(True, warn_only=True)
|
| 69 |
+
except TypeError:
|
| 70 |
+
torch.use_deterministic_algorithms(True)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def select_device(requested: str) -> torch.device:
|
| 74 |
+
requested = requested.lower()
|
| 75 |
+
if requested == "auto":
|
| 76 |
+
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 77 |
+
device = torch.device(requested)
|
| 78 |
+
if device.type == "cuda" and not torch.cuda.is_available():
|
| 79 |
+
raise RuntimeError("CUDA was requested but torch.cuda.is_available() is false")
|
| 80 |
+
return device
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@dataclass(frozen=True)
|
| 84 |
+
class MinMaxNormalizer:
|
| 85 |
+
input_min: float
|
| 86 |
+
input_max: float
|
| 87 |
+
output_min: float
|
| 88 |
+
output_max: float
|
| 89 |
+
epsilon: float = 1.0e-12
|
| 90 |
+
source: str = ""
|
| 91 |
+
|
| 92 |
+
@classmethod
|
| 93 |
+
def from_config(cls, config: dict[str, Any]) -> "MinMaxNormalizer":
|
| 94 |
+
values = config["normalization"]
|
| 95 |
+
result = cls(
|
| 96 |
+
input_min=float(values["input_min"]),
|
| 97 |
+
input_max=float(values["input_max"]),
|
| 98 |
+
output_min=float(values["output_min"]),
|
| 99 |
+
output_max=float(values["output_max"]),
|
| 100 |
+
epsilon=float(values.get("epsilon", 1.0e-12)),
|
| 101 |
+
source=str(values.get("source", "")),
|
| 102 |
+
)
|
| 103 |
+
result.validate()
|
| 104 |
+
return result
|
| 105 |
+
|
| 106 |
+
@classmethod
|
| 107 |
+
def from_state(cls, state: dict[str, Any]) -> "MinMaxNormalizer":
|
| 108 |
+
result = cls(**state)
|
| 109 |
+
result.validate()
|
| 110 |
+
return result
|
| 111 |
+
|
| 112 |
+
def validate(self) -> None:
|
| 113 |
+
values = (self.input_min, self.input_max, self.output_min, self.output_max)
|
| 114 |
+
if not all(np.isfinite(value) for value in values):
|
| 115 |
+
raise ValueError(f"normalization contains nonfinite values: {values}")
|
| 116 |
+
if self.input_max - self.input_min <= self.epsilon:
|
| 117 |
+
raise ValueError("input normalization range is zero or negative")
|
| 118 |
+
if self.output_max - self.output_min <= self.epsilon:
|
| 119 |
+
raise ValueError("output normalization range is zero or negative")
|
| 120 |
+
|
| 121 |
+
def normalize_input(self, value: Tensor) -> Tensor:
|
| 122 |
+
return (value - self.input_min) / (self.input_max - self.input_min)
|
| 123 |
+
|
| 124 |
+
def normalize_output(self, value: Tensor) -> Tensor:
|
| 125 |
+
return (value - self.output_min) / (self.output_max - self.output_min)
|
| 126 |
+
|
| 127 |
+
def denormalize_input(self, value: Tensor) -> Tensor:
|
| 128 |
+
return value * (self.input_max - self.input_min) + self.input_min
|
| 129 |
+
|
| 130 |
+
def denormalize_output(self, value: Tensor) -> Tensor:
|
| 131 |
+
return value * (self.output_max - self.output_min) + self.output_min
|
| 132 |
+
|
| 133 |
+
def state_dict(self) -> dict[str, Any]:
|
| 134 |
+
return asdict(self)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class NavierStokesH5Dataset(Dataset[tuple[Tensor, Tensor, int]]):
|
| 138 |
+
"""Lazy reader for the supplied ``Sample_i/{input,output}`` benchmark."""
|
| 139 |
+
|
| 140 |
+
def __init__(
|
| 141 |
+
self,
|
| 142 |
+
path: str | Path,
|
| 143 |
+
sample_ids: Sequence[int],
|
| 144 |
+
normalizer: MinMaxNormalizer,
|
| 145 |
+
input_key: str = "input",
|
| 146 |
+
output_key: str = "output",
|
| 147 |
+
) -> None:
|
| 148 |
+
self.path = Path(path).expanduser().resolve()
|
| 149 |
+
if not self.path.is_file():
|
| 150 |
+
raise FileNotFoundError(f"HDF5 file not found: {self.path}")
|
| 151 |
+
self.sample_ids = [int(sample_id) for sample_id in sample_ids]
|
| 152 |
+
if not self.sample_ids:
|
| 153 |
+
raise ValueError("dataset sample_ids must not be empty")
|
| 154 |
+
self.normalizer = normalizer
|
| 155 |
+
self.input_key = input_key
|
| 156 |
+
self.output_key = output_key
|
| 157 |
+
self._handle: h5py.File | None = None
|
| 158 |
+
self._validate_contract()
|
| 159 |
+
|
| 160 |
+
def _validate_contract(self) -> None:
|
| 161 |
+
with h5py.File(self.path, "r") as handle:
|
| 162 |
+
for sample_id in (self.sample_ids[0], self.sample_ids[-1]):
|
| 163 |
+
group_name = f"Sample_{sample_id}"
|
| 164 |
+
if group_name not in handle:
|
| 165 |
+
raise KeyError(f"missing group {group_name} in {self.path}")
|
| 166 |
+
group = handle[group_name]
|
| 167 |
+
if self.input_key not in group or self.output_key not in group:
|
| 168 |
+
raise KeyError(
|
| 169 |
+
f"{group_name} must contain {self.input_key!r} and {self.output_key!r}"
|
| 170 |
+
)
|
| 171 |
+
input_shape = tuple(group[self.input_key].shape)
|
| 172 |
+
output_shape = tuple(group[self.output_key].shape)
|
| 173 |
+
if len(input_shape) != 2 or input_shape != output_shape:
|
| 174 |
+
raise ValueError(
|
| 175 |
+
f"invalid field shapes in {group_name}: {input_shape}, {output_shape}"
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
def _file(self) -> h5py.File:
|
| 179 |
+
if self._handle is None:
|
| 180 |
+
self._handle = h5py.File(self.path, "r")
|
| 181 |
+
return self._handle
|
| 182 |
+
|
| 183 |
+
def __len__(self) -> int:
|
| 184 |
+
return len(self.sample_ids)
|
| 185 |
+
|
| 186 |
+
def __getitem__(self, index: int) -> tuple[Tensor, Tensor, int]:
|
| 187 |
+
sample_id = self.sample_ids[index]
|
| 188 |
+
group = self._file()[f"Sample_{sample_id}"]
|
| 189 |
+
input_array = np.asarray(group[self.input_key], dtype=np.float32)
|
| 190 |
+
output_array = np.asarray(group[self.output_key], dtype=np.float32)
|
| 191 |
+
if input_array.shape != output_array.shape or input_array.ndim != 2:
|
| 192 |
+
raise ValueError(f"invalid shapes for Sample_{sample_id}")
|
| 193 |
+
if not np.isfinite(input_array).all() or not np.isfinite(output_array).all():
|
| 194 |
+
raise ValueError(f"nonfinite field values in Sample_{sample_id}")
|
| 195 |
+
input_tensor = torch.from_numpy(input_array.copy()).unsqueeze(0)
|
| 196 |
+
output_tensor = torch.from_numpy(output_array.copy()).unsqueeze(0)
|
| 197 |
+
return (
|
| 198 |
+
self.normalizer.normalize_input(input_tensor),
|
| 199 |
+
self.normalizer.normalize_output(output_tensor),
|
| 200 |
+
sample_id,
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
def __getstate__(self) -> dict[str, Any]:
|
| 204 |
+
state = self.__dict__.copy()
|
| 205 |
+
state["_handle"] = None
|
| 206 |
+
return state
|
| 207 |
+
|
| 208 |
+
def close(self) -> None:
|
| 209 |
+
if self._handle is not None:
|
| 210 |
+
self._handle.close()
|
| 211 |
+
self._handle = None
|
| 212 |
+
|
| 213 |
+
def __del__(self) -> None:
|
| 214 |
+
# h5py modules may already be partially torn down during interpreter
|
| 215 |
+
# shutdown. Explicit ``close`` remains available for normal control
|
| 216 |
+
# flow; finalization must never emit a spurious exception.
|
| 217 |
+
try:
|
| 218 |
+
self.close()
|
| 219 |
+
except Exception:
|
| 220 |
+
self._handle = None
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def relative_l1_per_sample(prediction: Tensor, target: Tensor, epsilon: float) -> Tensor:
|
| 224 |
+
if prediction.shape != target.shape:
|
| 225 |
+
raise ValueError(
|
| 226 |
+
f"prediction/target shape mismatch: {prediction.shape} versus {target.shape}"
|
| 227 |
+
)
|
| 228 |
+
reduce_dims = tuple(range(1, prediction.ndim))
|
| 229 |
+
numerator = torch.sum(torch.abs(prediction - target), dim=reduce_dims)
|
| 230 |
+
denominator = torch.sum(torch.abs(target), dim=reduce_dims).clamp_min(epsilon)
|
| 231 |
+
return numerator / denominator
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def atomic_json_dump(payload: Any, path: str | Path) -> None:
|
| 235 |
+
destination = Path(path)
|
| 236 |
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
| 237 |
+
with tempfile.NamedTemporaryFile(
|
| 238 |
+
mode="w", encoding="utf-8", dir=destination.parent, suffix=".json", delete=False
|
| 239 |
+
) as handle:
|
| 240 |
+
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
| 241 |
+
handle.write("\n")
|
| 242 |
+
temporary = Path(handle.name)
|
| 243 |
+
os.replace(temporary, destination)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def atomic_torch_save(payload: Any, path: str | Path) -> None:
|
| 247 |
+
destination = Path(path)
|
| 248 |
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
| 249 |
+
with tempfile.NamedTemporaryFile(dir=destination.parent, suffix=".pth", delete=False) as handle:
|
| 250 |
+
temporary = Path(handle.name)
|
| 251 |
+
try:
|
| 252 |
+
torch.save(payload, temporary)
|
| 253 |
+
os.replace(temporary, destination)
|
| 254 |
+
finally:
|
| 255 |
+
if temporary.exists():
|
| 256 |
+
temporary.unlink()
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def atomic_npz_save(path: str | Path, **arrays: np.ndarray) -> None:
|
| 260 |
+
destination = Path(path)
|
| 261 |
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
| 262 |
+
with tempfile.NamedTemporaryFile(dir=destination.parent, suffix=".npz", delete=False) as handle:
|
| 263 |
+
temporary = Path(handle.name)
|
| 264 |
+
try:
|
| 265 |
+
np.savez_compressed(temporary, **arrays)
|
| 266 |
+
os.replace(temporary, destination)
|
| 267 |
+
finally:
|
| 268 |
+
if temporary.exists():
|
| 269 |
+
temporary.unlink()
|
scripts/inference.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Evaluate a trained CNO on the paper's ID and OOD Navier--Stokes sets."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import torch
|
| 13 |
+
from torch.utils.data import DataLoader
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 17 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 18 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 19 |
+
|
| 20 |
+
from models.FNO import build_model
|
| 21 |
+
from scripts.common import (
|
| 22 |
+
MinMaxNormalizer,
|
| 23 |
+
NavierStokesH5Dataset,
|
| 24 |
+
atomic_json_dump,
|
| 25 |
+
atomic_npz_save,
|
| 26 |
+
data_file,
|
| 27 |
+
load_config,
|
| 28 |
+
numeric_sample_ids,
|
| 29 |
+
project_path,
|
| 30 |
+
relative_l1_per_sample,
|
| 31 |
+
select_device,
|
| 32 |
+
set_reproducibility,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def parse_args() -> argparse.Namespace:
|
| 37 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 38 |
+
parser.add_argument(
|
| 39 |
+
"--config", default=str(PROJECT_ROOT / "config" / "config.yaml")
|
| 40 |
+
)
|
| 41 |
+
parser.add_argument("--checkpoint", default=None)
|
| 42 |
+
parser.add_argument("--device", default=None)
|
| 43 |
+
parser.add_argument("--batch-size", type=int, default=None)
|
| 44 |
+
parser.add_argument("--output-dir", default=None)
|
| 45 |
+
parser.add_argument(
|
| 46 |
+
"--splits",
|
| 47 |
+
nargs="+",
|
| 48 |
+
choices=("id", "ood"),
|
| 49 |
+
default=("id", "ood"),
|
| 50 |
+
)
|
| 51 |
+
return parser.parse_args()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def load_trained_model(
|
| 55 |
+
config: dict[str, Any], checkpoint_path: Path, device: torch.device
|
| 56 |
+
) -> tuple[torch.nn.Module, MinMaxNormalizer, dict[str, Any]]:
|
| 57 |
+
if not checkpoint_path.is_file():
|
| 58 |
+
raise FileNotFoundError(f"checkpoint not found: {checkpoint_path}")
|
| 59 |
+
checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
|
| 60 |
+
required = {
|
| 61 |
+
"model_state_dict",
|
| 62 |
+
"normalization",
|
| 63 |
+
"epoch",
|
| 64 |
+
"best_val_relative_l1",
|
| 65 |
+
}
|
| 66 |
+
missing = sorted(required.difference(checkpoint))
|
| 67 |
+
if missing:
|
| 68 |
+
raise KeyError(f"checkpoint is missing required keys: {missing}")
|
| 69 |
+
checkpoint_config = checkpoint.get("config", {})
|
| 70 |
+
if checkpoint_config and checkpoint_config.get("model") != config["model"]:
|
| 71 |
+
raise ValueError("checkpoint model configuration differs from config.yaml")
|
| 72 |
+
model = build_model(config["model"]).to(device)
|
| 73 |
+
model.load_state_dict(checkpoint["model_state_dict"], strict=True)
|
| 74 |
+
model.eval()
|
| 75 |
+
normalizer = MinMaxNormalizer.from_state(checkpoint["normalization"])
|
| 76 |
+
return model, normalizer, checkpoint
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@torch.inference_mode()
|
| 80 |
+
def evaluate_split(
|
| 81 |
+
split_name: str,
|
| 82 |
+
model: torch.nn.Module,
|
| 83 |
+
loader: DataLoader,
|
| 84 |
+
normalizer: MinMaxNormalizer,
|
| 85 |
+
device: torch.device,
|
| 86 |
+
epsilon: float,
|
| 87 |
+
) -> tuple[dict[str, np.ndarray], dict[str, Any]]:
|
| 88 |
+
all_ids: list[np.ndarray] = []
|
| 89 |
+
all_inputs: list[np.ndarray] = []
|
| 90 |
+
all_targets: list[np.ndarray] = []
|
| 91 |
+
all_predictions: list[np.ndarray] = []
|
| 92 |
+
all_ratios: list[np.ndarray] = []
|
| 93 |
+
completed = 0
|
| 94 |
+
total = len(loader.dataset)
|
| 95 |
+
|
| 96 |
+
for batch_index, (inputs, targets, sample_ids) in enumerate(loader, start=1):
|
| 97 |
+
inputs = inputs.to(device, non_blocking=True)
|
| 98 |
+
targets = targets.to(device, non_blocking=True)
|
| 99 |
+
predictions = model(inputs)
|
| 100 |
+
physical_inputs = normalizer.denormalize_input(inputs)
|
| 101 |
+
physical_targets = normalizer.denormalize_output(targets)
|
| 102 |
+
physical_predictions = normalizer.denormalize_output(predictions)
|
| 103 |
+
ratios = relative_l1_per_sample(
|
| 104 |
+
physical_predictions, physical_targets, epsilon
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
all_ids.append(np.asarray(sample_ids, dtype=np.int64))
|
| 108 |
+
all_inputs.append(physical_inputs.cpu().numpy().astype(np.float32))
|
| 109 |
+
all_targets.append(physical_targets.cpu().numpy().astype(np.float32))
|
| 110 |
+
all_predictions.append(physical_predictions.cpu().numpy().astype(np.float32))
|
| 111 |
+
all_ratios.append(ratios.cpu().numpy().astype(np.float64))
|
| 112 |
+
completed += inputs.shape[0]
|
| 113 |
+
running = np.concatenate(all_ratios) * 100.0
|
| 114 |
+
print(
|
| 115 |
+
f"inference split={split_name} batch={batch_index}/{len(loader)} "
|
| 116 |
+
f"samples={completed}/{total} running_rel_l1_median={np.median(running):.6f}%",
|
| 117 |
+
flush=True,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
arrays = {
|
| 121 |
+
"sample_ids": np.concatenate(all_ids),
|
| 122 |
+
"inputs": np.concatenate(all_inputs),
|
| 123 |
+
"targets": np.concatenate(all_targets),
|
| 124 |
+
"predictions": np.concatenate(all_predictions),
|
| 125 |
+
"relative_l1": np.concatenate(all_ratios),
|
| 126 |
+
}
|
| 127 |
+
percentages = arrays["relative_l1"] * 100.0
|
| 128 |
+
metrics = {
|
| 129 |
+
"sample_count": int(percentages.size),
|
| 130 |
+
"resolution": [int(arrays["inputs"].shape[-2]), int(arrays["inputs"].shape[-1])],
|
| 131 |
+
"relative_l1_median_percent": float(np.median(percentages)),
|
| 132 |
+
"relative_l1_mean_percent": float(np.mean(percentages)),
|
| 133 |
+
"relative_l1_std_percent": float(np.std(percentages)),
|
| 134 |
+
"relative_l1_min_percent": float(np.min(percentages)),
|
| 135 |
+
"relative_l1_max_percent": float(np.max(percentages)),
|
| 136 |
+
}
|
| 137 |
+
return arrays, metrics
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def main() -> None:
|
| 141 |
+
args = parse_args()
|
| 142 |
+
config = load_config(args.config)
|
| 143 |
+
inference_config = config["inference"]
|
| 144 |
+
device = select_device(args.device or str(inference_config["device"]))
|
| 145 |
+
batch_size = int(
|
| 146 |
+
args.batch_size if args.batch_size is not None else inference_config["batch_size"]
|
| 147 |
+
)
|
| 148 |
+
if batch_size < 1:
|
| 149 |
+
raise ValueError("batch_size must be positive")
|
| 150 |
+
seed = int(config["experiment"]["seed"])
|
| 151 |
+
set_reproducibility(seed, bool(config["experiment"].get("deterministic", True)))
|
| 152 |
+
|
| 153 |
+
checkpoint_path = (
|
| 154 |
+
Path(args.checkpoint).expanduser().resolve()
|
| 155 |
+
if args.checkpoint
|
| 156 |
+
else project_path(config["paths"]["checkpoint"])
|
| 157 |
+
)
|
| 158 |
+
output_dir = (
|
| 159 |
+
Path(args.output_dir).expanduser().resolve()
|
| 160 |
+
if args.output_dir
|
| 161 |
+
else project_path(config["paths"]["results_dir"])
|
| 162 |
+
)
|
| 163 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 164 |
+
model, normalizer, checkpoint = load_trained_model(config, checkpoint_path, device)
|
| 165 |
+
|
| 166 |
+
split_specs = {
|
| 167 |
+
"id": ("id_test_file", "test_id"),
|
| 168 |
+
"ood": ("ood_test_file", "test_ood"),
|
| 169 |
+
}
|
| 170 |
+
all_metrics: dict[str, Any] = {
|
| 171 |
+
"schema_version": "cno-navier-stokes-metrics-v1",
|
| 172 |
+
"checkpoint": str(checkpoint_path),
|
| 173 |
+
"checkpoint_epoch": int(checkpoint["epoch"]),
|
| 174 |
+
"best_validation_relative_l1_percent": float(
|
| 175 |
+
checkpoint["best_val_relative_l1"]
|
| 176 |
+
),
|
| 177 |
+
"normalization": normalizer.state_dict(),
|
| 178 |
+
"paper_reference": config.get("paper_reference", {}),
|
| 179 |
+
"splits": {},
|
| 180 |
+
}
|
| 181 |
+
print(
|
| 182 |
+
f"inference device={device} checkpoint_epoch={checkpoint['epoch']} "
|
| 183 |
+
f"best_val={float(checkpoint['best_val_relative_l1']):.6f}%",
|
| 184 |
+
flush=True,
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
for split_name in args.splits:
|
| 188 |
+
filename_key, split_key = split_specs[split_name]
|
| 189 |
+
dataset = NavierStokesH5Dataset(
|
| 190 |
+
data_file(config, filename_key),
|
| 191 |
+
numeric_sample_ids(config["data"][split_key]),
|
| 192 |
+
normalizer,
|
| 193 |
+
str(config["data"]["input_key"]),
|
| 194 |
+
str(config["data"]["output_key"]),
|
| 195 |
+
)
|
| 196 |
+
loader = DataLoader(
|
| 197 |
+
dataset,
|
| 198 |
+
batch_size=batch_size,
|
| 199 |
+
shuffle=False,
|
| 200 |
+
num_workers=int(inference_config["num_workers"]),
|
| 201 |
+
pin_memory=device.type == "cuda",
|
| 202 |
+
persistent_workers=int(inference_config["num_workers"]) > 0,
|
| 203 |
+
)
|
| 204 |
+
arrays, metrics = evaluate_split(
|
| 205 |
+
split_name,
|
| 206 |
+
model,
|
| 207 |
+
loader,
|
| 208 |
+
normalizer,
|
| 209 |
+
device,
|
| 210 |
+
float(inference_config["metric_epsilon"]),
|
| 211 |
+
)
|
| 212 |
+
artifact_path = output_dir / f"{split_name}_predictions.npz"
|
| 213 |
+
atomic_npz_save(artifact_path, **arrays)
|
| 214 |
+
metrics["predictions_file"] = str(artifact_path)
|
| 215 |
+
all_metrics["splits"][split_name] = metrics
|
| 216 |
+
print(
|
| 217 |
+
f"evaluation split={split_name} n={metrics['sample_count']} "
|
| 218 |
+
f"rel_l1_median={metrics['relative_l1_median_percent']:.6f}% "
|
| 219 |
+
f"mean={metrics['relative_l1_mean_percent']:.6f}% "
|
| 220 |
+
f"std={metrics['relative_l1_std_percent']:.6f}% "
|
| 221 |
+
f"saved={artifact_path}",
|
| 222 |
+
flush=True,
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
metrics_path = output_dir / "metrics.json"
|
| 226 |
+
atomic_json_dump(all_metrics, metrics_path)
|
| 227 |
+
print(f"metrics saved path={metrics_path}", flush=True)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
if __name__ == "__main__":
|
| 231 |
+
main()
|
scripts/result.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Visualize CNO fields and ID/OOD relative-L1 distributions."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import json
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import matplotlib
|
| 11 |
+
|
| 12 |
+
matplotlib.use("Agg")
|
| 13 |
+
import matplotlib.pyplot as plt
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def parse_args() -> argparse.Namespace:
|
| 21 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 22 |
+
parser.add_argument("--results-dir", default=str(PROJECT_ROOT / "results"))
|
| 23 |
+
parser.add_argument(
|
| 24 |
+
"--sample-index",
|
| 25 |
+
type=int,
|
| 26 |
+
default=None,
|
| 27 |
+
help="array index to plot; default is the sample nearest the split median error",
|
| 28 |
+
)
|
| 29 |
+
parser.add_argument("--dpi", type=int, default=180)
|
| 30 |
+
return parser.parse_args()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def load_predictions(path: Path) -> dict[str, np.ndarray]:
|
| 34 |
+
if not path.is_file():
|
| 35 |
+
raise FileNotFoundError(f"prediction artifact not found: {path}")
|
| 36 |
+
with np.load(path) as payload:
|
| 37 |
+
required = {"sample_ids", "inputs", "targets", "predictions", "relative_l1"}
|
| 38 |
+
missing = sorted(required.difference(payload.files))
|
| 39 |
+
if missing:
|
| 40 |
+
raise KeyError(f"{path} is missing arrays: {missing}")
|
| 41 |
+
arrays = {key: np.asarray(payload[key]) for key in required}
|
| 42 |
+
count = arrays["sample_ids"].shape[0]
|
| 43 |
+
for key in ("inputs", "targets", "predictions", "relative_l1"):
|
| 44 |
+
if arrays[key].shape[0] != count:
|
| 45 |
+
raise ValueError(f"sample count mismatch for {key} in {path}")
|
| 46 |
+
if not np.isfinite(arrays[key]).all():
|
| 47 |
+
raise ValueError(f"nonfinite values in {key} from {path}")
|
| 48 |
+
if arrays["inputs"].ndim != 4 or arrays["inputs"].shape[1] != 1:
|
| 49 |
+
raise ValueError(f"expected N1HW fields in {path}, got {arrays['inputs'].shape}")
|
| 50 |
+
return arrays
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def representative_index(errors: np.ndarray, requested: int | None) -> int:
|
| 54 |
+
if requested is not None:
|
| 55 |
+
if requested < 0 or requested >= errors.size:
|
| 56 |
+
raise IndexError(f"sample-index {requested} outside [0,{errors.size})")
|
| 57 |
+
return requested
|
| 58 |
+
median = np.median(errors)
|
| 59 |
+
return int(np.argmin(np.abs(errors - median)))
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def plot_fields(
|
| 63 |
+
split: str,
|
| 64 |
+
arrays: dict[str, np.ndarray],
|
| 65 |
+
output_path: Path,
|
| 66 |
+
requested_index: int | None,
|
| 67 |
+
dpi: int,
|
| 68 |
+
) -> None:
|
| 69 |
+
errors_percent = arrays["relative_l1"] * 100.0
|
| 70 |
+
index = representative_index(errors_percent, requested_index)
|
| 71 |
+
input_field = arrays["inputs"][index, 0]
|
| 72 |
+
target = arrays["targets"][index, 0]
|
| 73 |
+
prediction = arrays["predictions"][index, 0]
|
| 74 |
+
absolute_error = np.abs(prediction - target)
|
| 75 |
+
field_min = float(min(target.min(), prediction.min()))
|
| 76 |
+
field_max = float(max(target.max(), prediction.max()))
|
| 77 |
+
|
| 78 |
+
fig, axes = plt.subplots(1, 4, figsize=(16, 3.8), constrained_layout=True)
|
| 79 |
+
input_image = axes[0].imshow(input_field, origin="lower", cmap="RdBu_r")
|
| 80 |
+
fig.colorbar(input_image, ax=axes[0], shrink=0.78)
|
| 81 |
+
target_image = axes[1].imshow(
|
| 82 |
+
target, origin="lower", cmap="RdBu_r", vmin=field_min, vmax=field_max
|
| 83 |
+
)
|
| 84 |
+
prediction_image = axes[2].imshow(
|
| 85 |
+
prediction, origin="lower", cmap="RdBu_r", vmin=field_min, vmax=field_max
|
| 86 |
+
)
|
| 87 |
+
error_image = axes[3].imshow(absolute_error, origin="lower", cmap="magma")
|
| 88 |
+
fig.colorbar(target_image, ax=[axes[1], axes[2]], shrink=0.78)
|
| 89 |
+
fig.colorbar(error_image, ax=axes[3], shrink=0.78)
|
| 90 |
+
titles = ("Initial velocity", "Target at T=1", "CNO prediction", "Absolute error")
|
| 91 |
+
for axis, title in zip(axes, titles):
|
| 92 |
+
axis.set_title(title)
|
| 93 |
+
axis.set_xlabel("x index")
|
| 94 |
+
axis.set_ylabel("y index")
|
| 95 |
+
sample_id = int(arrays["sample_ids"][index])
|
| 96 |
+
fig.suptitle(
|
| 97 |
+
f"{split.upper()} Sample_{sample_id} — relative L1={errors_percent[index]:.3f}%"
|
| 98 |
+
)
|
| 99 |
+
fig.savefig(output_path, dpi=dpi)
|
| 100 |
+
plt.close(fig)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def plot_distribution(
|
| 104 |
+
split_arrays: dict[str, dict[str, np.ndarray]],
|
| 105 |
+
paper_reference: dict,
|
| 106 |
+
output_path: Path,
|
| 107 |
+
dpi: int,
|
| 108 |
+
) -> None:
|
| 109 |
+
fig, axes = plt.subplots(1, 2, figsize=(11, 4), constrained_layout=True)
|
| 110 |
+
colors = {"id": "#2166ac", "ood": "#b2182b"}
|
| 111 |
+
for split in ("id", "ood"):
|
| 112 |
+
values = split_arrays[split]["relative_l1"] * 100.0
|
| 113 |
+
axes[0].hist(values, bins=20, alpha=0.55, label=split.upper(), color=colors[split])
|
| 114 |
+
axes[0].axvline(
|
| 115 |
+
np.median(values), color=colors[split], linewidth=2, linestyle="-"
|
| 116 |
+
)
|
| 117 |
+
reference = paper_reference.get(split)
|
| 118 |
+
if reference is not None:
|
| 119 |
+
axes[0].axvline(
|
| 120 |
+
float(reference), color=colors[split], linewidth=1.5, linestyle="--"
|
| 121 |
+
)
|
| 122 |
+
axes[0].set_title("Per-sample relative L1")
|
| 123 |
+
axes[0].set_xlabel("Relative L1 (%)")
|
| 124 |
+
axes[0].set_ylabel("Count")
|
| 125 |
+
axes[0].legend(title="solid=reproduction\ndashed=paper")
|
| 126 |
+
|
| 127 |
+
values = [
|
| 128 |
+
split_arrays["id"]["relative_l1"] * 100.0,
|
| 129 |
+
split_arrays["ood"]["relative_l1"] * 100.0,
|
| 130 |
+
]
|
| 131 |
+
box = axes[1].boxplot(values, tick_labels=["ID", "OOD"], patch_artist=True)
|
| 132 |
+
for patch, color in zip(box["boxes"], (colors["id"], colors["ood"])):
|
| 133 |
+
patch.set_facecolor(color)
|
| 134 |
+
patch.set_alpha(0.55)
|
| 135 |
+
axes[1].set_title("Error distribution summary")
|
| 136 |
+
axes[1].set_ylabel("Relative L1 (%)")
|
| 137 |
+
fig.savefig(output_path, dpi=dpi)
|
| 138 |
+
plt.close(fig)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def main() -> None:
|
| 142 |
+
args = parse_args()
|
| 143 |
+
results_dir = Path(args.results_dir).expanduser().resolve()
|
| 144 |
+
metrics_path = results_dir / "metrics.json"
|
| 145 |
+
if not metrics_path.is_file():
|
| 146 |
+
raise FileNotFoundError(f"metrics file not found: {metrics_path}")
|
| 147 |
+
with metrics_path.open("r", encoding="utf-8") as handle:
|
| 148 |
+
metrics = json.load(handle)
|
| 149 |
+
|
| 150 |
+
split_arrays = {
|
| 151 |
+
split: load_predictions(results_dir / f"{split}_predictions.npz")
|
| 152 |
+
for split in ("id", "ood")
|
| 153 |
+
}
|
| 154 |
+
for split, arrays in split_arrays.items():
|
| 155 |
+
output_path = results_dir / f"{split}_fields.png"
|
| 156 |
+
plot_fields(split, arrays, output_path, args.sample_index, args.dpi)
|
| 157 |
+
values = arrays["relative_l1"] * 100.0
|
| 158 |
+
print(
|
| 159 |
+
f"visualization split={split} median={np.median(values):.6f}% "
|
| 160 |
+
f"mean={np.mean(values):.6f}% saved={output_path}",
|
| 161 |
+
flush=True,
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
distribution_path = results_dir / "error_distribution.png"
|
| 165 |
+
plot_distribution(
|
| 166 |
+
split_arrays,
|
| 167 |
+
metrics.get("paper_reference", {}),
|
| 168 |
+
distribution_path,
|
| 169 |
+
args.dpi,
|
| 170 |
+
)
|
| 171 |
+
print(f"visualization saved={distribution_path}", flush=True)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
if __name__ == "__main__":
|
| 175 |
+
main()
|
scripts/train.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Train the paper-configured CNO on the 2-D Navier--Stokes benchmark."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import platform
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn.functional as F
|
| 15 |
+
from torch.utils.data import DataLoader
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 19 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 20 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 21 |
+
|
| 22 |
+
from models.FNO import build_model, count_trainable_parameters
|
| 23 |
+
from scripts.common import (
|
| 24 |
+
MinMaxNormalizer,
|
| 25 |
+
NavierStokesH5Dataset,
|
| 26 |
+
atomic_json_dump,
|
| 27 |
+
atomic_torch_save,
|
| 28 |
+
data_file,
|
| 29 |
+
load_config,
|
| 30 |
+
numeric_sample_ids,
|
| 31 |
+
project_path,
|
| 32 |
+
relative_l1_per_sample,
|
| 33 |
+
select_device,
|
| 34 |
+
set_reproducibility,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def parse_args() -> argparse.Namespace:
|
| 39 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 40 |
+
parser.add_argument(
|
| 41 |
+
"--config",
|
| 42 |
+
default=str(PROJECT_ROOT / "config" / "config.yaml"),
|
| 43 |
+
help="experiment YAML configuration",
|
| 44 |
+
)
|
| 45 |
+
parser.add_argument("--device", default=None, help="override training.device")
|
| 46 |
+
parser.add_argument("--epochs", type=int, default=None, help="override training.epochs")
|
| 47 |
+
parser.add_argument(
|
| 48 |
+
"--resume",
|
| 49 |
+
default=None,
|
| 50 |
+
help="resume a complete training state; not a weight-only initialization",
|
| 51 |
+
)
|
| 52 |
+
return parser.parse_args()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _make_loader(
|
| 56 |
+
dataset: NavierStokesH5Dataset,
|
| 57 |
+
batch_size: int,
|
| 58 |
+
workers: int,
|
| 59 |
+
shuffle: bool,
|
| 60 |
+
seed: int,
|
| 61 |
+
device: torch.device,
|
| 62 |
+
) -> DataLoader:
|
| 63 |
+
generator = torch.Generator()
|
| 64 |
+
generator.manual_seed(seed)
|
| 65 |
+
return DataLoader(
|
| 66 |
+
dataset,
|
| 67 |
+
batch_size=batch_size,
|
| 68 |
+
shuffle=shuffle,
|
| 69 |
+
num_workers=workers,
|
| 70 |
+
pin_memory=device.type == "cuda",
|
| 71 |
+
persistent_workers=workers > 0,
|
| 72 |
+
generator=generator,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@torch.inference_mode()
|
| 77 |
+
def validate(
|
| 78 |
+
model: torch.nn.Module,
|
| 79 |
+
loader: DataLoader,
|
| 80 |
+
normalizer: MinMaxNormalizer,
|
| 81 |
+
device: torch.device,
|
| 82 |
+
epsilon: float,
|
| 83 |
+
) -> dict[str, float]:
|
| 84 |
+
model.eval()
|
| 85 |
+
ratios: list[torch.Tensor] = []
|
| 86 |
+
for inputs, targets, _ in loader:
|
| 87 |
+
inputs = inputs.to(device, non_blocking=True)
|
| 88 |
+
targets = targets.to(device, non_blocking=True)
|
| 89 |
+
predictions = model(inputs)
|
| 90 |
+
predictions = normalizer.denormalize_output(predictions)
|
| 91 |
+
targets = normalizer.denormalize_output(targets)
|
| 92 |
+
ratios.append(relative_l1_per_sample(predictions, targets, epsilon).cpu())
|
| 93 |
+
values = torch.cat(ratios).numpy() * 100.0
|
| 94 |
+
return {
|
| 95 |
+
"median_percent": float(np.median(values)),
|
| 96 |
+
"mean_percent": float(np.mean(values)),
|
| 97 |
+
"std_percent": float(np.std(values)),
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _resume_training(
|
| 102 |
+
path: Path,
|
| 103 |
+
model: torch.nn.Module,
|
| 104 |
+
optimizer: torch.optim.Optimizer,
|
| 105 |
+
scheduler: torch.optim.lr_scheduler.LRScheduler,
|
| 106 |
+
normalizer: MinMaxNormalizer,
|
| 107 |
+
device: torch.device,
|
| 108 |
+
) -> tuple[int, float, int, list[dict[str, Any]]]:
|
| 109 |
+
if not path.is_file():
|
| 110 |
+
raise FileNotFoundError(f"resume checkpoint not found: {path}")
|
| 111 |
+
state = torch.load(path, map_location=device, weights_only=False)
|
| 112 |
+
required = {
|
| 113 |
+
"model_state_dict",
|
| 114 |
+
"optimizer_state_dict",
|
| 115 |
+
"scheduler_state_dict",
|
| 116 |
+
"epoch",
|
| 117 |
+
"best_val_relative_l1",
|
| 118 |
+
"normalization",
|
| 119 |
+
}
|
| 120 |
+
missing = sorted(required.difference(state))
|
| 121 |
+
if missing:
|
| 122 |
+
raise KeyError(f"resume checkpoint is missing keys: {missing}")
|
| 123 |
+
checkpoint_normalizer = MinMaxNormalizer.from_state(state["normalization"])
|
| 124 |
+
if checkpoint_normalizer != normalizer:
|
| 125 |
+
raise ValueError("resume checkpoint normalization differs from config")
|
| 126 |
+
model.load_state_dict(state["model_state_dict"], strict=True)
|
| 127 |
+
optimizer.load_state_dict(state["optimizer_state_dict"])
|
| 128 |
+
scheduler.load_state_dict(state["scheduler_state_dict"])
|
| 129 |
+
return (
|
| 130 |
+
int(state["epoch"]) + 1,
|
| 131 |
+
float(state["best_val_relative_l1"]),
|
| 132 |
+
int(state.get("bad_epochs", 0)),
|
| 133 |
+
list(state.get("history", [])),
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def main() -> None:
|
| 138 |
+
args = parse_args()
|
| 139 |
+
config = load_config(args.config)
|
| 140 |
+
train_config = config["training"]
|
| 141 |
+
epochs = int(args.epochs if args.epochs is not None else train_config["epochs"])
|
| 142 |
+
if epochs < 1:
|
| 143 |
+
raise ValueError("epochs must be positive")
|
| 144 |
+
device = select_device(args.device or str(train_config["device"]))
|
| 145 |
+
seed = int(config["experiment"]["seed"])
|
| 146 |
+
set_reproducibility(seed, bool(config["experiment"].get("deterministic", True)))
|
| 147 |
+
normalizer = MinMaxNormalizer.from_config(config)
|
| 148 |
+
|
| 149 |
+
source = data_file(config, "train_file")
|
| 150 |
+
train_dataset = NavierStokesH5Dataset(
|
| 151 |
+
source,
|
| 152 |
+
numeric_sample_ids(config["data"]["train"]),
|
| 153 |
+
normalizer,
|
| 154 |
+
str(config["data"]["input_key"]),
|
| 155 |
+
str(config["data"]["output_key"]),
|
| 156 |
+
)
|
| 157 |
+
validation_dataset = NavierStokesH5Dataset(
|
| 158 |
+
source,
|
| 159 |
+
numeric_sample_ids(config["data"]["validation"]),
|
| 160 |
+
normalizer,
|
| 161 |
+
str(config["data"]["input_key"]),
|
| 162 |
+
str(config["data"]["output_key"]),
|
| 163 |
+
)
|
| 164 |
+
batch_size = int(train_config["batch_size"])
|
| 165 |
+
workers = int(train_config["num_workers"])
|
| 166 |
+
train_loader = _make_loader(train_dataset, batch_size, workers, True, seed, device)
|
| 167 |
+
validation_loader = _make_loader(
|
| 168 |
+
validation_dataset, batch_size, workers, False, seed, device
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
model = build_model(config["model"]).to(device)
|
| 172 |
+
parameter_count = count_trainable_parameters(model)
|
| 173 |
+
optimizer_name = str(train_config["optimizer"])
|
| 174 |
+
if optimizer_name != "Adam":
|
| 175 |
+
raise ValueError(f"paper reproduction requires Adam, got {optimizer_name}")
|
| 176 |
+
optimizer = torch.optim.Adam(
|
| 177 |
+
model.parameters(),
|
| 178 |
+
lr=float(train_config["learning_rate"]),
|
| 179 |
+
weight_decay=float(train_config["weight_decay"]),
|
| 180 |
+
)
|
| 181 |
+
if str(train_config["scheduler"]) != "StepLR":
|
| 182 |
+
raise ValueError("paper reproduction requires StepLR")
|
| 183 |
+
scheduler = torch.optim.lr_scheduler.StepLR(
|
| 184 |
+
optimizer,
|
| 185 |
+
step_size=int(train_config["scheduler_step_size"]),
|
| 186 |
+
gamma=float(train_config["scheduler_gamma"]),
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
start_epoch = 1
|
| 190 |
+
best_validation = float("inf")
|
| 191 |
+
bad_epochs = 0
|
| 192 |
+
history: list[dict[str, Any]] = []
|
| 193 |
+
if args.resume:
|
| 194 |
+
start_epoch, best_validation, bad_epochs, history = _resume_training(
|
| 195 |
+
Path(args.resume).expanduser().resolve(),
|
| 196 |
+
model,
|
| 197 |
+
optimizer,
|
| 198 |
+
scheduler,
|
| 199 |
+
normalizer,
|
| 200 |
+
device,
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
checkpoint_path = project_path(config["paths"]["checkpoint"])
|
| 204 |
+
results_dir = project_path(config["paths"]["results_dir"])
|
| 205 |
+
history_path = results_dir / "training_history.json"
|
| 206 |
+
patience = int(train_config["early_stopping_patience"])
|
| 207 |
+
log_interval = max(1, int(train_config["log_interval"]))
|
| 208 |
+
|
| 209 |
+
print(
|
| 210 |
+
f"experiment={config['experiment']['name']} device={device} "
|
| 211 |
+
f"python={platform.python_version()} torch={torch.__version__}",
|
| 212 |
+
flush=True,
|
| 213 |
+
)
|
| 214 |
+
print(
|
| 215 |
+
f"train_samples={len(train_dataset)} val_samples={len(validation_dataset)} "
|
| 216 |
+
f"batch_size={batch_size} parameters={parameter_count:,} "
|
| 217 |
+
f"checkpoint={checkpoint_path}",
|
| 218 |
+
flush=True,
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
for epoch in range(start_epoch, epochs + 1):
|
| 222 |
+
model.train()
|
| 223 |
+
loss_sum = 0.0
|
| 224 |
+
sample_count = 0
|
| 225 |
+
learning_rate = float(optimizer.param_groups[0]["lr"])
|
| 226 |
+
for batch_index, (inputs, targets, _) in enumerate(train_loader, start=1):
|
| 227 |
+
inputs = inputs.to(device, non_blocking=True)
|
| 228 |
+
targets = targets.to(device, non_blocking=True)
|
| 229 |
+
optimizer.zero_grad(set_to_none=True)
|
| 230 |
+
predictions = model(inputs)
|
| 231 |
+
loss = F.l1_loss(predictions, targets)
|
| 232 |
+
if not torch.isfinite(loss):
|
| 233 |
+
raise FloatingPointError(
|
| 234 |
+
f"nonfinite training loss at epoch={epoch}, batch={batch_index}: {loss}"
|
| 235 |
+
)
|
| 236 |
+
loss.backward()
|
| 237 |
+
optimizer.step()
|
| 238 |
+
batch_samples = inputs.shape[0]
|
| 239 |
+
loss_sum += float(loss.detach()) * batch_samples
|
| 240 |
+
sample_count += batch_samples
|
| 241 |
+
if batch_index % log_interval == 0 or batch_index == len(train_loader):
|
| 242 |
+
print(
|
| 243 |
+
f"train epoch={epoch}/{epochs} batch={batch_index}/{len(train_loader)} "
|
| 244 |
+
f"loss={float(loss.detach()):.8f} running_loss={loss_sum/sample_count:.8f}",
|
| 245 |
+
flush=True,
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
train_loss = loss_sum / sample_count
|
| 249 |
+
validation = validate(
|
| 250 |
+
model,
|
| 251 |
+
validation_loader,
|
| 252 |
+
normalizer,
|
| 253 |
+
device,
|
| 254 |
+
float(config["normalization"]["epsilon"]),
|
| 255 |
+
)
|
| 256 |
+
scheduler.step()
|
| 257 |
+
improved = validation["median_percent"] < best_validation
|
| 258 |
+
if improved:
|
| 259 |
+
best_validation = validation["median_percent"]
|
| 260 |
+
bad_epochs = 0
|
| 261 |
+
else:
|
| 262 |
+
bad_epochs += 1
|
| 263 |
+
|
| 264 |
+
record = {
|
| 265 |
+
"epoch": epoch,
|
| 266 |
+
"learning_rate": learning_rate,
|
| 267 |
+
"train_l1": train_loss,
|
| 268 |
+
"validation_relative_l1_median_percent": validation["median_percent"],
|
| 269 |
+
"validation_relative_l1_mean_percent": validation["mean_percent"],
|
| 270 |
+
"validation_relative_l1_std_percent": validation["std_percent"],
|
| 271 |
+
"best_validation_percent": best_validation,
|
| 272 |
+
}
|
| 273 |
+
history.append(record)
|
| 274 |
+
print(
|
| 275 |
+
f"eval epoch={epoch}/{epochs} lr={learning_rate:.8g} "
|
| 276 |
+
f"train_l1={train_loss:.8f} "
|
| 277 |
+
f"val_rel_l1_median={validation['median_percent']:.6f}% "
|
| 278 |
+
f"val_rel_l1_mean={validation['mean_percent']:.6f}% "
|
| 279 |
+
f"best={best_validation:.6f}% bad_epochs={bad_epochs}/{patience}",
|
| 280 |
+
flush=True,
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
if improved:
|
| 284 |
+
checkpoint = {
|
| 285 |
+
"schema_version": "cno-navier-stokes-checkpoint-v1",
|
| 286 |
+
"model_state_dict": model.state_dict(),
|
| 287 |
+
"optimizer_state_dict": optimizer.state_dict(),
|
| 288 |
+
"scheduler_state_dict": scheduler.state_dict(),
|
| 289 |
+
"epoch": epoch,
|
| 290 |
+
"best_val_relative_l1": best_validation,
|
| 291 |
+
"bad_epochs": bad_epochs,
|
| 292 |
+
"normalization": normalizer.state_dict(),
|
| 293 |
+
"config": config,
|
| 294 |
+
"seed": seed,
|
| 295 |
+
"parameter_count": parameter_count,
|
| 296 |
+
"history": history,
|
| 297 |
+
}
|
| 298 |
+
atomic_torch_save(checkpoint, checkpoint_path)
|
| 299 |
+
print(
|
| 300 |
+
f"checkpoint saved path={checkpoint_path} "
|
| 301 |
+
f"val_rel_l1_median={best_validation:.6f}%",
|
| 302 |
+
flush=True,
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
atomic_json_dump(
|
| 306 |
+
{
|
| 307 |
+
"experiment": config["experiment"],
|
| 308 |
+
"device": str(device),
|
| 309 |
+
"parameter_count": parameter_count,
|
| 310 |
+
"normalization": normalizer.state_dict(),
|
| 311 |
+
"best_validation_percent": best_validation,
|
| 312 |
+
"history": history,
|
| 313 |
+
},
|
| 314 |
+
history_path,
|
| 315 |
+
)
|
| 316 |
+
if bad_epochs >= patience:
|
| 317 |
+
print(
|
| 318 |
+
f"early stopping at epoch={epoch}; no improvement for {patience} epochs",
|
| 319 |
+
flush=True,
|
| 320 |
+
)
|
| 321 |
+
break
|
| 322 |
+
|
| 323 |
+
print(
|
| 324 |
+
f"training complete best_val_rel_l1_median={best_validation:.6f}% "
|
| 325 |
+
f"checkpoint={checkpoint_path}",
|
| 326 |
+
flush=True,
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
if __name__ == "__main__":
|
| 331 |
+
main()
|
weight/best_model.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:630ad36593abc8fcd55a79b5fd25add748236f087f66eca551576f21e8d552ea
|
| 3 |
+
size 39430846
|