yushuang88 commited on
Commit
cb18693
·
verified ·
1 Parent(s): a9ee3cf

Upload folder using huggingface_hub

Browse files
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