yzt15806542928 commited on
Commit
eca4864
·
verified ·
1 Parent(s): 660d16f

Upload folder using huggingface_hub

Browse files
README.md ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ frameworks: PyTorch
3
+ language:
4
+ - en
5
+ license: apache-2.0
6
+ tags:
7
+ - OneScience
8
+ - Earth Science
9
+ - Weather Forecast
10
+ - Short-to-Medium-Range Weather Forecast
11
+ - ERA5
12
+ - FourCastNet
13
+ - SFNO
14
+ tasks: []
15
+ datasets:
16
+ - OneScience/ERA5
17
+ ---
18
+ <p align="center">
19
+ <strong>
20
+ <span style="font-size: 30px;">FourCastNet_v2</span>
21
+ </strong>
22
+ </p>
23
+
24
+ # Model Introduction
25
+
26
+ FourCastNet v2 is a global weather forecast model based on the Spherical Fourier Neural Operator (SFNO), proposed by NVIDIA and its collaborators.
27
+
28
+ Paper: Spherical Fourier Neural Operators: Learning Stable Dynamics on the Sphere
29
+
30
+ https://arxiv.org/abs/2306.03838
31
+
32
+ # Model Description
33
+
34
+ The key architectural change from v1 is replacing the Adaptive Fourier Neural Operator (AFNO) with the Spherical Fourier Neural Operator (SFNO).
35
+
36
+ # Use Cases
37
+
38
+ | Scenario | Description |
39
+ | :---: | :--- |
40
+ | Global weather forecast training | Train an SFNO-style FourCastNet v2 model with 73-channel ERA5 HDF5 data. |
41
+ | Local quick validation | Use synthetic ERA5 files to check the training, inference, and result-visualization pipeline. |
42
+ | ModelScope / OneCode execution | Download the standalone model package, install dependencies, and run the scripts directly. |
43
+ | Multi-GPU training | Launch PyTorch DDP with `torchrun`. |
44
+
45
+ # Usage Guide
46
+
47
+ ## 1. OneCode Usage
48
+
49
+ Experience intelligent one-click AI4S programming through the OneCode online environment:
50
+
51
+ [Click to Experience Intelligent One-Click AI4S Programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
52
+
53
+ ## 2. Manual Installation and Usage
54
+
55
+ **Hardware Requirements**
56
+
57
+ - A GPU or DCU is recommended.
58
+ - CPU can be used for import and small-scale connectivity verification; full training and inference will be slow.
59
+ - DCU users must install DTK in advance. DTK 25.04.2 or above, or the OneScience recommended version matching your cluster, is recommended.
60
+
61
+ ### Download the Model Package
62
+
63
+ ```bash
64
+ hf download OneScience-Group/FourCastNet_v2 --local-dir ./FourCastNet_v2
65
+ cd FourCastNet_v2
66
+ ```
67
+
68
+ ### Install the Runtime Environment
69
+
70
+ **DCU Environment**
71
+
72
+ ```bash
73
+ # Please activate DTK and CONDA first
74
+ conda create -n onescience311 python=3.11 -y
75
+ conda activate onescience311
76
+ # uv installation is supported
77
+ pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
78
+ ```
79
+
80
+ **GPU Environment**
81
+ ```bash
82
+ # Please activate CONDA first
83
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
84
+ conda activate onescience311
85
+ # uv installation is supported
86
+ pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
87
+ ```
88
+
89
+ ### Training Data Introduction
90
+
91
+ The OneScience community provides an ERA5 data slice that can be downloaded as follows:
92
+
93
+ ```bash
94
+ hf download --repo-type dataset OneScience-Group/ERA5 --local-dir ./data
95
+ ```
96
+
97
+ Real HDF5 annual files must contain `fields`, variable attributes, `time_step`, `global_means`, and `global_stds`. When real data is unavailable, first generate synthetic files for pipeline validation:
98
+
99
+ ```bash
100
+ python scripts/fake_data.py
101
+ ```
102
+
103
+ ### Training
104
+
105
+ Single GPU:
106
+
107
+ ```bash
108
+ python scripts/train.py
109
+ ```
110
+
111
+ Multi-GPU:
112
+
113
+ ```bash
114
+ torchrun --nproc_per_node=8 scripts/train.py
115
+ ```
116
+
117
+ The default checkpoint is saved to `data/checkpoint/one_step/model_bak.pt`.
118
+
119
+ ### Fine-tuning
120
+
121
+ Single GPU:
122
+
123
+ ```bash
124
+ python scripts/train.py --stage finetune
125
+ ```
126
+
127
+ Multi-GPU:
128
+
129
+ ```bash
130
+ torchrun --nproc_per_node=8 scripts/train.py --stage finetune
131
+ ```
132
+
133
+ The checkpoint is saved to `data/checkpoint/<stage>/model_bak.pt` by default.
134
+
135
+ ### Training Weights
136
+
137
+ This repository provides weights trained on ERA5 reanalysis data in the `weight/` folder. The weight files will be uploaded soon and are expected to be available in the near future.
138
+
139
+ ### Inference
140
+
141
+ ```bash
142
+ python scripts/inference.py
143
+ ```
144
+
145
+ Prediction results are written to `result/output/` by default.
146
+
147
+ ### Evaluation and Visualization
148
+
149
+ ```bash
150
+ python scripts/result.py
151
+ ```
152
+
153
+ The default output includes latitude-weighted RMSE/ACC metrics and `result/figures/t2m_forecast.png`.
154
+
155
+ # Official OneScience Resources
156
+
157
+ | Platform | OneScience Main Repository | Skills Repository |
158
+ | --- | --- | --- |
159
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
160
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
161
+
162
+ # Citation and License
163
+
164
+ - The SFNO numerical implementation of FourCastNet v2 follows the design of NVIDIA Earth2MIP and related official implementations. The upstream code and model licenses and copyright notices must be retained.
conf/config.yaml ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ project:
2
+ name: FourCastNet_v2
3
+ seed: 42
4
+ output_dir: ./result
5
+ checkpoint_dir: ./data/checkpoint
6
+
7
+ data:
8
+ dataset_dir: ./data/era5_fake
9
+ train_years: [2014,2015]
10
+ val_years: [2016]
11
+ test_years: [2018]
12
+ official_splits:
13
+ train_years: [2014, 2015]
14
+ val_years: [2016, 2017]
15
+ test_years: [2018]
16
+ input_steps: 1
17
+ output_steps: 1
18
+ time_step_hours: 6
19
+ grid_shape: [721, 1440]
20
+ normalize: true
21
+ variables:
22
+ - u10m
23
+ - v10m
24
+ - u100m
25
+ - v100m
26
+ - t2m
27
+ - sp
28
+ - msl
29
+ - tcwv
30
+ - u50
31
+ - u100
32
+ - u150
33
+ - u200
34
+ - u250
35
+ - u300
36
+ - u400
37
+ - u500
38
+ - u600
39
+ - u700
40
+ - u850
41
+ - u925
42
+ - u1000
43
+ - v50
44
+ - v100
45
+ - v150
46
+ - v200
47
+ - v250
48
+ - v300
49
+ - v400
50
+ - v500
51
+ - v600
52
+ - v700
53
+ - v850
54
+ - v925
55
+ - v1000
56
+ - z50
57
+ - z100
58
+ - z150
59
+ - z200
60
+ - z250
61
+ - z300
62
+ - z400
63
+ - z500
64
+ - z600
65
+ - z700
66
+ - z850
67
+ - z925
68
+ - z1000
69
+ - t50
70
+ - t100
71
+ - t150
72
+ - t200
73
+ - t250
74
+ - t300
75
+ - t400
76
+ - t500
77
+ - t600
78
+ - t700
79
+ - t850
80
+ - t925
81
+ - t1000
82
+ - r50
83
+ - r100
84
+ - r150
85
+ - r200
86
+ - r250
87
+ - r300
88
+ - r400
89
+ - r500
90
+ - r600
91
+ - r700
92
+ - r850
93
+ - r925
94
+ - r1000
95
+
96
+ fake_data:
97
+ time_steps_per_year: 20
98
+ chunk_time_steps: 1
99
+ fill_value: 0.0
100
+ materialize_pattern: true
101
+
102
+ model:
103
+ profile: smoke
104
+ profiles:
105
+ full_resolution:
106
+ img_size: [721, 1440]
107
+ in_channels: 73
108
+ out_channels: 73
109
+ spectral_transform: sht
110
+ filter_type: non-linear
111
+ scale_factor: 6
112
+ embed_dim: 256
113
+ num_layers: 12
114
+ num_blocks: 8
115
+ normalization_layer: instance_norm
116
+ mlp_mode: distributed
117
+ spectral_layers: 3
118
+ complex_activation: real
119
+ hard_thresholding_fraction: 1.0
120
+ big_skip: true
121
+ smoke:
122
+ img_size: [16, 32]
123
+ in_channels: 73
124
+ out_channels: 73
125
+ spectral_transform: sht
126
+ filter_type: linear
127
+ scale_factor: 4
128
+ embed_dim: 8
129
+ num_layers: 2
130
+ num_blocks: 1
131
+ normalization_layer: instance_norm
132
+ mlp_mode: serial
133
+ spectral_layers: 1
134
+ complex_activation: real
135
+ hard_thresholding_fraction: 0.5
136
+ big_skip: true
137
+ # The smoke profile keeps the same equations but reduces the grid/model.
138
+
139
+ checkpoint:
140
+ initialize_from: scratch
141
+ prefix: model_bak
142
+ finetune_from: ./data/checkpoint/one_step/model_bak.pt
143
+ strict: true
144
+
145
+ training:
146
+ stage: one_step
147
+ epochs: 3
148
+ batch_size: 1
149
+ num_workers: 0
150
+ learning_rate: 0.0006
151
+ weight_decay: 0.0
152
+ optimizer_betas: [0.9, 0.95]
153
+ max_grad_norm: 32.0
154
+ scheduler: cosine
155
+ amp: false
156
+ max_train_batches: null
157
+ max_val_batches: null
158
+ finetune:
159
+ autoregressive_steps: 2
160
+ epochs: 3
161
+ learning_rate: 0.0001
162
+
163
+ distributed:
164
+ backend: nccl
165
+ master_addr: 127.0.0.1
166
+ master_port: 29500
167
+
168
+ inference:
169
+ checkpoint_path: ./data/checkpoint/finetune/model_bak.pt
170
+ rollout_steps: 1
171
+ max_samples: 1
172
+ save_normalized: false
173
+ output_dir: ./result/output
174
+
175
+ visualization:
176
+ variable: t2m
177
+ sample_index: 0
178
+ output_dir: ./result/figures
179
+ cmap: coolwarm
180
+
181
+ slurm:
182
+ job_name: fcnv2_train
183
+ nodes: 1
184
+ gpus_per_node: 8
185
+ cpus_per_task: 8
186
+ time: "24:00:00"
187
+ partition: null
188
+ conda_env: fourcastnetv2_develop
config.json ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "FourCastNet v2",
3
+ "model_type": "fourcastnet_v2",
4
+ "architectures": [
5
+ "FourCastNetV2"
6
+ ],
7
+ "framework": "PyTorch",
8
+ "domain": "atmosphere",
9
+ "task": "global-weather-forecasting",
10
+ "implementation": {
11
+ "entry_point": "model/fourcastnet_v2.py",
12
+ "scope": "adapter for the bundled NVIDIA FourCastNet v2 SFNO network"
13
+ },
14
+ "architecture": {
15
+ "family": "spherical Fourier neural operator",
16
+ "grid_shape": [
17
+ 721,
18
+ 1440
19
+ ],
20
+ "input_channels": 73,
21
+ "output_channels": 73,
22
+ "spectral_transform": "sht",
23
+ "filter_type": "non-linear",
24
+ "scale_factor": 6,
25
+ "embedding_size": 256,
26
+ "layers": 12,
27
+ "blocks_per_layer": 8,
28
+ "normalization": "instance_norm",
29
+ "mlp_mode": "distributed",
30
+ "spectral_layers": 3,
31
+ "complex_activation": "real",
32
+ "hard_thresholding_fraction": 1.0
33
+ },
34
+ "data": {
35
+ "dataset": "ERA5",
36
+ "spatial_resolution_degrees": 0.25,
37
+ "time_step_hours": 6,
38
+ "input_steps": 1,
39
+ "output_steps": 1,
40
+ "protocol": "synthetic_era5"
41
+ },
42
+ "configuration_sources": [
43
+ "conf/config.yaml",
44
+ "model/fourcastnet_v2.py",
45
+ "model/fcnv2"
46
+ ]
47
+ }
configuration.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "framework": "PyTorch",
3
+ "task": "weather_forecasting",
4
+ "model": "FourCastNet_v2",
5
+ "input_format": "BCHW",
6
+ "protocol": "synthetic_era5",
7
+ "default_config": "conf/config.yaml",
8
+ "train": "scripts/train.py",
9
+ "inference": "scripts/inference.py",
10
+ "evaluation": "scripts/result.py",
11
+ "visualization": "scripts/result.py"
12
+ }
model/fcnv2/fcnv2_activations.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2023 NVIDIA CORPORATION & AFFILIATES.
2
+ # SPDX-FileCopyrightText: All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ import torch
18
+ from torch import nn
19
+
20
+
21
+ class ComplexReLU(nn.Module):
22
+ def __init__(self, negative_slope=0.0, mode="cartesian", bias_shape=None):
23
+ super(ComplexReLU, self).__init__()
24
+
25
+ # store parameters
26
+ self.mode = mode
27
+ if self.mode in ["modulus", "halfplane"]:
28
+ if bias_shape is not None:
29
+ self.bias = nn.Parameter(torch.zeros(bias_shape, dtype=torch.float32))
30
+ else:
31
+ self.bias = nn.Parameter(torch.zeros((1), dtype=torch.float32))
32
+ else:
33
+ bias = torch.zeros((1), dtype=torch.float32)
34
+ self.register_buffer("bias", bias)
35
+
36
+ self.negative_slope = negative_slope
37
+ self.act = nn.LeakyReLU(negative_slope=negative_slope)
38
+
39
+ def forward(self, z: torch.Tensor) -> torch.Tensor:
40
+ if self.mode == "cartesian":
41
+ zr = torch.view_as_real(z)
42
+ za = self.act(zr)
43
+ out = torch.view_as_complex(za)
44
+ elif self.mode == "modulus":
45
+ zabs = torch.sqrt(torch.square(z.real) + torch.square(z.imag))
46
+ out = self.act(zabs + self.bias) * torch.exp(1.0j * z.angle())
47
+ elif self.mode == "halfplane":
48
+ # bias is an angle parameter in this case
49
+ modified_angle = torch.angle(z) - self.bias
50
+ condition = torch.logical_and(
51
+ (0.0 <= modified_angle), (modified_angle < torch.pi / 2.0)
52
+ )
53
+ out = torch.where(condition, z, self.negative_slope * z)
54
+ elif self.mode == "real":
55
+ zr = torch.view_as_real(z)
56
+ outr = torch.stack((self.act(zr[..., 0]), zr[..., 1]), dim=-1)
57
+ out = torch.view_as_complex(outr)
58
+ else:
59
+ # identity
60
+ out = z
61
+
62
+ return out
63
+
64
+
65
+ class ComplexActivation(nn.Module):
66
+ def __init__(self, activation, mode="cartesian", bias_shape=None):
67
+ super(ComplexActivation, self).__init__()
68
+
69
+ # store parameters
70
+ self.mode = mode
71
+ if self.mode == "modulus":
72
+ if bias_shape is not None:
73
+ self.bias = nn.Parameter(torch.zeros(bias_shape, dtype=torch.float32))
74
+ else:
75
+ self.bias = nn.Parameter(torch.zeros((1), dtype=torch.float32))
76
+ else:
77
+ bias = torch.zeros((1), dtype=torch.float32)
78
+ self.register_buffer("bias", bias)
79
+
80
+ # real valued activation
81
+ self.act = activation
82
+
83
+ def forward(self, z: torch.Tensor) -> torch.Tensor:
84
+ if self.mode == "cartesian":
85
+ zr = torch.view_as_real(z)
86
+ za = self.act(zr)
87
+ out = torch.view_as_complex(za)
88
+ elif self.mode == "modulus":
89
+ zabs = torch.sqrt(torch.square(z.real) + torch.square(z.imag))
90
+ out = self.act(zabs + self.bias) * torch.exp(1.0j * z.angle())
91
+ else:
92
+ # identity
93
+ out = z
94
+
95
+ return out
model/fcnv2/fcnv2_contractions.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2023 NVIDIA CORPORATION & AFFILIATES.
2
+ # SPDX-FileCopyrightText: All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ import torch
18
+
19
+ # Helper routines for FNOs
20
+
21
+
22
+ @torch.jit.script
23
+ def compl_contract2d_fwd(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
24
+ tmp = torch.einsum("bixys,kixyr->srbkxy", a, b)
25
+ res = torch.stack(
26
+ [tmp[0, 0, ...] - tmp[1, 1, ...], tmp[1, 0, ...] + tmp[0, 1, ...]], dim=-1
27
+ )
28
+ return res
29
+
30
+
31
+ @torch.jit.script
32
+ def compl_contract2d_fwd_c(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
33
+ ac = torch.view_as_complex(a)
34
+ bc = torch.view_as_complex(b)
35
+ res = torch.einsum("bixy,kixy->bkxy", ac, bc)
36
+ return torch.view_as_real(res)
37
+
38
+
39
+ @torch.jit.script
40
+ def compl_contract_fwd(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
41
+ tmp = torch.einsum("bins,kinr->srbkn", a, b)
42
+ res = torch.stack(
43
+ [tmp[0, 0, ...] - tmp[1, 1, ...], tmp[1, 0, ...] + tmp[0, 1, ...]], dim=-1
44
+ )
45
+ return res
46
+
47
+
48
+ @torch.jit.script
49
+ def compl_contract_fwd_c(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
50
+ ac = torch.view_as_complex(a)
51
+ bc = torch.view_as_complex(b)
52
+ res = torch.einsum("bin,kin->bkn", ac, bc)
53
+ return torch.view_as_real(res)
54
+
55
+
56
+ @torch.jit.script
57
+ def compl_ttc1_c_fwd(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
58
+ ac = torch.view_as_complex(a)
59
+ bc = torch.view_as_complex(b)
60
+ res = torch.einsum("jt,bct->jbct", ac, bc)
61
+ return torch.view_as_real(res)
62
+
63
+
64
+ @torch.jit.script
65
+ def compl_ttc2_c_fwd(a: torch.Tensor, b: torch.Tensor, c: torch.Tensor) -> torch.Tensor:
66
+ ac = torch.view_as_complex(a)
67
+ bc = torch.view_as_complex(b)
68
+ cc = torch.view_as_complex(c)
69
+ res = torch.einsum("oi,icj,jbct->bot", ac, bc, cc)
70
+ return torch.view_as_real(res)
71
+
72
+
73
+ def contract_tt(x, w):
74
+ y = compl_ttc1_c_fwd(w[2], x)
75
+ return compl_ttc2_c_fwd(w[0], w[1], y)
76
+
77
+
78
+ # Helper routines for spherical MLPs
79
+ @torch.jit.script
80
+ def compl_mul1d_fwd(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
81
+ tmp = torch.einsum("bixs,ior->srbox", a, b)
82
+ res = torch.stack(
83
+ [tmp[0, 0, ...] - tmp[1, 1, ...], tmp[1, 0, ...] + tmp[0, 1, ...]], dim=-1
84
+ )
85
+ return res
86
+
87
+
88
+ @torch.jit.script
89
+ def compl_mul1d_fwd_c(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
90
+ ac = torch.view_as_complex(a)
91
+ bc = torch.view_as_complex(b)
92
+ resc = torch.einsum("bix,io->box", ac, bc)
93
+ res = torch.view_as_real(resc)
94
+ return res
95
+
96
+
97
+ @torch.jit.script
98
+ def compl_muladd1d_fwd(
99
+ a: torch.Tensor, b: torch.Tensor, c: torch.Tensor
100
+ ) -> torch.Tensor:
101
+ res = compl_mul1d_fwd(a, b) + c
102
+ return res
103
+
104
+
105
+ @torch.jit.script
106
+ def compl_muladd1d_fwd_c(
107
+ a: torch.Tensor, b: torch.Tensor, c: torch.Tensor
108
+ ) -> torch.Tensor:
109
+ tmpcc = torch.view_as_complex(compl_mul1d_fwd_c(a, b))
110
+ cc = torch.view_as_complex(c)
111
+ return torch.view_as_real(tmpcc + cc)
112
+
113
+
114
+ # for the real-valued case:
115
+
116
+
117
+ @torch.jit.script
118
+ def compl_mul1d_fwd_r(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
119
+ res = torch.einsum("bix,io->box", a, b)
120
+ return res
121
+
122
+
123
+ @torch.jit.script
124
+ def compl_muladd1d_fwd_r(
125
+ a: torch.Tensor, b: torch.Tensor, c: torch.Tensor
126
+ ) -> torch.Tensor:
127
+ tmp = compl_mul1d_fwd_r(a, b)
128
+ return tmp + c
129
+
130
+
131
+ # Helper routines for FFT MLPs
132
+
133
+
134
+ @torch.jit.script
135
+ def compl_mul2d_fwd(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
136
+ tmp = torch.einsum("bixys,ior->srboxy", a, b)
137
+ res = torch.stack(
138
+ [tmp[0, 0, ...] - tmp[1, 1, ...], tmp[1, 0, ...] + tmp[0, 1, ...]], dim=-1
139
+ )
140
+ return res
141
+
142
+
143
+ @torch.jit.script
144
+ def compl_mul2d_fwd_c(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
145
+ ac = torch.view_as_complex(a)
146
+ bc = torch.view_as_complex(b)
147
+ resc = torch.einsum("bixy,io->boxy", ac, bc)
148
+ res = torch.view_as_real(resc)
149
+ return res
150
+
151
+
152
+ @torch.jit.script
153
+ def compl_muladd2d_fwd(
154
+ a: torch.Tensor, b: torch.Tensor, c: torch.Tensor
155
+ ) -> torch.Tensor:
156
+ res = compl_mul2d_fwd(a, b) + c
157
+ return res
158
+
159
+
160
+ @torch.jit.script
161
+ def compl_muladd2d_fwd_c(
162
+ a: torch.Tensor, b: torch.Tensor, c: torch.Tensor
163
+ ) -> torch.Tensor:
164
+ tmpcc = torch.view_as_complex(compl_mul2d_fwd_c(a, b))
165
+ cc = torch.view_as_complex(c)
166
+ return torch.view_as_real(tmpcc + cc)
167
+
168
+
169
+ # for the real-valued case:
170
+ @torch.jit.script
171
+ def compl_mul2d_fwd_r(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
172
+ res = torch.einsum("bixy,io->boxy", a, b)
173
+ return res
174
+
175
+
176
+ @torch.jit.script
177
+ def compl_muladd2d_fwd_r(
178
+ a: torch.Tensor, b: torch.Tensor, c: torch.Tensor
179
+ ) -> torch.Tensor:
180
+ tmp = compl_mul2d_fwd_c(a, b)
181
+ return torch.view_as_real(tmp + c)
model/fcnv2/fcnv2_layers.py ADDED
@@ -0,0 +1,662 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2023 NVIDIA CORPORATION & AFFILIATES.
2
+ # SPDX-FileCopyrightText: All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ import math
18
+ import warnings
19
+
20
+ import torch
21
+ import torch.fft
22
+ import torch.nn as nn
23
+ import torch.nn.functional as F
24
+ from torch.cuda import amp
25
+ from torch.utils.checkpoint import checkpoint
26
+ from torch_harmonics import * # noqa
27
+
28
+ from fcnv2_activations import ComplexReLU # noqa
29
+ from fcnv2_contractions import (
30
+ compl_contract2d_fwd,
31
+ compl_contract2d_fwd_c,
32
+ compl_contract_fwd,
33
+ compl_contract_fwd_c,
34
+ compl_mul2d_fwd,
35
+ compl_mul2d_fwd_c,
36
+ compl_muladd2d_fwd,
37
+ compl_muladd2d_fwd_c,
38
+ contract_tt,
39
+ )
40
+
41
+
42
+ def _no_grad_trunc_normal_(tensor, mean, std, a, b):
43
+ # Cut & paste from PyTorch official master until it's in a few official releases - RW
44
+ # Method based on https://people.sc.fsu.edu/~jburkardt/presentations/truncated_normal.pdf
45
+ def norm_cdf(x):
46
+ # Computes standard normal cumulative distribution function
47
+ return (1.0 + math.erf(x / math.sqrt(2.0))) / 2.0
48
+
49
+ if (mean < a - 2 * std) or (mean > b + 2 * std):
50
+ warnings.warn(
51
+ "mean is more than 2 std from [a, b] in nn.init.trunc_normal_. "
52
+ "The distribution of values may be incorrect.",
53
+ stacklevel=2,
54
+ )
55
+
56
+ with torch.no_grad():
57
+ # Values are generated by using a truncated uniform distribution and
58
+ # then using the inverse CDF for the normal distribution.
59
+ # Get upper and lower cdf values
60
+ l = norm_cdf((a - mean) / std) # noqa
61
+ u = norm_cdf((b - mean) / std)
62
+
63
+ # Uniformly fill tensor with values from [l, u], then translate to
64
+ # [2l-1, 2u-1].
65
+ tensor.uniform_(2 * l - 1, 2 * u - 1)
66
+
67
+ # Use inverse cdf transform for normal distribution to get truncated
68
+ # standard normal
69
+ tensor.erfinv_()
70
+
71
+ # Transform to proper mean, std
72
+ tensor.mul_(std * math.sqrt(2.0))
73
+ tensor.add_(mean)
74
+
75
+ # Clamp to ensure it's in the proper range
76
+ tensor.clamp_(min=a, max=b)
77
+ return tensor
78
+
79
+
80
+ def trunc_normal_(tensor, mean=0.0, std=1.0, a=-2.0, b=2.0):
81
+ r"""Fills the input Tensor with values drawn from a truncated
82
+ normal distribution. The values are effectively drawn from the
83
+ normal distribution :math:`\mathcal{N}(\text{mean}, \text{std}^2)`
84
+ with values outside :math:`[a, b]` redrawn until they are within
85
+ the bounds. The method used for generating the random values works
86
+ best when :math:`a \leq \text{mean} \leq b`.
87
+ Args:
88
+ tensor: an n-dimensional `torch.Tensor`
89
+ mean: the mean of the normal distribution
90
+ std: the standard deviation of the normal distribution
91
+ a: the minimum cutoff value
92
+ b: the maximum cutoff value
93
+ Examples:
94
+ >>> w = torch.empty(3, 5)
95
+ >>> nn.init.trunc_normal_(w)
96
+ """
97
+ return _no_grad_trunc_normal_(tensor, mean, std, a, b)
98
+
99
+
100
+ @torch.jit.script
101
+ def drop_path(
102
+ x: torch.Tensor, drop_prob: float = 0.0, training: bool = False
103
+ ) -> torch.Tensor:
104
+ """Drop paths (Stochastic Depth) per sample (when applied in main path of residual blocks).
105
+ This is the same as the DropConnect impl I created for EfficientNet, etc networks, however,
106
+ the original name is misleading as 'Drop Connect' is a different form of dropout in a separate paper...
107
+ See discussion: https://github.com/tensorflow/tpu/issues/494#issuecomment-532968956 ... I've opted for
108
+ changing the layer and argument names to 'drop path' rather than mix DropConnect as a layer name and use
109
+ 'survival rate' as the argument.
110
+ """
111
+ if drop_prob == 0.0 or not training:
112
+ return x
113
+ keep_prob = 1.0 - drop_prob
114
+ shape = (x.shape[0],) + (1,) * (
115
+ x.ndim - 1
116
+ ) # work with diff dim tensors, not just 2d ConvNets
117
+ random_tensor = keep_prob + torch.rand(shape, dtype=x.dtype, device=x.device)
118
+ random_tensor.floor_() # binarize
119
+ output = x.div(keep_prob) * random_tensor
120
+ return output
121
+
122
+
123
+ class DropPath(nn.Module):
124
+ """Drop paths (Stochastic Depth) per sample (when applied in main path of residual blocks)."""
125
+
126
+ def __init__(self, drop_prob=None):
127
+ super(DropPath, self).__init__()
128
+ self.drop_prob = drop_prob
129
+
130
+ def forward(self, x):
131
+ return drop_path(x, self.drop_prob, self.training)
132
+
133
+
134
+ class PatchEmbed(nn.Module):
135
+ def __init__(
136
+ self, img_size=(224, 224), patch_size=(16, 16), in_chans=3, embed_dim=768
137
+ ):
138
+ super(PatchEmbed, self).__init__()
139
+ num_patches = (img_size[1] // patch_size[1]) * (img_size[0] // patch_size[0])
140
+ self.img_size = img_size
141
+ self.patch_size = patch_size
142
+ self.num_patches = num_patches
143
+ self.proj = nn.Conv2d(
144
+ in_chans, embed_dim, kernel_size=patch_size, stride=patch_size
145
+ )
146
+
147
+ def forward(self, x):
148
+ # gather input
149
+ B, C, H, W = x.shape
150
+ assert ( # noqa
151
+ H == self.img_size[0] and W == self.img_size[1]
152
+ ), f"Input image size ({H}*{W}) doesn't match model ({self.img_size[0]}*{self.img_size[1]})."
153
+ # new: B, C, H*W
154
+ x = self.proj(x).flatten(2)
155
+ return x
156
+
157
+
158
+ class MLP(nn.Module):
159
+ def __init__(
160
+ self,
161
+ in_features,
162
+ hidden_features=None,
163
+ out_features=None,
164
+ act_layer=nn.GELU,
165
+ output_bias=True,
166
+ drop_rate=0.0,
167
+ checkpointing=False,
168
+ ):
169
+ super(MLP, self).__init__()
170
+ self.checkpointing = checkpointing
171
+ out_features = out_features or in_features
172
+ hidden_features = hidden_features or in_features
173
+
174
+ fc1 = nn.Conv2d(in_features, hidden_features, 1, bias=True)
175
+ act = act_layer()
176
+ fc2 = nn.Conv2d(hidden_features, out_features, 1, bias=output_bias)
177
+ if drop_rate > 0.0:
178
+ drop = nn.Dropout(drop_rate)
179
+ self.fwd = nn.Sequential(fc1, act, drop, fc2, drop)
180
+ else:
181
+ self.fwd = nn.Sequential(fc1, act, fc2)
182
+
183
+ @torch.jit.ignore
184
+ def checkpoint_forward(self, x):
185
+ return checkpoint(self.fwd, x)
186
+
187
+ def forward(self, x):
188
+ if self.checkpointing:
189
+ return self.checkpoint_forward(x)
190
+ else:
191
+ return self.fwd(x)
192
+
193
+
194
+ class RealFFT2(nn.Module):
195
+ """
196
+ Helper routine to wrap FFT similarly to the SHT
197
+ """
198
+
199
+ def __init__(self, nlat, nlon, lmax=None, mmax=None):
200
+ super(RealFFT2, self).__init__()
201
+
202
+ self.nlat = nlat
203
+ self.nlon = nlon
204
+ self.lmax = lmax or self.nlat
205
+ self.mmax = mmax or self.nlon // 2 + 1
206
+ self.num_batches = 1
207
+
208
+ assert self.lmax % 2 == 0 # noqa
209
+
210
+ def forward(self, x):
211
+ # do batched FFT
212
+ xs = torch.split(x, x.shape[1] // self.num_batches, dim=1)
213
+
214
+ ys = []
215
+ for xt in xs:
216
+ yt = torch.fft.rfft2(xt, dim=(-2, -1), norm="ortho")
217
+ ys.append(
218
+ torch.cat(
219
+ (
220
+ yt[..., : math.ceil(self.lmax / 2), : self.mmax],
221
+ yt[..., -math.floor(self.lmax / 2) :, : self.mmax],
222
+ ),
223
+ dim=-2,
224
+ )
225
+ )
226
+
227
+ # connect
228
+ y = torch.cat(ys, dim=1).contiguous()
229
+
230
+ # y = torch.fft.rfft2(x, dim=(-2, -1), norm="ortho")
231
+ # y = torch.cat((y[..., :math.ceil(self.lmax/2), :self.mmax], y[..., -math.floor(self.lmax/2):, :self.mmax]), dim=-2)
232
+ return y
233
+
234
+
235
+ class InverseRealFFT2(nn.Module):
236
+ """
237
+ Helper routine to wrap FFT similarly to the SHT
238
+ """
239
+
240
+ def __init__(self, nlat, nlon, lmax=None, mmax=None):
241
+ super(InverseRealFFT2, self).__init__()
242
+
243
+ self.nlat = nlat
244
+ self.nlon = nlon
245
+ self.lmax = lmax or self.nlat
246
+ self.mmax = mmax or self.nlon // 2 + 1
247
+ self.num_batches = 1
248
+
249
+ def forward(self, x):
250
+ # do batched FFT
251
+ xs = torch.split(x, x.shape[1] // self.num_batches, dim=1)
252
+
253
+ ys = []
254
+ for xt in xs:
255
+ ys.append(
256
+ torch.fft.irfft2(
257
+ xt, dim=(-2, -1), s=(self.nlat, self.nlon), norm="ortho"
258
+ )
259
+ )
260
+ out = torch.cat(ys, dim=1).contiguous()
261
+
262
+ # out = torch.fft.irfft2(x, dim=(-2, -1), s=(self.nlat, self.nlon), norm="ortho")
263
+ return out
264
+
265
+
266
+ class SpectralConv2d(nn.Module):
267
+ """
268
+ Spectral Convolution as utilized in
269
+ """
270
+
271
+ def __init__(
272
+ self,
273
+ forward_transform,
274
+ inverse_transform,
275
+ hidden_size,
276
+ sparsity_threshold=0.0,
277
+ hard_thresholding_fraction=1,
278
+ use_complex_kernels=False,
279
+ compression=None,
280
+ rank=0,
281
+ bias=False,
282
+ ):
283
+ super(SpectralConv2d, self).__init__()
284
+
285
+ self.hidden_size = hidden_size
286
+ self.sparsity_threshold = sparsity_threshold
287
+ self.hard_thresholding_fraction = hard_thresholding_fraction
288
+ self.scale = 1 / hidden_size**2
289
+ self.contract_handle = (
290
+ compl_contract2d_fwd_c if use_complex_kernels else compl_contract2d_fwd
291
+ )
292
+
293
+ self.forward_transform = forward_transform
294
+ self.inverse_transform = inverse_transform
295
+
296
+ self.output_dims = (self.inverse_transform.nlat, self.inverse_transform.nlon)
297
+ modes_lat = self.inverse_transform.lmax
298
+ modes_lon = self.inverse_transform.mmax
299
+ self.modes_lat = int(modes_lat * self.hard_thresholding_fraction)
300
+ self.modes_lon = int(modes_lon * self.hard_thresholding_fraction)
301
+
302
+ # new simple linear layer
303
+ self.w = nn.Parameter(
304
+ self.scale
305
+ * torch.randn(
306
+ self.hidden_size, self.hidden_size, self.modes_lat, self.modes_lon, 2
307
+ )
308
+ )
309
+ # optional bias
310
+ if bias:
311
+ self.b = nn.Parameter(
312
+ self.scale * torch.randn(1, self.hidden_size, *self.output_dims)
313
+ )
314
+
315
+ def forward(self, x):
316
+ dtype = x.dtype
317
+ # x = x.float()
318
+ B, C, H, W = x.shape
319
+
320
+ with amp.autocast(enabled=False):
321
+ x = x.to(torch.float32)
322
+ x = self.forward_transform(x)
323
+ x = torch.view_as_real(x)
324
+ x = x.to(dtype)
325
+
326
+ # do spectral conv
327
+ modes = torch.zeros(x.shape, device=x.device)
328
+
329
+ # modes[:, :, :self.modes_lat, :self.modes_lon, :] = self.contract_handle(x[:, :, :self.modes_lat, :self.modes_lon, :], self.wh)
330
+ # modes[:, :, -self.modes_lat:, :self.modes_lon, :] = self.contract_handle(x[:, :, -self.modes_lat:, :self.modes_lon, :], self.wl)
331
+ modes = self.contract_handle(x, self.w)
332
+
333
+ # finalize
334
+ x = F.softshrink(modes, lambd=self.sparsity_threshold)
335
+ x = torch.view_as_complex(x)
336
+
337
+ with amp.autocast(enabled=False):
338
+ x = x.to(torch.float32)
339
+ x = torch.view_as_complex(x)
340
+ x = self.inverse_transform(x)
341
+ x = x.to(dtype)
342
+
343
+ if hasattr(self, "b"):
344
+ x = x + self.b
345
+
346
+ return x
347
+
348
+
349
+ class SpectralConvS2(nn.Module):
350
+ """
351
+ Spectral Convolution as utilized in
352
+ """
353
+
354
+ def __init__(
355
+ self,
356
+ forward_transform,
357
+ inverse_transform,
358
+ hidden_size,
359
+ sparsity_threshold=0.0,
360
+ use_complex_kernels=False,
361
+ compression=None,
362
+ rank=128,
363
+ bias=False,
364
+ ):
365
+ super(SpectralConvS2, self).__init__()
366
+
367
+ self.hidden_size = hidden_size
368
+ self.sparsity_threshold = sparsity_threshold
369
+ self.scale = 0.02
370
+
371
+ self.forward_transform = forward_transform
372
+ self.inverse_transform = inverse_transform
373
+
374
+ self.modes_lat = self.forward_transform.lmax
375
+ self.modes_lon = self.forward_transform.mmax
376
+
377
+ assert self.inverse_transform.lmax == self.modes_lat # noqa
378
+ assert self.inverse_transform.mmax == self.modes_lon # noqa
379
+
380
+ # remember the lower triangular indices
381
+ ii, jj = torch.tril_indices(self.modes_lat, self.modes_lon)
382
+ self.register_buffer("ii", ii)
383
+ self.register_buffer("jj", jj)
384
+
385
+ if compression == "tt":
386
+ self.rank = rank
387
+ # tensortrain coefficients
388
+ g1 = nn.Parameter(self.scale * torch.randn(self.hidden_size, self.rank, 2))
389
+ g2 = nn.Parameter(
390
+ self.scale * torch.randn(self.rank, self.hidden_size, self.rank, 2)
391
+ )
392
+ g3 = nn.Parameter(self.scale * torch.randn(self.rank, len(ii), 2))
393
+ self.w = nn.ParameterList([g1, g2, g3])
394
+
395
+ self.contract_handle = (
396
+ contract_tt # if use_complex_kernels else raise(NotImplementedError)
397
+ )
398
+ else:
399
+ self.w = nn.Parameter(
400
+ self.scale * torch.randn(self.hidden_size, self.hidden_size, len(ii), 2)
401
+ )
402
+ self.contract_handle = (
403
+ compl_contract_fwd_c if use_complex_kernels else compl_contract_fwd
404
+ )
405
+
406
+ if bias:
407
+ self.b = nn.Parameter(
408
+ self.scale * torch.randn(1, self.hidden_size, *self.output_dims)
409
+ )
410
+
411
+ def forward(self, x):
412
+ dtype = x.dtype
413
+ # x = x.float()
414
+ B, C, H, W = x.shape
415
+
416
+ with amp.autocast(enabled=False):
417
+ x = x.to(torch.float32)
418
+ x = self.forward_transform(x)
419
+ x = torch.view_as_real(x)
420
+ x = x.to(dtype)
421
+
422
+ # Populate the sparse spectral grid without an in-place write. The
423
+ # latter breaks autograd under multi-process DDP on some HIP builds.
424
+ spectral_height, spectral_width = x.shape[2:4]
425
+ contracted = self.contract_handle(
426
+ x[:, :, self.ii, self.jj, :], self.w
427
+ )
428
+ spectral_indices = self.ii * spectral_width + self.jj
429
+ modes = torch.zeros_like(x).reshape(
430
+ B, C, spectral_height * spectral_width, 2
431
+ ).index_copy(
432
+ 2, spectral_indices, contracted
433
+ )
434
+ modes = modes.view_as(x)
435
+
436
+ # finalize
437
+ x = F.softshrink(modes, lambd=self.sparsity_threshold)
438
+
439
+ with amp.autocast(enabled=False):
440
+ x = x.to(torch.float32)
441
+ x = torch.view_as_complex(x)
442
+ x = self.inverse_transform(x)
443
+ x = x.to(dtype)
444
+
445
+ if hasattr(self, "b"):
446
+ x = x + self.b
447
+
448
+ return x
449
+
450
+
451
+ class SpectralAttention2d(nn.Module):
452
+ """
453
+ 2d Spectral Attention layer
454
+ """
455
+
456
+ def __init__(
457
+ self,
458
+ forward_transform,
459
+ inverse_transform,
460
+ embed_dim,
461
+ sparsity_threshold=0.0,
462
+ hidden_size_factor=2,
463
+ use_complex_network=True,
464
+ use_complex_kernels=False,
465
+ complex_activation="real",
466
+ bias=False,
467
+ spectral_layers=1,
468
+ drop_rate=0.0,
469
+ ):
470
+ super(SpectralAttention2d, self).__init__()
471
+
472
+ self.embed_dim = embed_dim
473
+ self.sparsity_threshold = sparsity_threshold
474
+ self.hidden_size = int(hidden_size_factor * self.embed_dim)
475
+ self.scale = 0.02
476
+ self.spectral_layers = spectral_layers
477
+ self.mul_add_handle = (
478
+ compl_muladd2d_fwd_c if use_complex_kernels else compl_muladd2d_fwd
479
+ )
480
+ self.mul_handle = compl_mul2d_fwd_c if use_complex_kernels else compl_mul2d_fwd
481
+
482
+ self.modes_lat = forward_transform.lmax
483
+ self.modes_lon = forward_transform.mmax
484
+
485
+ # only storing the forward handle to be able to call it
486
+ self.forward_transform = forward_transform.forward
487
+ self.inverse_transform = inverse_transform.forward
488
+
489
+ assert inverse_transform.lmax == self.modes_lat # noqa
490
+ assert inverse_transform.mmax == self.modes_lon # noqa
491
+
492
+ # weights
493
+ w = [self.scale * torch.randn(self.embed_dim, self.hidden_size, 2)]
494
+ # w = [self.scale * torch.randn(self.embed_dim + 2*self.embed_freqs, self.hidden_size, 2)]
495
+ # w = [self.scale * torch.randn(self.embed_dim + 4*self.embed_freqs, self.hidden_size, 2)]
496
+ for l in range(1, self.spectral_layers):
497
+ w.append(self.scale * torch.randn(self.hidden_size, self.hidden_size, 2))
498
+ self.w = nn.ParameterList(w)
499
+
500
+ if bias:
501
+ self.b = nn.ParameterList(
502
+ [
503
+ self.scale * torch.randn(self.hidden_size, 1, 2)
504
+ for _ in range(self.spectral_layers)
505
+ ]
506
+ )
507
+
508
+ self.wout = nn.Parameter(
509
+ self.scale * torch.randn(self.hidden_size, self.embed_dim, 2)
510
+ )
511
+
512
+ self.drop = nn.Dropout(drop_rate) if drop_rate > 0.0 else nn.Identity()
513
+
514
+ self.activation = ComplexReLU(
515
+ mode=complex_activation, bias_shape=(self.hidden_size, 1, 1)
516
+ )
517
+
518
+ def forward_mlp(self, xr):
519
+ for l in range(self.spectral_layers):
520
+ if hasattr(self, "b"):
521
+ xr = self.mul_add_handle(
522
+ xr, self.w[l].to(xr.dtype), self.b[l].to(xr.dtype)
523
+ )
524
+ else:
525
+ xr = self.mul_handle(xr, self.w[l].to(xr.dtype))
526
+ xr = torch.view_as_complex(xr)
527
+ xr = self.activation(xr)
528
+ xr = self.drop(xr)
529
+ xr = torch.view_as_real(xr)
530
+
531
+ xr = self.mul_handle(xr, self.wout)
532
+
533
+ return xr
534
+
535
+ def forward(self, x):
536
+ dtype = x.dtype
537
+ # x = x.to(torch.float32)
538
+
539
+ # FWD transform
540
+ with amp.autocast(enabled=False):
541
+ x = x.to(torch.float32)
542
+ x = self.forward_transform(x)
543
+ x = torch.view_as_real(x)
544
+
545
+ # MLP
546
+ x = self.forward_mlp(x)
547
+
548
+ # BWD transform
549
+ with amp.autocast(enabled=False):
550
+ x = torch.view_as_complex(x)
551
+ x = self.inverse_transform(x)
552
+ x = x.to(dtype)
553
+
554
+ return x
555
+
556
+
557
+ class SpectralAttentionS2(nn.Module):
558
+ """
559
+ geometrical Spectral Attention layer
560
+ """
561
+
562
+ def __init__(
563
+ self,
564
+ forward_transform,
565
+ inverse_transform,
566
+ embed_dim,
567
+ sparsity_threshold=0.0,
568
+ hidden_size_factor=2,
569
+ use_complex_network=True,
570
+ use_complex_kernels=False,
571
+ complex_activation="real",
572
+ bias=False,
573
+ spectral_layers=1,
574
+ drop_rate=0.0,
575
+ ):
576
+ super(SpectralAttentionS2, self).__init__()
577
+
578
+ self.embed_dim = embed_dim
579
+ self.sparsity_threshold = sparsity_threshold
580
+ self.hidden_size = int(hidden_size_factor * self.embed_dim)
581
+ self.scale = 0.02
582
+ # self.mul_add_handle = compl_muladd1d_fwd_c if use_complex_kernels else compl_muladd1d_fwd
583
+ self.mul_add_handle = (
584
+ compl_muladd2d_fwd_c if use_complex_kernels else compl_muladd2d_fwd
585
+ )
586
+ # self.mul_handle = compl_mul1d_fwd_c if use_complex_kernels else compl_mul1d_fwd
587
+ self.mul_handle = compl_mul2d_fwd_c if use_complex_kernels else compl_mul2d_fwd
588
+ self.spectral_layers = spectral_layers
589
+
590
+ self.modes_lat = forward_transform.lmax
591
+ self.modes_lon = forward_transform.mmax
592
+
593
+ # only storing the forward handle to be able to call it
594
+ self.forward_transform = forward_transform.forward
595
+ self.inverse_transform = inverse_transform.forward
596
+
597
+ assert inverse_transform.lmax == self.modes_lat # noqa
598
+ assert inverse_transform.mmax == self.modes_lon # noqa
599
+
600
+ # weights
601
+ w = [self.scale * torch.randn(self.embed_dim, self.hidden_size, 2)]
602
+ # w = [self.scale * torch.randn(self.embed_dim + 4*self.embed_freqs, self.hidden_size, 2)]
603
+ for l in range(1, self.spectral_layers):
604
+ w.append(self.scale * torch.randn(self.hidden_size, self.hidden_size, 2))
605
+ self.w = nn.ParameterList(w)
606
+
607
+ if bias:
608
+ self.b = nn.ParameterList(
609
+ [
610
+ self.scale * torch.randn(2 * self.hidden_size, 1, 1, 2)
611
+ for _ in range(self.spectral_layers)
612
+ ]
613
+ )
614
+
615
+ self.wout = nn.Parameter(
616
+ self.scale * torch.randn(self.hidden_size, self.embed_dim, 2)
617
+ )
618
+
619
+ self.drop = nn.Dropout(drop_rate) if drop_rate > 0.0 else nn.Identity()
620
+
621
+ self.activation = ComplexReLU(
622
+ mode=complex_activation, bias_shape=(self.hidden_size, 1, 1)
623
+ )
624
+
625
+ def forward_mlp(self, xr):
626
+ for l in range(self.spectral_layers):
627
+ if hasattr(self, "b"):
628
+ xr = self.mul_add_handle(
629
+ xr, self.w[l].to(xr.dtype), self.b[l].to(xr.dtype)
630
+ )
631
+ else:
632
+ xr = self.mul_handle(xr, self.w[l].to(xr.dtype))
633
+ xr = torch.view_as_complex(xr)
634
+ xr = self.activation(xr)
635
+ xr = self.drop(xr)
636
+ xr = torch.view_as_real(xr)
637
+
638
+ # final MLP
639
+ xr = self.mul_handle(xr, self.wout)
640
+
641
+ return xr
642
+
643
+ def forward(self, x):
644
+ dtype = x.dtype
645
+ # x = x.to(torch.float32)
646
+
647
+ # FWD transform
648
+ with amp.autocast(enabled=False):
649
+ x = x.to(torch.float32)
650
+ x = self.forward_transform(x)
651
+ x = torch.view_as_real(x)
652
+
653
+ # MLP
654
+ x = self.forward_mlp(x)
655
+
656
+ # BWD transform
657
+ with amp.autocast(enabled=False):
658
+ x = torch.view_as_complex(x)
659
+ x = self.inverse_transform(x)
660
+ x = x.to(dtype)
661
+
662
+ return x
model/fcnv2/fcnv2_sfnonet.py ADDED
@@ -0,0 +1,615 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2023 NVIDIA CORPORATION & AFFILIATES.
2
+ # SPDX-FileCopyrightText: All rights reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ from functools import partial
18
+
19
+ import torch
20
+ import torch.nn as nn
21
+ import torch_harmonics as harmonics
22
+ from apex.normalization import FusedLayerNorm
23
+
24
+ # helpers
25
+ # to fake the sht module with ffts
26
+ from fcnv2_layers import (
27
+ MLP,
28
+ DropPath,
29
+ InverseRealFFT2,
30
+ RealFFT2,
31
+ SpectralAttention2d,
32
+ SpectralAttentionS2,
33
+ SpectralConv2d,
34
+ SpectralConvS2,
35
+ trunc_normal_,
36
+ )
37
+
38
+
39
+ class SafeRealSHT(harmonics.RealSHT):
40
+ """RealSHT variant that avoids in-place writes during autograd."""
41
+
42
+ def forward(self, x):
43
+ if x.dim() < 2:
44
+ raise ValueError("Expected tensor with at least 2 dimensions")
45
+ if x.shape[-2:] != (self.nlat, self.nlon):
46
+ raise ValueError(
47
+ f"Expected spatial shape {(self.nlat, self.nlon)}, got {tuple(x.shape[-2:])}"
48
+ )
49
+
50
+ transformed = torch.view_as_real(
51
+ 2.0 * torch.pi * torch.fft.rfft(x, dim=-1, norm="forward")
52
+ )
53
+ weights = self.weights.to(dtype=transformed.dtype)
54
+ real = torch.einsum(
55
+ "...km,mlk->...lm", transformed[..., : self.mmax, 0], weights
56
+ )
57
+ imag = torch.einsum(
58
+ "...km,mlk->...lm", transformed[..., : self.mmax, 1], weights
59
+ )
60
+ return torch.view_as_complex(torch.stack((real, imag), dim=-1).contiguous())
61
+
62
+
63
+ class SafeInverseRealSHT(harmonics.InverseRealSHT):
64
+ """InverseRealSHT variant that avoids in-place writes during autograd."""
65
+
66
+ def forward(self, x):
67
+ if x.dim() < 2:
68
+ raise ValueError("Expected tensor with at least 2 dimensions")
69
+ if x.shape[-2:] != (self.lmax, self.mmax):
70
+ raise ValueError(
71
+ f"Expected spectral shape {(self.lmax, self.mmax)}, got {tuple(x.shape[-2:])}"
72
+ )
73
+
74
+ spectral = torch.view_as_real(x)
75
+ spatial = torch.einsum(
76
+ "...lmr,mlk->...kmr", spectral, self.pct.to(dtype=spectral.dtype)
77
+ )
78
+ spatial = torch.view_as_complex(spatial.contiguous())
79
+
80
+ # torch_harmonics clears these components in-place. Build the same
81
+ # result functionally so autograd can retain the spectral tensor.
82
+ longitude = torch.arange(
83
+ self.mmax, device=spatial.device, dtype=spatial.real.dtype
84
+ )
85
+ constrained = (longitude != 0).to(spatial.real.dtype)
86
+ if self.nlon % 2 == 0 and self.nlon // 2 < self.mmax:
87
+ constrained = constrained * (
88
+ longitude != self.nlon // 2
89
+ ).to(spatial.real.dtype)
90
+ spatial = torch.complex(spatial.real, spatial.imag * constrained)
91
+ return torch.fft.irfft(spatial, n=self.nlon, dim=-1, norm="forward")
92
+
93
+
94
+ class SpectralFilterLayer(nn.Module):
95
+ def __init__(
96
+ self,
97
+ forward_transform,
98
+ inverse_transform,
99
+ embed_dim,
100
+ filter_type="linear",
101
+ sparsity_threshold=0.0,
102
+ use_complex_kernels=True,
103
+ hidden_size_factor=2,
104
+ compression=None,
105
+ rank=128,
106
+ complex_network=True,
107
+ complex_activation="real",
108
+ spectral_layers=1,
109
+ drop_rate=0.0,
110
+ ):
111
+ super(SpectralFilterLayer, self).__init__()
112
+
113
+ if filter_type == "non-linear" and isinstance(
114
+ forward_transform, harmonics.RealSHT
115
+ ):
116
+ self.filter = SpectralAttentionS2(
117
+ forward_transform,
118
+ inverse_transform,
119
+ embed_dim,
120
+ sparsity_threshold,
121
+ use_complex_network=complex_network,
122
+ use_complex_kernels=use_complex_kernels,
123
+ hidden_size_factor=hidden_size_factor,
124
+ complex_activation=complex_activation,
125
+ spectral_layers=spectral_layers,
126
+ drop_rate=drop_rate,
127
+ bias=False,
128
+ )
129
+ elif filter_type == "non-linear" and isinstance(
130
+ forward_transform, harmonics.RealFFT2
131
+ ):
132
+ self.filter = SpectralAttention2d(
133
+ forward_transform,
134
+ inverse_transform,
135
+ embed_dim,
136
+ sparsity_threshold,
137
+ use_complex_kernels=use_complex_kernels,
138
+ hidden_size_factor=hidden_size_factor,
139
+ complex_activation=complex_activation,
140
+ spectral_layers=spectral_layers,
141
+ drop_rate=drop_rate,
142
+ bias=False,
143
+ )
144
+
145
+ elif filter_type == "linear" and isinstance(
146
+ forward_transform, harmonics.RealSHT
147
+ ):
148
+ self.filter = SpectralConvS2(
149
+ forward_transform,
150
+ inverse_transform,
151
+ embed_dim,
152
+ sparsity_threshold,
153
+ use_complex_kernels=use_complex_kernels,
154
+ compression=compression,
155
+ rank=rank,
156
+ bias=False,
157
+ )
158
+
159
+ elif filter_type == "linear" and isinstance(forward_transform, RealFFT2):
160
+ self.filter = SpectralConv2d(
161
+ forward_transform,
162
+ inverse_transform,
163
+ embed_dim,
164
+ sparsity_threshold,
165
+ use_complex_kernels=use_complex_kernels,
166
+ compression=compression,
167
+ rank=rank,
168
+ bias=False,
169
+ )
170
+
171
+ else:
172
+ raise (NotImplementedError)
173
+
174
+ def forward(self, x):
175
+ return self.filter(x)
176
+
177
+
178
+ class FourierNeuralOperatorBlock(nn.Module):
179
+ def __init__(
180
+ self,
181
+ forward_transform,
182
+ inverse_transform,
183
+ embed_dim,
184
+ filter_type="linear",
185
+ mlp_ratio=2.0,
186
+ drop_rate=0.0,
187
+ drop_path=0.0,
188
+ act_layer=nn.GELU,
189
+ norm_layer=(nn.LayerNorm, nn.LayerNorm),
190
+ # num_blocks = 8,
191
+ sparsity_threshold=0.0,
192
+ use_complex_kernels=True,
193
+ compression=None,
194
+ rank=128,
195
+ inner_skip="linear",
196
+ outer_skip=None, # None, nn.linear or nn.Identity
197
+ concat_skip=False,
198
+ mlp_mode="none",
199
+ complex_network=True,
200
+ complex_activation="real",
201
+ spectral_layers=1,
202
+ checkpointing=False,
203
+ ):
204
+ super(FourierNeuralOperatorBlock, self).__init__()
205
+
206
+ # norm layer
207
+ self.norm0 = norm_layer[0]() # ((h,w))
208
+
209
+ # convolution layer
210
+ self.filter_layer = SpectralFilterLayer(
211
+ forward_transform,
212
+ inverse_transform,
213
+ embed_dim,
214
+ filter_type,
215
+ sparsity_threshold,
216
+ use_complex_kernels=use_complex_kernels,
217
+ hidden_size_factor=mlp_ratio,
218
+ compression=compression,
219
+ rank=rank,
220
+ complex_network=complex_network,
221
+ complex_activation=complex_activation,
222
+ spectral_layers=spectral_layers,
223
+ drop_rate=drop_rate,
224
+ )
225
+
226
+ if inner_skip == "linear":
227
+ self.inner_skip = nn.Conv2d(embed_dim, embed_dim, 1, 1)
228
+ elif inner_skip == "identity":
229
+ self.inner_skip = nn.Identity()
230
+
231
+ self.concat_skip = concat_skip
232
+
233
+ if concat_skip and inner_skip is not None:
234
+ self.inner_skip_conv = nn.Conv2d(2 * embed_dim, embed_dim, 1, bias=False)
235
+
236
+ if filter_type == "linear":
237
+ self.act_layer = act_layer()
238
+
239
+ # dropout
240
+ self.drop_path = DropPath(drop_path) if drop_path > 0.0 else nn.Identity()
241
+
242
+ # norm layer
243
+ self.norm1 = norm_layer[1]() # ((h,w))
244
+
245
+ if mlp_mode != "none":
246
+ mlp_hidden_dim = int(embed_dim * mlp_ratio)
247
+ self.mlp = MLP(
248
+ in_features=embed_dim,
249
+ hidden_features=mlp_hidden_dim,
250
+ act_layer=act_layer,
251
+ drop_rate=drop_rate,
252
+ checkpointing=checkpointing,
253
+ )
254
+
255
+ if outer_skip == "linear":
256
+ self.outer_skip = nn.Conv2d(embed_dim, embed_dim, 1, 1)
257
+ elif outer_skip == "identity":
258
+ self.outer_skip = nn.Identity()
259
+
260
+ if concat_skip and outer_skip is not None:
261
+ self.outer_skip_conv = nn.Conv2d(2 * embed_dim, embed_dim, 1, bias=False)
262
+
263
+ def forward(self, x):
264
+ residual = x
265
+
266
+ x = self.norm0(x)
267
+ x = self.filter_layer(x).contiguous()
268
+
269
+ if hasattr(self, "inner_skip"):
270
+ if self.concat_skip:
271
+ x = torch.cat((x, self.inner_skip(residual)), dim=1)
272
+ x = self.inner_skip_conv(x)
273
+ else:
274
+ x = x + self.inner_skip(residual)
275
+
276
+ if hasattr(self, "act_layer"):
277
+ x = self.act_layer(x)
278
+
279
+ x = self.norm1(x)
280
+
281
+ if hasattr(self, "mlp"):
282
+ x = self.mlp(x)
283
+
284
+ x = self.drop_path(x)
285
+
286
+ if hasattr(self, "outer_skip"):
287
+ if self.concat_skip:
288
+ x = torch.cat((x, self.outer_skip(residual)), dim=1)
289
+ x = self.outer_skip_conv(x)
290
+ else:
291
+ x = x + self.outer_skip(residual)
292
+
293
+ return x
294
+
295
+ # @torch.jit.ignore
296
+ # def checkpoint_forward(self, x):
297
+ # return checkpoint(self._forward, x)
298
+
299
+ # def forward(self, x):
300
+ # if self.checkpointing:
301
+ # return self.checkpoint_forward(x)
302
+ # else:
303
+ # return self._forward(x)
304
+
305
+
306
+ class FourierNeuralOperatorNet(nn.Module):
307
+ def __init__(
308
+ self,
309
+ params,
310
+ spectral_transform="sht",
311
+ filter_type="non-linear",
312
+ img_size=(721, 1440),
313
+ scale_factor=16,
314
+ in_chans=2,
315
+ out_chans=2,
316
+ embed_dim=256,
317
+ num_layers=12,
318
+ mlp_mode="none",
319
+ mlp_ratio=2.0,
320
+ drop_rate=0.0,
321
+ drop_path_rate=0.0,
322
+ num_blocks=16,
323
+ sparsity_threshold=0.0,
324
+ normalization_layer="instance_norm",
325
+ hard_thresholding_fraction=1.0,
326
+ use_complex_kernels=True,
327
+ big_skip=True,
328
+ compression=None,
329
+ rank=128,
330
+ complex_network=True,
331
+ complex_activation="real",
332
+ spectral_layers=3,
333
+ laplace_weighting=False,
334
+ checkpointing=False,
335
+ ):
336
+ super(FourierNeuralOperatorNet, self).__init__()
337
+
338
+ self.params = params
339
+ self.spectral_transform = (
340
+ params.spectral_transform
341
+ if hasattr(params, "spectral_transform")
342
+ else spectral_transform
343
+ )
344
+ self.filter_type = (
345
+ params.filter_type if hasattr(params, "filter_type") else filter_type
346
+ )
347
+ self.img_size = (params.img_crop_shape_x, params.img_crop_shape_y)
348
+ self.scale_factor = (
349
+ params.scale_factor if hasattr(params, "scale_factor") else scale_factor
350
+ )
351
+ self.in_chans = (
352
+ params.N_in_channels if hasattr(params, "N_in_channels") else in_chans
353
+ )
354
+ self.out_chans = (
355
+ params.N_out_channels if hasattr(params, "N_out_channels") else out_chans
356
+ )
357
+ self.embed_dim = self.num_features = (
358
+ params.embed_dim if hasattr(params, "embed_dim") else embed_dim
359
+ )
360
+ self.num_layers = (
361
+ params.num_layers if hasattr(params, "num_layers") else num_layers
362
+ )
363
+ self.num_blocks = (
364
+ params.num_blocks if hasattr(params, "num_blocks") else num_blocks
365
+ )
366
+ self.hard_thresholding_fraction = (
367
+ params.hard_thresholding_fraction
368
+ if hasattr(params, "hard_thresholding_fraction")
369
+ else hard_thresholding_fraction
370
+ )
371
+ self.normalization_layer = (
372
+ params.normalization_layer
373
+ if hasattr(params, "normalization_layer")
374
+ else normalization_layer
375
+ )
376
+ self.mlp_mode = params.mlp_mode if hasattr(params, "mlp_mode") else mlp_mode
377
+ self.big_skip = params.big_skip if hasattr(params, "big_skip") else big_skip
378
+ self.compression = (
379
+ params.compression if hasattr(params, "compression") else compression
380
+ )
381
+ self.rank = params.rank if hasattr(params, "rank") else rank
382
+ self.complex_network = (
383
+ params.complex_network
384
+ if hasattr(params, "complex_network")
385
+ else complex_network
386
+ )
387
+ self.complex_activation = (
388
+ params.complex_activation
389
+ if hasattr(params, "complex_activation")
390
+ else complex_activation
391
+ )
392
+ self.spectral_layers = (
393
+ params.spectral_layers
394
+ if hasattr(params, "spectral_layers")
395
+ else spectral_layers
396
+ )
397
+ self.laplace_weighting = (
398
+ params.laplace_weighting
399
+ if hasattr(params, "laplace_weighting")
400
+ else laplace_weighting
401
+ )
402
+ self.checkpointing = (
403
+ params.checkpointing if hasattr(params, "checkpointing") else checkpointing
404
+ )
405
+
406
+ # compute downsampled image size
407
+ self.h = self.img_size[0] // self.scale_factor
408
+ self.w = self.img_size[1] // self.scale_factor
409
+
410
+ # dropout
411
+ self.pos_drop = nn.Dropout(p=drop_rate) if drop_rate > 0.0 else nn.Identity()
412
+ dpr = [x.item() for x in torch.linspace(0, drop_path_rate, self.num_layers)]
413
+
414
+ # pick norm layer
415
+ if self.normalization_layer == "layer_norm":
416
+ norm_layer0 = partial(
417
+ nn.LayerNorm,
418
+ normalized_shape=(self.img_size[0], self.img_size[1]),
419
+ eps=1e-6,
420
+ )
421
+ norm_layer1 = partial(
422
+ nn.LayerNorm, normalized_shape=(self.h, self.w), eps=1e-6
423
+ )
424
+ elif self.normalization_layer == "instance_norm":
425
+ norm_layer0 = partial(
426
+ nn.InstanceNorm2d,
427
+ num_features=self.embed_dim,
428
+ eps=1e-6,
429
+ affine=True,
430
+ track_running_stats=False,
431
+ )
432
+ norm_layer1 = norm_layer0
433
+ # elif self.normalization_layer == "batch_norm":
434
+ # norm_layer = partial(nn.InstanceNorm2d, num_features=self.embed_dim, eps=1e-6, affine=True, track_running_stats=False)
435
+ else:
436
+ raise NotImplementedError(
437
+ f"Error, normalization {self.normalization_layer} not implemented."
438
+ )
439
+
440
+ # ENCODER is just an MLP?
441
+ encoder_hidden_dim = self.embed_dim
442
+ encoder_act = nn.GELU
443
+
444
+ # encoder0 = nn.Conv2d(self.in_chans, encoder_hidden_dim, 1, bias=True)
445
+ # encoder1 = nn.Conv2d(encoder_hidden_dim, self.embed_dim, 1, bias=False)
446
+ # encoder_act = nn.GELU()
447
+ # self.encoder = nn.Sequential(encoder0, encoder_act, encoder1, norm_layer0())
448
+
449
+ self.encoder = MLP(
450
+ in_features=self.in_chans,
451
+ hidden_features=encoder_hidden_dim,
452
+ out_features=self.embed_dim,
453
+ output_bias=False,
454
+ act_layer=encoder_act,
455
+ drop_rate=0.0,
456
+ checkpointing=checkpointing,
457
+ )
458
+
459
+ # self.input_encoding = nn.Conv2d(self.in_chans, self.embed_dim, 1)
460
+ # self.pos_embed = nn.Parameter(torch.zeros(1, self.pos_embed_dim, self.img_size[0], self.img_size[1]))
461
+ self.pos_embed = nn.Parameter(
462
+ torch.zeros(1, self.embed_dim, self.img_size[0], self.img_size[1])
463
+ )
464
+
465
+ # prepare the SHT
466
+ modes_lat = int(self.h * self.hard_thresholding_fraction)
467
+ modes_lon = int((self.w // 2 + 1) * self.hard_thresholding_fraction)
468
+
469
+ if self.spectral_transform == "sht":
470
+ self.trans_down = SafeRealSHT(
471
+ *self.img_size, lmax=modes_lat, mmax=modes_lon, grid="equiangular"
472
+ ).float()
473
+ self.itrans_up = SafeInverseRealSHT(
474
+ *self.img_size, lmax=modes_lat, mmax=modes_lon, grid="equiangular"
475
+ ).float()
476
+ self.trans = SafeRealSHT(
477
+ self.h, self.w, lmax=modes_lat, mmax=modes_lon, grid="legendre-gauss"
478
+ ).float()
479
+ self.itrans = SafeInverseRealSHT(
480
+ self.h, self.w, lmax=modes_lat, mmax=modes_lon, grid="legendre-gauss"
481
+ ).float()
482
+
483
+ # we introduce some ad-hoc rescaling of the weights to aid gradient computation:
484
+ sht_rescaling_factor = 1e5
485
+ self.trans_down.weights = self.trans_down.weights * sht_rescaling_factor
486
+ self.itrans_up.pct = self.itrans_up.pct / sht_rescaling_factor
487
+ self.trans.weights = self.trans.weights * sht_rescaling_factor
488
+ self.itrans.pct = self.itrans.pct / sht_rescaling_factor
489
+
490
+ elif self.spectral_transform == "fft":
491
+ self.trans_down = RealFFT2(
492
+ *self.img_size, lmax=modes_lat, mmax=modes_lon
493
+ ).float()
494
+ self.itrans_up = InverseRealFFT2(
495
+ *self.img_size, lmax=modes_lat, mmax=modes_lon
496
+ ).float()
497
+ self.trans = RealFFT2(
498
+ self.h, self.w, lmax=modes_lat, mmax=modes_lon
499
+ ).float()
500
+ self.itrans = InverseRealFFT2(
501
+ self.h, self.w, lmax=modes_lat, mmax=modes_lon
502
+ ).float()
503
+ else:
504
+ raise (ValueError("Unknown spectral transform"))
505
+
506
+ self.blocks = nn.ModuleList([])
507
+ for i in range(self.num_layers):
508
+ first_layer = i == 0
509
+ last_layer = i == self.num_layers - 1
510
+
511
+ forward_transform = self.trans_down if first_layer else self.trans
512
+ inverse_transform = self.itrans_up if last_layer else self.itrans
513
+
514
+ inner_skip = "linear" if 0 < i < self.num_layers - 1 else None
515
+ outer_skip = "identity" if 0 < i < self.num_layers - 1 else None
516
+ mlp_mode = self.mlp_mode if not last_layer else "none"
517
+
518
+ if first_layer:
519
+ norm_layer = (norm_layer0, norm_layer1)
520
+ elif last_layer:
521
+ norm_layer = (norm_layer1, norm_layer0)
522
+ else:
523
+ norm_layer = (norm_layer1, norm_layer1)
524
+
525
+ block = FourierNeuralOperatorBlock(
526
+ forward_transform,
527
+ inverse_transform,
528
+ self.embed_dim,
529
+ filter_type=self.filter_type,
530
+ mlp_ratio=mlp_ratio,
531
+ drop_rate=drop_rate,
532
+ drop_path=dpr[i],
533
+ norm_layer=norm_layer,
534
+ sparsity_threshold=sparsity_threshold,
535
+ use_complex_kernels=use_complex_kernels,
536
+ inner_skip=inner_skip,
537
+ outer_skip=outer_skip,
538
+ mlp_mode=mlp_mode,
539
+ compression=self.compression,
540
+ rank=self.rank,
541
+ complex_network=self.complex_network,
542
+ complex_activation=self.complex_activation,
543
+ spectral_layers=self.spectral_layers,
544
+ checkpointing=self.checkpointing,
545
+ )
546
+
547
+ self.blocks.append(block)
548
+
549
+ # DECODER is also an MLP
550
+ decoder_hidden_dim = self.embed_dim
551
+ decoder_act = nn.GELU
552
+
553
+ # decoder0 = nn.Conv2d(self.embed_dim + self.big_skip*self.in_chans, decoder_hidden_dim, 1, bias=True)
554
+ # decoder1 = nn.Conv2d(decoder_hidden_dim, self.out_chans, 1, bias=False)
555
+ # decoder_act = nn.GELU()
556
+ # self.decoder = nn.Sequential(decoder0, decoder_act, decoder1)
557
+
558
+ self.decoder = MLP(
559
+ in_features=self.embed_dim + self.big_skip * self.in_chans,
560
+ hidden_features=decoder_hidden_dim,
561
+ out_features=self.out_chans,
562
+ output_bias=False,
563
+ act_layer=decoder_act,
564
+ drop_rate=0.0,
565
+ checkpointing=checkpointing,
566
+ )
567
+
568
+ trunc_normal_(self.pos_embed, std=0.02)
569
+ self.apply(self._init_weights)
570
+
571
+ def _init_weights(self, m):
572
+ if isinstance(m, nn.Linear) or isinstance(m, nn.Conv2d):
573
+ trunc_normal_(m.weight, std=0.02)
574
+ # nn.init.normal_(m.weight, std=0.02)
575
+ if m.bias is not None:
576
+ nn.init.constant_(m.bias, 0)
577
+ elif isinstance(m, nn.LayerNorm) or isinstance(m, FusedLayerNorm):
578
+ nn.init.constant_(m.bias, 0)
579
+ nn.init.constant_(m.weight, 1.0)
580
+
581
+ @torch.jit.ignore
582
+ def no_weight_decay(self):
583
+ return {"pos_embed", "cls_token"}
584
+
585
+ def forward_features(self, x):
586
+ # x = x + self.pos_embed
587
+ x = self.pos_drop(x)
588
+
589
+ for blk in self.blocks:
590
+ x = blk(x)
591
+
592
+ return x
593
+
594
+ def forward(self, x):
595
+ # save big skip
596
+ if self.big_skip:
597
+ residual = x
598
+
599
+ # encoder
600
+ x = self.encoder(x)
601
+
602
+ # do positional embedding
603
+ x = x + self.pos_embed
604
+
605
+ # forward features
606
+ x = self.forward_features(x)
607
+
608
+ # concatenate the big skip
609
+ if self.big_skip:
610
+ x = torch.cat((x, residual), dim=1)
611
+
612
+ # decoder
613
+ x = self.decoder(x)
614
+
615
+ return x
model/fourcastnet_v2.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OneScience adapter for NVIDIA's official legacy FourCastNet v2 network."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from types import SimpleNamespace
8
+ from typing import Any, Mapping
9
+
10
+ import torch
11
+ from torch import nn
12
+
13
+
14
+ def _load_model_class():
15
+ package_dir = Path(__file__).resolve().parent / "fcnv2"
16
+ if not (package_dir / "fcnv2_sfnonet.py").is_file():
17
+ raise FileNotFoundError(
18
+ f"Bundled FCNv2 source was not found at {package_dir}"
19
+ )
20
+
21
+ package_path = str(package_dir)
22
+ if package_path not in sys.path:
23
+ sys.path.insert(0, package_path)
24
+
25
+ try:
26
+ from fcnv2_sfnonet import FourierNeuralOperatorNet
27
+ except ModuleNotFoundError as error:
28
+ if error.name == "torch_harmonics":
29
+ raise ModuleNotFoundError(
30
+ "FourCastNet v2 requires NVIDIA torch-harmonics. Install the "
31
+ "version pinned by this project before constructing the model."
32
+ ) from error
33
+ raise
34
+ return FourierNeuralOperatorNet
35
+
36
+
37
+ def _official_params(model_config: Mapping[str, Any]) -> SimpleNamespace:
38
+ required = {
39
+ "img_size",
40
+ "in_channels",
41
+ "out_channels",
42
+ "spectral_transform",
43
+ "filter_type",
44
+ "scale_factor",
45
+ "embed_dim",
46
+ "num_layers",
47
+ "num_blocks",
48
+ "normalization_layer",
49
+ "mlp_mode",
50
+ "spectral_layers",
51
+ "complex_activation",
52
+ "hard_thresholding_fraction",
53
+ "big_skip",
54
+ }
55
+ missing = sorted(required.difference(model_config))
56
+ if missing:
57
+ raise ValueError(f"Missing FourCastNet v2 model settings: {missing}")
58
+
59
+ height, width = model_config["img_size"]
60
+ hidden_height = height // model_config["scale_factor"]
61
+ hidden_width = width // model_config["scale_factor"]
62
+ if hidden_height < 2 or hidden_width < 2:
63
+ raise ValueError("The internal SFNO grid must have at least 2 x 2 points")
64
+
65
+ return SimpleNamespace(
66
+ img_crop_shape_x=int(height),
67
+ img_crop_shape_y=int(width),
68
+ N_in_channels=int(model_config["in_channels"]),
69
+ N_out_channels=int(model_config["out_channels"]),
70
+ spectral_transform=model_config["spectral_transform"],
71
+ filter_type=model_config["filter_type"],
72
+ scale_factor=int(model_config["scale_factor"]),
73
+ embed_dim=int(model_config["embed_dim"]),
74
+ num_layers=int(model_config["num_layers"]),
75
+ num_blocks=int(model_config["num_blocks"]),
76
+ normalization_layer=model_config["normalization_layer"],
77
+ mlp_mode=model_config["mlp_mode"],
78
+ spectral_layers=int(model_config["spectral_layers"]),
79
+ complex_activation=model_config["complex_activation"],
80
+ hard_thresholding_fraction=float(
81
+ model_config["hard_thresholding_fraction"]
82
+ ),
83
+ big_skip=bool(model_config["big_skip"]),
84
+ )
85
+
86
+
87
+ class FourCastNetV2(nn.Module):
88
+ """Build the exact official FCNv2 network behind a stable project API."""
89
+
90
+ def __init__(
91
+ self,
92
+ model_config: Mapping[str, Any],
93
+ ) -> None:
94
+ super().__init__()
95
+ self.model_config = dict(model_config)
96
+ self.expected_shape = (
97
+ int(model_config["in_channels"]),
98
+ int(model_config["img_size"][0]),
99
+ int(model_config["img_size"][1]),
100
+ )
101
+ model_class = _load_model_class()
102
+ self.model = model_class(_official_params(model_config))
103
+
104
+ def forward(self, inputs: torch.Tensor) -> torch.Tensor:
105
+ if inputs.ndim != 4:
106
+ raise ValueError(f"Expected [B,C,H,W], got {tuple(inputs.shape)}")
107
+ if tuple(inputs.shape[1:]) != self.expected_shape:
108
+ raise ValueError(
109
+ f"Expected trailing shape {self.expected_shape}, "
110
+ f"got {tuple(inputs.shape[1:])}"
111
+ )
112
+ return self.model(inputs)
113
+
114
+ def no_weight_decay(self) -> set[str]:
115
+ return {f"model.{name}" for name in self.model.no_weight_decay()}
116
+
117
+
118
+ def _unwrap_state_dict(checkpoint: Any) -> Mapping[str, torch.Tensor]:
119
+ if not isinstance(checkpoint, Mapping):
120
+ raise TypeError("Checkpoint must contain a mapping")
121
+ for key in ("model_state", "model_state_dict", "state_dict"):
122
+ candidate = checkpoint.get(key)
123
+ if isinstance(candidate, Mapping):
124
+ return candidate
125
+ if checkpoint and all(isinstance(value, torch.Tensor) for value in checkpoint.values()):
126
+ return checkpoint
127
+ raise KeyError("Checkpoint has no recognized model state mapping")
128
+
129
+
130
+ def _normalize_state_keys(
131
+ state_dict: Mapping[str, torch.Tensor], model: nn.Module
132
+ ) -> dict[str, torch.Tensor]:
133
+ target_keys = set(model.state_dict())
134
+ normalized: dict[str, torch.Tensor] = {}
135
+ for key, value in state_dict.items():
136
+ clean_key = key
137
+ while clean_key.startswith("module."):
138
+ clean_key = clean_key[len("module.") :]
139
+ if clean_key not in target_keys and f"model.{clean_key}" in target_keys:
140
+ clean_key = f"model.{clean_key}"
141
+ normalized[clean_key] = value
142
+ return normalized
143
+
144
+
145
+ def load_checkpoint(
146
+ model: nn.Module,
147
+ checkpoint_path: str | Path,
148
+ *,
149
+ expected_profile: str,
150
+ expected_variables: list[str],
151
+ allowed_stages: set[str],
152
+ allowed_initializations: set[str],
153
+ strict: bool = True,
154
+ map_location: str | torch.device = "cpu",
155
+ ) -> dict[str, Any]:
156
+ """Load a project checkpoint without changing its parameter tensors."""
157
+
158
+ checkpoint = torch.load(
159
+ Path(checkpoint_path).expanduser(),
160
+ map_location=map_location,
161
+ weights_only=False,
162
+ )
163
+ validate_project_checkpoint(
164
+ checkpoint,
165
+ expected_profile=expected_profile,
166
+ expected_variables=expected_variables,
167
+ allowed_stages=allowed_stages,
168
+ allowed_initializations=allowed_initializations,
169
+ )
170
+ state_dict = _normalize_state_keys(_unwrap_state_dict(checkpoint), model)
171
+ incompatible = model.load_state_dict(state_dict, strict=strict)
172
+ return {
173
+ "checkpoint": checkpoint,
174
+ "missing_keys": list(incompatible.missing_keys),
175
+ "unexpected_keys": list(incompatible.unexpected_keys),
176
+ }
177
+
178
+
179
+ def validate_project_checkpoint(
180
+ checkpoint: Any,
181
+ *,
182
+ expected_profile: str,
183
+ expected_variables: list[str],
184
+ allowed_stages: set[str],
185
+ allowed_initializations: set[str],
186
+ ) -> None:
187
+ if not isinstance(checkpoint, Mapping):
188
+ raise TypeError("Project checkpoint must contain metadata")
189
+ expected = {
190
+ "checkpoint_format": "fourcastnet_v2_project",
191
+ "scratch_lineage": True,
192
+ "model_profile": expected_profile,
193
+ "variables": expected_variables,
194
+ }
195
+ for key, value in expected.items():
196
+ if checkpoint.get(key) != value:
197
+ raise ValueError(
198
+ f"Checkpoint metadata {key!r} does not match the project config"
199
+ )
200
+ if checkpoint.get("stage") not in allowed_stages:
201
+ raise ValueError(
202
+ f"Checkpoint stage must be one of {sorted(allowed_stages)}"
203
+ )
204
+ if checkpoint.get("initialization") not in allowed_initializations:
205
+ raise ValueError(
206
+ "Checkpoint does not have an approved random-initialization lineage"
207
+ )
208
+ expected_initialization = {
209
+ "one_step": "random",
210
+ "finetune": "one_step_checkpoint",
211
+ }.get(checkpoint.get("stage"))
212
+ if checkpoint.get("initialization") != expected_initialization:
213
+ raise ValueError(
214
+ "Checkpoint stage and initialization metadata are inconsistent"
215
+ )
scripts/common.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+ import torch
9
+ import yaml
10
+
11
+
12
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
13
+ DEFAULT_CONFIG = PROJECT_ROOT / "conf" / "config.yaml"
14
+
15
+
16
+ def load_config(path: str | Path = DEFAULT_CONFIG) -> dict[str, Any]:
17
+ config_path = Path(path).expanduser().resolve()
18
+ with config_path.open("r", encoding="utf-8") as stream:
19
+ config = yaml.safe_load(stream)
20
+ config["_config_path"] = str(config_path)
21
+ config["_project_root"] = str(PROJECT_ROOT)
22
+ validate_config(config)
23
+ return config
24
+
25
+
26
+ def validate_config(config: dict[str, Any]) -> None:
27
+ variables = config["data"]["variables"]
28
+ if len(variables) != 73 or len(set(variables)) != 73:
29
+ raise ValueError("FourCastNet v2 requires 73 unique variables")
30
+
31
+ profile_name = config["model"]["profile"]
32
+ profiles = config["model"]["profiles"]
33
+ if profile_name not in profiles:
34
+ raise ValueError(f"Unknown model profile: {profile_name}")
35
+
36
+ profile = profiles[profile_name]
37
+ if profile["in_channels"] != len(variables):
38
+ raise ValueError("Model input channels do not match the variable ledger")
39
+ if profile["out_channels"] != len(variables):
40
+ raise ValueError("Model output channels do not match the variable ledger")
41
+
42
+ if config["data"]["input_steps"] != 1:
43
+ raise ValueError("FourCastNet v2 expects exactly one input time step")
44
+ if config["data"]["output_steps"] != 1:
45
+ raise ValueError("One-step pretraining expects data.output_steps=1")
46
+ if config["training"]["finetune"]["autoregressive_steps"] < 2:
47
+ raise ValueError("Fine-tuning requires at least two autoregressive steps")
48
+ if config["inference"]["rollout_steps"] < 1:
49
+ raise ValueError("inference.rollout_steps must be positive")
50
+
51
+ if config["training"]["stage"] not in {"one_step", "finetune"}:
52
+ raise ValueError("training.stage must be 'one_step' or 'finetune'")
53
+ if config["checkpoint"]["initialize_from"] != "scratch":
54
+ raise ValueError("checkpoint.initialize_from must be 'scratch'")
55
+ if not config["checkpoint"].get("finetune_from"):
56
+ raise ValueError("checkpoint.finetune_from must name a one-step checkpoint")
57
+ prefix = config["checkpoint"].get("prefix", "model_bak")
58
+ if not prefix or Path(prefix).name != prefix:
59
+ raise ValueError("checkpoint.prefix must be a non-empty file name")
60
+
61
+
62
+ def resolve_path(config: dict[str, Any], value: str | Path) -> Path:
63
+ path = Path(value).expanduser()
64
+ if path.is_absolute():
65
+ return path
66
+ return Path(config["_project_root"]) / path
67
+
68
+
69
+ def active_model_config(config: dict[str, Any]) -> dict[str, Any]:
70
+ return dict(config["model"]["profiles"][config["model"]["profile"]])
71
+
72
+
73
+ def seed_everything(seed: int) -> None:
74
+ random.seed(seed)
75
+ np.random.seed(seed)
76
+ torch.manual_seed(seed)
77
+ if torch.cuda.is_available():
78
+ torch.cuda.manual_seed_all(seed)
scripts/data.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import torch
8
+ import torch.nn.functional as functional
9
+ from torch.utils.data import DataLoader, Dataset
10
+
11
+
12
+ def _import_era5_dataset(onescience_source_dir: str | None = None):
13
+ if onescience_source_dir:
14
+ source_dir = str(Path(onescience_source_dir).expanduser().resolve())
15
+ if source_dir not in sys.path:
16
+ sys.path.insert(0, source_dir)
17
+ from onescience.datapipes.climate import ERA5Dataset
18
+
19
+ return ERA5Dataset
20
+
21
+
22
+ class SpatialAdapter(Dataset):
23
+ """Resize OneScience ERA5 samples only for reduced smoke profiles."""
24
+
25
+ def __init__(self, dataset: Dataset, output_size: tuple[int, int]) -> None:
26
+ self.dataset = dataset
27
+ self.output_size = output_size
28
+
29
+ def __len__(self) -> int:
30
+ return len(self.dataset)
31
+
32
+ def _resize(self, tensor: torch.Tensor) -> torch.Tensor:
33
+ if tuple(tensor.shape[-2:]) == self.output_size:
34
+ return tensor
35
+ leading_shape = tensor.shape[:-2]
36
+ resized = functional.interpolate(
37
+ tensor.reshape(-1, 1, *tensor.shape[-2:]),
38
+ size=self.output_size,
39
+ mode="bilinear",
40
+ align_corners=False,
41
+ )
42
+ return resized.reshape(*leading_shape, *self.output_size)
43
+
44
+ def __getitem__(self, index: int):
45
+ inputs, targets, cos_zenith, step_idx, time_index = self.dataset[index]
46
+ inputs = self._resize(inputs)
47
+ targets = self._resize(targets)
48
+ cos_zenith = self._resize(cos_zenith)
49
+ return inputs, targets, cos_zenith, step_idx, time_index
50
+
51
+
52
+ def build_dataset(
53
+ config: dict[str, Any],
54
+ years: list[int],
55
+ *,
56
+ output_steps: int = 1,
57
+ ) -> Dataset:
58
+ from common import active_model_config, resolve_path
59
+
60
+ era5_dataset = _import_era5_dataset(config["project"].get("onescience_source_dir"))
61
+ data_config = config["data"]
62
+ dataset = era5_dataset(
63
+ dataset_dir=str(resolve_path(config, data_config["dataset_dir"])),
64
+ used_years=years,
65
+ used_variables=data_config["variables"],
66
+ input_steps=data_config["input_steps"],
67
+ output_steps=output_steps,
68
+ normalize=data_config["normalize"],
69
+ )
70
+ model_size = tuple(active_model_config(config)["img_size"])
71
+ data_size = tuple(data_config["grid_shape"])
72
+ if model_size != data_size:
73
+ dataset = SpatialAdapter(dataset, model_size)
74
+ return dataset
75
+
76
+
77
+ def build_loader(
78
+ config: dict[str, Any],
79
+ years: list[int],
80
+ *,
81
+ train: bool,
82
+ distributed: bool,
83
+ output_steps: int = 1,
84
+ ) -> tuple[DataLoader, torch.utils.data.Sampler | None]:
85
+ dataset = build_dataset(config, years, output_steps=output_steps)
86
+ sampler = None
87
+ if distributed:
88
+ sampler = torch.utils.data.distributed.DistributedSampler(
89
+ dataset, shuffle=train
90
+ )
91
+ loader = DataLoader(
92
+ dataset,
93
+ batch_size=config["training"]["batch_size"],
94
+ shuffle=train and sampler is None,
95
+ sampler=sampler,
96
+ num_workers=config["training"]["num_workers"],
97
+ pin_memory=True,
98
+ drop_last=False,
99
+ )
100
+ return loader, sampler
101
+
102
+
103
+ def load_statistics(config: dict[str, Any]) -> tuple[torch.Tensor, torch.Tensor]:
104
+ import h5py
105
+ import numpy as np
106
+
107
+ from common import resolve_path
108
+
109
+ data_config = config["data"]
110
+ year = data_config["test_years"][0]
111
+ path = resolve_path(config, data_config["dataset_dir"]) / "data" / f"{year}.h5"
112
+ with h5py.File(path, "r") as handle:
113
+ fields = handle["fields"]
114
+ all_variables = [
115
+ item.decode() if isinstance(item, bytes) else str(item)
116
+ for item in fields.attrs["variables"]
117
+ ]
118
+ indices = [all_variables.index(name) for name in data_config["variables"]]
119
+ if "global_means" in handle:
120
+ means = handle["global_means"][:]
121
+ stds = handle["global_stds"][:]
122
+ else:
123
+ stats_dir = path.parents[1] / "stats"
124
+ means = np.load(stats_dir / "global_means.npy")
125
+ stds = np.load(stats_dir / "global_stds.npy")
126
+ return torch.from_numpy(means[:, indices]), torch.from_numpy(stds[:, indices])
scripts/fake_data.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ SCRIPT_DIR = Path(__file__).resolve().parent
8
+ if str(SCRIPT_DIR) not in sys.path:
9
+ sys.path.insert(0, str(SCRIPT_DIR))
10
+
11
+ import h5py
12
+ import numpy as np
13
+
14
+ from common import DEFAULT_CONFIG, load_config, resolve_path
15
+
16
+
17
+ def generate_year(
18
+ path: Path,
19
+ variables: list[str],
20
+ *,
21
+ time_steps: int,
22
+ height: int,
23
+ width: int,
24
+ time_step_hours: int,
25
+ chunk_time_steps: int,
26
+ fill_value: float,
27
+ materialize_pattern: bool,
28
+ ) -> None:
29
+ path.parent.mkdir(parents=True, exist_ok=True)
30
+ channels = len(variables)
31
+ with h5py.File(path, "w") as handle:
32
+ fields = handle.create_dataset(
33
+ "fields",
34
+ shape=(time_steps, channels, height, width),
35
+ dtype=np.float32,
36
+ chunks=(chunk_time_steps, 1, height, width),
37
+ fillvalue=np.float32(fill_value),
38
+ compression="lzf",
39
+ )
40
+ fields.attrs["variables"] = np.asarray(variables, dtype=h5py.string_dtype())
41
+ fields.attrs["time_step"] = time_step_hours
42
+ handle.create_dataset(
43
+ "global_means", data=np.zeros((1, channels, 1, 1), dtype=np.float32)
44
+ )
45
+ handle.create_dataset(
46
+ "global_stds", data=np.ones((1, channels, 1, 1), dtype=np.float32)
47
+ )
48
+
49
+ if materialize_pattern:
50
+ latitude = np.linspace(1.0, -1.0, height, dtype=np.float32)[:, None]
51
+ longitude = np.linspace(
52
+ 0.0, 2.0 * np.pi, width, endpoint=False, dtype=np.float32
53
+ )
54
+ base = latitude + np.sin(longitude)[None, :]
55
+ # Two frames are enough to exercise non-zero input and target reads.
56
+ for time_index in range(min(time_steps, 2)):
57
+ for channel_index in range(channels):
58
+ fields[time_index, channel_index] = (
59
+ base + channel_index / channels + time_index * 0.01
60
+ )
61
+
62
+
63
+ def main() -> None:
64
+ parser = argparse.ArgumentParser(description="Generate ERA5-compatible FCNv2 data")
65
+ parser.add_argument("--config", default=str(DEFAULT_CONFIG))
66
+ parser.add_argument("--no-pattern", action="store_true")
67
+ args = parser.parse_args()
68
+
69
+ config = load_config(args.config)
70
+ data = config["data"]
71
+ fake = config["fake_data"]
72
+ output_dir = resolve_path(config, data["dataset_dir"])
73
+ years = sorted(set(data["train_years"] + data["val_years"] + data["test_years"]))
74
+ height, width = data["grid_shape"]
75
+ for year in years:
76
+ path = output_dir / "data" / f"{year}.h5"
77
+ generate_year(
78
+ path,
79
+ data["variables"],
80
+ time_steps=fake["time_steps_per_year"],
81
+ height=height,
82
+ width=width,
83
+ time_step_hours=data["time_step_hours"],
84
+ chunk_time_steps=fake["chunk_time_steps"],
85
+ fill_value=fake["fill_value"],
86
+ materialize_pattern=fake["materialize_pattern"] and not args.no_pattern,
87
+ )
88
+ logical_gib = (
89
+ fake["time_steps_per_year"] * len(data["variables"]) * height * width * 4
90
+ ) / 1024**3
91
+ print(f"{path}: logical={logical_gib:.2f} GiB, actual={path.stat().st_size / 1024**2:.2f} MiB")
92
+
93
+
94
+ if __name__ == "__main__":
95
+ main()
scripts/inference.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ SCRIPT_DIR = Path(__file__).resolve().parent
8
+ MODEL_DIR = SCRIPT_DIR.parent / "model"
9
+ for module_dir in (SCRIPT_DIR, MODEL_DIR):
10
+ if str(module_dir) not in sys.path:
11
+ sys.path.insert(0, str(module_dir))
12
+
13
+ import numpy as np
14
+ import torch
15
+
16
+ from common import DEFAULT_CONFIG, active_model_config, load_config, resolve_path
17
+ from data import build_loader, load_statistics
18
+ from fourcastnet_v2 import (
19
+ FourCastNetV2,
20
+ load_checkpoint,
21
+ )
22
+
23
+
24
+ def choose_device() -> torch.device:
25
+ return torch.device("cuda", 0) if torch.cuda.is_available() else torch.device("cpu")
26
+
27
+
28
+ def main() -> None:
29
+ parser = argparse.ArgumentParser(description="Run FourCastNet v2 inference")
30
+ parser.add_argument("--config", default=str(DEFAULT_CONFIG))
31
+ parser.add_argument("--checkpoint")
32
+ args = parser.parse_args()
33
+
34
+ config = load_config(args.config)
35
+ inference = config["inference"]
36
+
37
+ device = choose_device()
38
+ model = FourCastNetV2(active_model_config(config)).to(device)
39
+ if args.checkpoint:
40
+ checkpoint_path = resolve_path(config, args.checkpoint)
41
+ else:
42
+ checkpoint_path = resolve_path(config, inference["checkpoint_path"])
43
+ result = load_checkpoint(
44
+ model,
45
+ checkpoint_path,
46
+ expected_profile=config["model"]["profile"],
47
+ expected_variables=config["data"]["variables"],
48
+ allowed_stages={"one_step", "finetune"},
49
+ allowed_initializations={"random", "one_step_checkpoint"},
50
+ strict=config["checkpoint"]["strict"],
51
+ map_location=device,
52
+ )
53
+ if result["missing_keys"] or result["unexpected_keys"]:
54
+ print(
55
+ f"missing_keys={result['missing_keys']} "
56
+ f"unexpected_keys={result['unexpected_keys']}"
57
+ )
58
+
59
+ loader, _ = build_loader(
60
+ config,
61
+ config["data"]["test_years"],
62
+ train=False,
63
+ distributed=False,
64
+ output_steps=inference["rollout_steps"],
65
+ )
66
+ means, stds = load_statistics(config)
67
+ means = means.numpy()
68
+ stds = stds.numpy()
69
+
70
+ output_dir = resolve_path(config, inference["output_dir"])
71
+ output_dir.mkdir(parents=True, exist_ok=True)
72
+ model.eval()
73
+ with torch.no_grad():
74
+ for sample_index, batch in enumerate(loader):
75
+ if sample_index >= inference["max_samples"]:
76
+ break
77
+ inputs = batch[0].to(device)
78
+ targets = batch[1]
79
+ state = inputs
80
+ predictions = []
81
+ for _ in range(inference["rollout_steps"]):
82
+ state = model(state)
83
+ predictions.append(state.cpu())
84
+ prediction = torch.stack(predictions, dim=1).numpy()
85
+ if targets.ndim == 4:
86
+ targets = targets.unsqueeze(1)
87
+ target = targets.numpy()
88
+ input_array = inputs.cpu().numpy()
89
+ if not inference["save_normalized"]:
90
+ prediction = prediction * stds[:, None] + means[:, None]
91
+ target = target * stds[:, None] + means[:, None]
92
+ input_array = input_array * stds + means
93
+ path = output_dir / f"sample_{sample_index:04d}.npz"
94
+ np.savez_compressed(
95
+ path,
96
+ input=input_array,
97
+ prediction=prediction,
98
+ target=target,
99
+ variables=np.asarray(config["data"]["variables"]),
100
+ time_index=np.asarray(batch[4], dtype=str).T,
101
+ )
102
+ print(path)
103
+
104
+
105
+ if __name__ == "__main__":
106
+ main()
scripts/result.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ SCRIPT_DIR = Path(__file__).resolve().parent
9
+ if str(SCRIPT_DIR) not in sys.path:
10
+ sys.path.insert(0, str(SCRIPT_DIR))
11
+
12
+ import matplotlib
13
+
14
+ matplotlib.use("Agg")
15
+ import matplotlib.pyplot as plt
16
+ import numpy as np
17
+
18
+ from common import DEFAULT_CONFIG, load_config, resolve_path
19
+
20
+
21
+ def latitude_weights(height: int) -> np.ndarray:
22
+ latitude = np.linspace(np.pi / 2, -np.pi / 2, height)
23
+ weights = np.cos(latitude).clip(min=0)
24
+ return weights / weights.mean()
25
+
26
+
27
+ def compute_metrics(prediction: np.ndarray, target: np.ndarray) -> dict[str, list[float]]:
28
+ weights = latitude_weights(target.shape[-2])[None, None, None, :, None]
29
+ error = prediction - target
30
+ rmse = np.sqrt(np.mean(error**2 * weights, axis=(0, 1, 3, 4)))
31
+ spatial_weight = weights / (
32
+ weights.sum(axis=(-2, -1), keepdims=True) * target.shape[-1]
33
+ )
34
+ pred_mean = np.sum(prediction * spatial_weight, axis=(-2, -1), keepdims=True)
35
+ target_mean = np.sum(target * spatial_weight, axis=(-2, -1), keepdims=True)
36
+ pred_anomaly = prediction - pred_mean
37
+ target_anomaly = target - target_mean
38
+ numerator = np.sum(pred_anomaly * target_anomaly * weights, axis=(0, 1, 3, 4))
39
+ denominator = np.sqrt(
40
+ np.sum(pred_anomaly**2 * weights, axis=(0, 1, 3, 4))
41
+ * np.sum(target_anomaly**2 * weights, axis=(0, 1, 3, 4))
42
+ )
43
+ acc = numerator / np.maximum(denominator, 1e-12)
44
+ return {"rmse": rmse.tolist(), "acc": acc.tolist()}
45
+
46
+
47
+ def plot_sample(
48
+ prediction: np.ndarray,
49
+ target: np.ndarray,
50
+ variable: str,
51
+ channel_index: int,
52
+ cmap: str,
53
+ output_path: Path,
54
+ ) -> None:
55
+ predicted = prediction[0, 0, channel_index]
56
+ expected = target[0, 0, channel_index]
57
+ error = predicted - expected
58
+ value_min = min(predicted.min(), expected.min())
59
+ value_max = max(predicted.max(), expected.max())
60
+ error_limit = max(abs(error.min()), abs(error.max()), 1e-12)
61
+ extent = (0, 360, -90, 90)
62
+
63
+ figure, axes = plt.subplots(3, 1, figsize=(12, 10), constrained_layout=True)
64
+ image = axes[0].imshow(
65
+ expected, origin="upper", extent=extent, aspect="auto", cmap=cmap,
66
+ vmin=value_min, vmax=value_max,
67
+ )
68
+ axes[0].set_title(f"Target {variable}")
69
+ figure.colorbar(image, ax=axes[0], orientation="vertical")
70
+ image = axes[1].imshow(
71
+ predicted, origin="upper", extent=extent, aspect="auto", cmap=cmap,
72
+ vmin=value_min, vmax=value_max,
73
+ )
74
+ axes[1].set_title(f"Prediction {variable}")
75
+ figure.colorbar(image, ax=axes[1], orientation="vertical")
76
+ image = axes[2].imshow(
77
+ error, origin="upper", extent=extent, aspect="auto", cmap="RdBu_r",
78
+ vmin=-error_limit, vmax=error_limit,
79
+ )
80
+ axes[2].set_title(f"Error {variable}")
81
+ figure.colorbar(image, ax=axes[2], orientation="vertical")
82
+ for axis in axes:
83
+ axis.set_xlabel("Longitude")
84
+ axis.set_ylabel("Latitude")
85
+ output_path.parent.mkdir(parents=True, exist_ok=True)
86
+ figure.savefig(output_path, dpi=160)
87
+ plt.close(figure)
88
+
89
+
90
+ def main() -> None:
91
+ parser = argparse.ArgumentParser(description="Evaluate and plot FCNv2 output")
92
+ parser.add_argument("--config", default=str(DEFAULT_CONFIG))
93
+ parser.add_argument("--input")
94
+ args = parser.parse_args()
95
+
96
+ config = load_config(args.config)
97
+ inference_dir = resolve_path(config, config["inference"]["output_dir"])
98
+ input_path = Path(args.input).expanduser().resolve() if args.input else None
99
+ files = [input_path] if input_path else sorted(inference_dir.glob("sample_*.npz"))
100
+ if not files:
101
+ raise FileNotFoundError(f"No inference outputs found in {inference_dir}")
102
+
103
+ predictions = []
104
+ targets = []
105
+ for path in files:
106
+ with np.load(path) as data:
107
+ predictions.append(data["prediction"])
108
+ targets.append(data["target"])
109
+ prediction = np.concatenate(predictions)
110
+ target = np.concatenate(targets)
111
+ metrics = compute_metrics(prediction, target)
112
+
113
+ output_dir = resolve_path(config, config["visualization"]["output_dir"])
114
+ output_dir.mkdir(parents=True, exist_ok=True)
115
+ (output_dir / "metrics.json").write_text(
116
+ json.dumps(metrics, indent=2), encoding="utf-8"
117
+ )
118
+ variable = config["visualization"]["variable"]
119
+ channel_index = config["data"]["variables"].index(variable)
120
+ sample_index = config["visualization"]["sample_index"]
121
+ if not 0 <= sample_index < prediction.shape[0]:
122
+ raise IndexError(
123
+ f"visualization.sample_index={sample_index} is outside "
124
+ f"the available range [0, {prediction.shape[0] - 1}]"
125
+ )
126
+ plot_sample(
127
+ prediction[sample_index : sample_index + 1],
128
+ target[sample_index : sample_index + 1],
129
+ variable,
130
+ channel_index,
131
+ config["visualization"]["cmap"],
132
+ output_dir / f"{variable}_forecast.png",
133
+ )
134
+ print(output_dir / "metrics.json")
135
+ print(output_dir / f"{variable}_forecast.png")
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
scripts/train.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ from contextlib import nullcontext
9
+ from pathlib import Path
10
+ from typing import Any, Iterable
11
+
12
+ import torch
13
+ import torch.distributed as dist
14
+ from torch.nn.parallel import DistributedDataParallel
15
+
16
+ SCRIPT_DIR = Path(__file__).resolve().parent
17
+ MODEL_DIR = SCRIPT_DIR.parent / "model"
18
+ for module_dir in (SCRIPT_DIR, MODEL_DIR):
19
+ if str(module_dir) not in sys.path:
20
+ sys.path.insert(0, str(module_dir))
21
+
22
+ from common import (
23
+ DEFAULT_CONFIG,
24
+ active_model_config,
25
+ load_config,
26
+ resolve_path,
27
+ seed_everything,
28
+ )
29
+ from data import build_loader
30
+ from fourcastnet_v2 import (
31
+ FourCastNetV2,
32
+ load_checkpoint,
33
+ )
34
+
35
+
36
+ def initialize_distributed(backend: str) -> tuple[torch.device, int, int, int]:
37
+ world_size = int(os.environ.get("WORLD_SIZE", "1"))
38
+ local_rank = int(os.environ.get("LOCAL_RANK", "0"))
39
+ if torch.cuda.is_available():
40
+ device_count = torch.cuda.device_count()
41
+ if not 0 <= local_rank < device_count:
42
+ raise RuntimeError(
43
+ f"LOCAL_RANK={local_rank} is not available; "
44
+ f"this process can see {device_count} CUDA devices "
45
+ f"(CUDA_VISIBLE_DEVICES={os.environ.get('CUDA_VISIBLE_DEVICES', '<unset>')})"
46
+ )
47
+ # Select the rank-local device before NCCL initialization. Otherwise
48
+ # every process starts with the default device (usually cuda:0).
49
+ torch.cuda.set_device(local_rank)
50
+ device = torch.device("cuda", local_rank)
51
+ else:
52
+ device = torch.device("cpu")
53
+
54
+ if world_size > 1 and not dist.is_initialized():
55
+ dist.init_process_group(backend=backend, init_method="env://")
56
+ rank = dist.get_rank() if dist.is_initialized() else 0
57
+ return device, rank, local_rank, world_size
58
+
59
+
60
+ def spherical_relative_l2(prediction: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
61
+ height = target.shape[-2]
62
+ latitude = torch.linspace(
63
+ torch.pi / 2,
64
+ -torch.pi / 2,
65
+ height,
66
+ device=target.device,
67
+ dtype=target.dtype,
68
+ )
69
+ weights = torch.cos(latitude).clamp_min(0)
70
+ weights = weights / weights.mean()
71
+ weights = weights.view(1, 1, height, 1)
72
+ error = ((prediction - target).square() * weights).sum(dim=(-2, -1))
73
+ reference = (target.square() * weights).sum(dim=(-2, -1)).clamp_min(1e-12)
74
+ return torch.sqrt(error / reference).mean()
75
+
76
+
77
+ def autoregressive_loss(
78
+ model: torch.nn.Module,
79
+ inputs: torch.Tensor,
80
+ targets: torch.Tensor,
81
+ steps: int,
82
+ ) -> torch.Tensor:
83
+ if steps == 1:
84
+ targets = targets if targets.ndim == 4 else targets[:, 0]
85
+ elif targets.ndim != 5 or targets.shape[1] != steps:
86
+ raise ValueError(f"Expected targets [B,{steps},C,H,W], got {targets.shape}")
87
+
88
+ state = inputs
89
+ losses = []
90
+ for step in range(steps):
91
+ state = model(state)
92
+ target = targets if steps == 1 else targets[:, step]
93
+ losses.append(spherical_relative_l2(state, target))
94
+ return torch.stack(losses).mean()
95
+
96
+
97
+ def limited_batches(loader: Iterable, maximum: int | None):
98
+ for index, batch in enumerate(loader):
99
+ if maximum is not None and index >= maximum:
100
+ break
101
+ yield batch
102
+
103
+
104
+ def reduce_average(total: float, count: int, device: torch.device) -> float:
105
+ values = torch.tensor([total, count], dtype=torch.float64, device=device)
106
+ if dist.is_initialized():
107
+ dist.all_reduce(values, op=dist.ReduceOp.SUM)
108
+ return (values[0] / values[1].clamp_min(1)).item()
109
+
110
+
111
+ def run_epoch(
112
+ model: torch.nn.Module,
113
+ loader,
114
+ device: torch.device,
115
+ *,
116
+ steps: int,
117
+ optimizer: torch.optim.Optimizer | None,
118
+ amp: bool,
119
+ max_batches: int | None,
120
+ max_grad_norm: float,
121
+ ) -> float:
122
+ training = optimizer is not None
123
+ model.train(training)
124
+ total = 0.0
125
+ count = 0
126
+ context = nullcontext if training else torch.no_grad
127
+ with context():
128
+ for batch in limited_batches(loader, max_batches):
129
+ inputs = batch[0].to(device, non_blocking=True)
130
+ targets = batch[1].to(device, non_blocking=True)
131
+ if training:
132
+ optimizer.zero_grad(set_to_none=True)
133
+ with torch.autocast(
134
+ device_type=device.type,
135
+ dtype=torch.float16,
136
+ enabled=amp and device.type == "cuda",
137
+ ):
138
+ loss = autoregressive_loss(model, inputs, targets, steps)
139
+ if training:
140
+ loss.backward()
141
+ torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm)
142
+ optimizer.step()
143
+ total += loss.detach().item()
144
+ count += 1
145
+ return reduce_average(total, count, device)
146
+
147
+
148
+ def save_checkpoint_atomic(path: Path, state: dict[str, Any]) -> None:
149
+ path.parent.mkdir(parents=True, exist_ok=True)
150
+ with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as stream:
151
+ temporary_path = Path(stream.name)
152
+ try:
153
+ torch.save(state, temporary_path)
154
+ os.replace(temporary_path, path)
155
+ finally:
156
+ temporary_path.unlink(missing_ok=True)
157
+
158
+
159
+ def main() -> None:
160
+ parser = argparse.ArgumentParser(description="Train FourCastNet v2")
161
+ parser.add_argument("--config", default=str(DEFAULT_CONFIG))
162
+ parser.add_argument("--stage", choices=("one_step", "finetune"))
163
+ parser.add_argument("--resume")
164
+ args = parser.parse_args()
165
+
166
+ config = load_config(args.config)
167
+ seed_everything(config["project"]["seed"])
168
+ training = config["training"]
169
+ stage = args.stage or training["stage"]
170
+ if stage == "one_step" and args.resume:
171
+ raise ValueError("One-step training always starts from random initialization")
172
+ steps = 1 if stage == "one_step" else training["finetune"]["autoregressive_steps"]
173
+ epochs = training["epochs"] if stage == "one_step" else training["finetune"]["epochs"]
174
+ learning_rate = (
175
+ training["learning_rate"]
176
+ if stage == "one_step"
177
+ else training["finetune"]["learning_rate"]
178
+ )
179
+
180
+ device, rank, local_rank, world_size = initialize_distributed(
181
+ config["distributed"]["backend"]
182
+ )
183
+ print(
184
+ f"rank={rank}/{world_size} local_rank={local_rank} "
185
+ f"device={device} visible_devices={torch.cuda.device_count()}",
186
+ flush=True,
187
+ )
188
+ train_loader, train_sampler = build_loader(
189
+ config,
190
+ config["data"]["train_years"],
191
+ train=True,
192
+ distributed=world_size > 1,
193
+ output_steps=steps,
194
+ )
195
+ val_loader, val_sampler = build_loader(
196
+ config,
197
+ config["data"]["val_years"],
198
+ train=False,
199
+ distributed=world_size > 1,
200
+ output_steps=steps,
201
+ )
202
+
203
+ model = FourCastNetV2(active_model_config(config)).to(device)
204
+ if stage == "finetune":
205
+ resume_path = args.resume or config["checkpoint"]["finetune_from"]
206
+ result = load_checkpoint(
207
+ model,
208
+ resolve_path(config, resume_path),
209
+ expected_profile=config["model"]["profile"],
210
+ expected_variables=config["data"]["variables"],
211
+ allowed_stages={"one_step"},
212
+ allowed_initializations={"random"},
213
+ strict=config["checkpoint"]["strict"],
214
+ map_location=device,
215
+ )
216
+
217
+ optimizer = torch.optim.AdamW(
218
+ model.parameters(),
219
+ lr=learning_rate,
220
+ betas=tuple(training["optimizer_betas"]),
221
+ weight_decay=training["weight_decay"],
222
+ )
223
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
224
+ if world_size > 1:
225
+ ddp_options = (
226
+ {
227
+ "device_ids": [device.index],
228
+ "output_device": device.index,
229
+ # SFNO's SHT buffers are immutable coefficients. DDP's
230
+ # per-forward buffer broadcast mutates them in-place and
231
+ # invalidates the graph in multi-step autoregressive training.
232
+ "broadcast_buffers": False,
233
+ }
234
+ if device.type == "cuda"
235
+ else {"broadcast_buffers": False}
236
+ )
237
+ model = DistributedDataParallel(model, **ddp_options)
238
+
239
+ checkpoint_dir = (
240
+ resolve_path(config, config["project"]["checkpoint_dir"]) / stage
241
+ )
242
+ checkpoint_prefix = config["checkpoint"].get("prefix", "model_bak")
243
+ best_loss = float("inf")
244
+ history = []
245
+ for epoch in range(epochs):
246
+ if train_sampler is not None:
247
+ train_sampler.set_epoch(epoch)
248
+ if val_sampler is not None:
249
+ val_sampler.set_epoch(epoch)
250
+ train_loss = run_epoch(
251
+ model,
252
+ train_loader,
253
+ device,
254
+ steps=steps,
255
+ optimizer=optimizer,
256
+ amp=training["amp"],
257
+ max_batches=training["max_train_batches"],
258
+ max_grad_norm=training["max_grad_norm"],
259
+ )
260
+ val_loss = run_epoch(
261
+ model,
262
+ val_loader,
263
+ device,
264
+ steps=steps,
265
+ optimizer=None,
266
+ amp=training["amp"],
267
+ max_batches=training["max_val_batches"],
268
+ max_grad_norm=training["max_grad_norm"],
269
+ )
270
+ scheduler.step()
271
+ history.append({"epoch": epoch, "train_loss": train_loss, "val_loss": val_loss})
272
+ if rank == 0:
273
+ print(
274
+ f"epoch={epoch + 1}/{epochs} train_loss={train_loss:.6f} "
275
+ f"val_loss={val_loss:.6f}"
276
+ )
277
+ raw_model = model.module if hasattr(model, "module") else model
278
+ state = {
279
+ "checkpoint_format": "fourcastnet_v2_project",
280
+ "scratch_lineage": True,
281
+ "model_state_dict": raw_model.state_dict(),
282
+ "optimizer_state_dict": optimizer.state_dict(),
283
+ "scheduler_state_dict": scheduler.state_dict(),
284
+ "epoch": epoch,
285
+ "stage": stage,
286
+ "initialization": (
287
+ "random" if stage == "one_step" else "one_step_checkpoint"
288
+ ),
289
+ "model_profile": config["model"]["profile"],
290
+ "variables": config["data"]["variables"],
291
+ }
292
+ save_checkpoint_atomic(checkpoint_dir / f"{checkpoint_prefix}_last.pt", state)
293
+ if val_loss < best_loss:
294
+ best_loss = val_loss
295
+ save_checkpoint_atomic(checkpoint_dir / f"{checkpoint_prefix}.pt", state)
296
+ checkpoint_dir.mkdir(parents=True, exist_ok=True)
297
+ (checkpoint_dir / "history.json").write_text(
298
+ json.dumps(history, indent=2), encoding="utf-8"
299
+ )
300
+
301
+ if dist.is_initialized():
302
+ dist.destroy_process_group()
303
+
304
+
305
+ if __name__ == "__main__":
306
+ main()
weight/.gitkeep ADDED
File without changes