Zhongning commited on
Commit
9c16f7b
·
verified ·
1 Parent(s): 5f602dd

Upload folder using huggingface_hub

Browse files
.DS_Store ADDED
Binary file (6.15 kB). View file
 
.ms_upload_cache ADDED
@@ -0,0 +1 @@
 
 
1
+ {"version": 3, "repo_id": "OneScience/WeatherNext2", "files": {"model/__pycache__/fgn.cpython-311.pyc|1787129521.0|26822": {"hash": "194470d61ce734af9a3531410be74b5f6344e55028cf50334b5ffb63e9359484", "size": 26822, "status": "c"}, "conf/config.yaml|1787189086.0|3101": {"hash": "837175aa730e9754739f9eb85b5ccaaf198a83e26bdc1485410e4a5d8a39330d", "size": 3101, "status": "c"}, "README.md|1787118190.0|7664": {"hash": "4058aaeb1f88068ac319b037830b1e6b0667d5a58f9f3cbed9b62ed95cb5face", "size": 7664, "status": "c"}, "scripts/fake_data.py|1787118238.0|3101": {"hash": "478615dcf3e95eaae7a8c24f913c2c938f5aa65b18292ca7e0db3b224b2ecfa9", "size": 3101, "status": "c"}, "scripts/result.py|1787110546.0|8612": {"hash": "7bb894cb829949e18ae2fa3702f26eec5b97cdfcbd97d515965e13ed811a5932", "size": 8612, "status": "c"}, "scripts/inference.py|1787110490.0|3682": {"hash": "158ba56d5e71f27cff76254205895505a39d2ad4a31421b697c1c3df1542f79c", "size": 3682, "status": "c"}, "model/fgn.py|1787110319.0|17001": {"hash": "21c87f83975836c72f29da3c7dea2cbebe777bd2b94cd8962815546ef245d12d", "size": 17001, "status": "c"}, "configuration.json|1787110570.0|38": {"hash": "97d4072fc4a7a3b71e3184609fc491e77a324a982d9df860d9379c009dff84de", "size": 38, "status": "c"}, "scripts/train.py|1787110476.0|9363": {"hash": "c2abc2179712f6366dcb7ef6fa074ec9b62bd7e0a4c466c56983934233936cce", "size": 9363, "status": "c"}, "weight/.gitkeep|1787110570.0|0": {"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "size": 0, "status": "c"}}}
README.md ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - en
5
+ - zh
6
+ tags:
7
+ - OneScience
8
+ - Earth science
9
+ - Weather forecasting
10
+ - Probabilistic weather forecasting
11
+ - Ensemble forecasting
12
+ - Graph neural networks
13
+ - Conditional normalization
14
+ - CRPS
15
+ - ERA5
16
+ frameworks: PyTorch
17
+ datasets:
18
+ - OneScience/ERA5
19
+ ---
20
+ <p align="center">
21
+ <strong>
22
+ <span style="font-size: 30px;">WeatherNext2 · FGN</span>
23
+ </strong>
24
+ </p>
25
+
26
+
27
+ # Model Introduction
28
+
29
+ FGN (**F**unctional **G**enerative **N**etworks) was proposed by Google DeepMind and is the probabilistic global weather forecasting model in the WeatherNext series (WeatherNext2). FGN uses the fair continuous ranked probability score (fair CRPS) of the marginal distribution as its training objective. It models aleatoric uncertainty by injecting a global noise vector into conditional normalization layers and epistemic uncertainty through an ensemble of independently trained models. In this way, it captures the joint spatial structure of ensemble forecasts while optimizing only pointwise marginal objectives, and surpasses GenCast and ECMWF ENS across 15-day medium-range forecasts.
30
+
31
+ Paper:Skilful joint probabilistic weather forecasting from marginals
32
+
33
+ https://arxiv.org/abs/2506.14285
34
+
35
+ # Model Description
36
+
37
+ FGN uses a GNN encoder-processor-decoder architecture consistent with the GenCast denoiser: a sparse GNN encoder maps latitude-longitude grid inputs to a latent space on a six-times-subdivided icosahedral grid, a graph-transformer processor advances the atmospheric state on that grid, and a GNN decoder maps the latent grid back to the output grid. Each forecast samples a 32-dimensional global noise vector, embeds it with a single matrix multiplication, and injects it into all conditional LayerNorm layers (equivalent to applying a learned functional perturbation to the network parameters) as the source of ensemble spread. Under a second-order Markov assumption (using the two previous frames), the model generates forecasts autoregressively at 6-hour intervals. This repository is a minimal reproduction based on the paper and is integrated with the OneScience data loading and training workflow.
38
+
39
+ # Use Cases
40
+
41
+ | Scenario | Description |
42
+ | :---: | :--- |
43
+ | Probabilistic/ensemble medium-range weather forecasting research | Learn pointwise marginal distributions with CRPS and generate joint ensemble forecasts. |
44
+ | Uncertainty modeling research | Reproducible parameter-space noise injection (conditional normalization) and deep ensemble mechanisms. |
45
+ | Graph + Transformer latent-space model research | Encoder-processor-decoder architecture and fair CRPS objective. |
46
+ | Local quick validation | Use synthetic data to check data loading, training, inference, and result scripts. |
47
+ | ModelScope/OneCode execution | Download the model package, install dependencies, and run the scripts directly. |
48
+ | Multi-card training | Launch multi-process training with `torchrun`. |
49
+
50
+
51
+ # Usage
52
+
53
+ ## 1. OneCode Usage
54
+
55
+ Use the OneCode online environment for intelligent one-click AI4S programming:
56
+
57
+ [Try intelligent one-click AI4S programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
58
+
59
+ ## 2. Manual Installation and Usage
60
+
61
+ **Hardware Requirements**
62
+
63
+ - GPU or DCU is recommended.
64
+ - CPU can be used for imports and small-configuration connectivity validation, but full training and inference are slower.
65
+ - DCU users must install DTK beforehand. DTK 25.04.2 or later, or the OneScience-recommended version matching the current cluster, is recommended.
66
+
67
+
68
+ ### Download the Model Package
69
+
70
+ ```bash
71
+ hf download OneScience-Group/WeatherNext2 --local-dir ./WeatherNext2
72
+ cd WeatherNext2
73
+ ```
74
+
75
+ ### Install the Runtime Environment
76
+
77
+ **DCU Environment**
78
+
79
+ ```bash
80
+ # Activate DTK and CONDA first
81
+ conda create -n onescience311 python=3.11 -y
82
+ conda activate onescience311
83
+ # uv installation is supported
84
+ pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
85
+ ```
86
+
87
+ **GPU Environment**
88
+ ```bash
89
+ # Activate CONDA first
90
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
91
+ conda activate onescience311
92
+ # uv installation is supported
93
+ pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
94
+ ```
95
+
96
+ ### Training Data
97
+
98
+ The OneScience community provides ERA5 data for training (the current repository contains complete data slices subject to data-file size limits). Download it with the command below and confirm that the data path in `conf/config.yaml` is correct:
99
+
100
+ ```bash
101
+ hf download --repo-type dataset OneScience-Group/ERA5 --local-dir ./data
102
+ ```
103
+
104
+ For a quick workflow validation, run the synthetic data script first:
105
+
106
+ ```bash
107
+ python scripts/fake_data.py
108
+ ```
109
+
110
+ > Note: `scripts/fake_data.py` generates `[T, C, H, W]` data from second-order Markov inputs, forecast steps, batch size, and `grid_shape`. The current small configuration uses 6 channels and a 32×32 grid.
111
+
112
+ ### Training
113
+
114
+ Single card:
115
+
116
+ ```bash
117
+ python scripts/train.py
118
+ ```
119
+
120
+ Multiple cards:
121
+
122
+ ```bash
123
+ torchrun --nproc_per_node=8 --nnodes=1 --rdzv_id=1000 --rdzv_backend=c10d --max_restarts=0 --master_addr="localhost" --master_port=29500 scripts/train.py
124
+ ```
125
+
126
+ Training outputs:
127
+
128
+ ```text
129
+ data/checkpoints/model_bak.pth
130
+ data/checkpoints/trloss.npy
131
+ data/checkpoints/valoss.npy
132
+ ```
133
+
134
+ ### Training Weights
135
+ The `weight/` folder is reserved for model weights. Pretrained weights are not provided by default; users may train the model using the paper configuration. The paper model (per-seed latent dimension 768, 24 processor layers, an ensemble of 4 model seeds, and approximately 490 TPU-days of total computation) has no publicly released weights.
136
+
137
+ ### Inference
138
+
139
+ Inference reads `data/checkpoints/model_bak.pth` by default, generates `num_members` ensemble members for each initialization time (independently sampling global noise for each), and uses the member mean as the deterministic forecast output:
140
+
141
+ ```bash
142
+ python scripts/inference.py
143
+ ```
144
+
145
+ Prediction results are written frame by frame to:
146
+
147
+ ```text
148
+ result/output/
149
+ ```
150
+
151
+ ### Evaluation and Visualization
152
+
153
+ ```bash
154
+ python scripts/result.py
155
+ ```
156
+
157
+ Outputs include:
158
+
159
+ - `result/rmse.npy`
160
+ - `result/acc.npy`
161
+ - `result/loss.png`
162
+ - Forecast comparison plots for the specified date and variables
163
+
164
+
165
+ # Official Source and Reproduction Notes
166
+
167
+ - The paper is a Google DeepMind preprint (© 2025 Google DeepMind. All rights reserved.), and no official implementation or weights are publicly available. This repository's `model/fgn.py` is a pure PyTorch minimal reproduction that preserves the paper's encoder-processor-decoder GNN structure, conditional LayerNorm global-noise injection, and fair CRPS objective (with N=2 ensemble samples during training).
168
+ - Differences from the paper (due to the OneScience gridded data pipeline and connectivity-validation scale): the paper uses a six-times-subdivided icosahedral latent grid (approximately 40k nodes) and a 0.25° (1440×721) output grid, with approximately 180M parameters per seed. This reproduction uses a fixed regular 8-neighbor latent grid (`mesh_shape`), ERA5 gridded h5 channels as input placeholders, and a default latent dimension of 64. The number of latent-grid nodes and edge features are determined by the `mesh_shape` and `channel_weights` configurations.
169
+ - The following details are not disclosed in the paper and are assumptions in this reproduction:the specific projection from noise to each layer's scale/shift in conditional LayerNorm (currently implemented with per-layer linear projections initialized to zero so the initial behavior is standard LayerNorm); per-channel weights for the multi-task loss (currently all 1 by default); and the evaluation convention of using the ensemble mean as the deterministic forecast for probabilistic output.
170
+ - Paper-level reproduction requires the four-stage training procedure in the paper (ERA5 1°12h → 1°6h → 0.25°6h → HRES-fc0 0.25° AR fine-tuning); `conf/config.yaml` uses a small configuration for connectivity validation by default.
171
+
172
+ # Official OneScience Information
173
+
174
+ | Platform | OneScience Main Repository | Skills Repository |
175
+ | --- | --- | --- |
176
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
177
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
178
+
179
+ # Citation and License
180
+
181
+ - This repository is an independent FGN (WeatherNext2) reproduction (the model code is an original minimal implementation), with the architecture design based on the paper by Alet et al. (2025).
182
+ - Please cite:Alet, F., Price, I., El-Kadi, A., Masters, D., Markou, S., Andersson, T. R., Stott, J., Lam, R., Willson, M., Sanchez-Gonzalez, A. and Battaglia, P. Skilful joint probabilistic weather forecasting from marginals. arXiv:2506.14285, 2025.
conf/config.yaml ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FGN (Functional Generative Networks) 训练配置示例
2
+ # 论文配置:第二阶 Markov(输入两个先验状态帧 x_{t-2},x_{t-1})预测下一状态 x_t;
3
+ # 网格 0.25°(1440×721),潜空间为 6 次细分球面 icosahedral 网格,潜维度 768、
4
+ # 处理器 24 层、6 attention heads(每模型种子约 180M 参数);训练 4 个模型种子,
5
+ # 评估时每个种子生成 14 个集合成员(共 56);目标为公平 CRPS(Eq.4),训练时 N=2。
6
+ # 时间步长 6h,15 天预报共 60 帧。
7
+ # 当前为连通性验证小配置:虚拟数据 32×32 网格、潜网格 8×8、latent_dim=64。
8
+ model:
9
+ start_epoch: 0
10
+ max_epoch: 100
11
+ lr: 1E-3 # 论文 peak lr=8e-4,cosine 退火(warmup 1000 步)
12
+ patience: 50
13
+ checkpoint_dir: "./data/checkpoints"
14
+
15
+ # FGN 结构参数(论文值见注释)
16
+ in_channels: 6 # 通道数(论文 6 个大气变量×13 层 + 6 个表面变量 = 84 通道)
17
+ out_channels: 6
18
+ input_steps: 2 # 第二阶 Markov,输入先验两帧(论文 input_steps=2)
19
+ output_steps: 2 # 自回归预报帧数(论文 6h 步长 15 天 = 60 帧)
20
+ grid_shape: [32, 32] # 观测网格尺寸(论文 0.25° 1440×721)
21
+ mesh_shape: [8, 8] # 潜网格尺寸(论文 6 次细分 icosahedral 网格约 40k 节点)
22
+ latent_dim: 64 # 潜空间维度(论文 768)
23
+ num_encoder_layers: 2 # 编码器 GNN 消息传递层数
24
+ num_decoder_layers: 2 # 解码器 GNN 消息传递层数
25
+ num_processor_blocks: 2 # 处理器 graph-transformer 块数(论文 24 层)
26
+ n_heads: 4 # 处理器 attention 头数(论文 6)
27
+ hidden_dim: 64 # 处理器 FFN / MLP 隐藏维度
28
+ noise_dim: 32 # 全局噪声向量维度(论文 32,经条件 LayerNorm 注入)
29
+ num_ensemble_models: 1 # 深度集成的模型种子数(论文 4)
30
+ num_members: 2 # 推理时每个模型种子生成的集合成员数(论文每种子 14,共 56)
31
+ channel_weights: [1, 1, 1, 1, 1, 1] # CRPS 逐通道权重(论文取 GraphCast/GenCast 权重)
32
+
33
+ # 整个数据读取流程
34
+ datapipe:
35
+ name: "ERA5"
36
+ task: "weather_forecasting"
37
+
38
+ dataset:
39
+ type: "hdf5"
40
+ data_dir: './data/'
41
+ train_time: [1951, 1952]
42
+ val_time: [1953]
43
+ test_time: [1954]
44
+ img_size: [32, 32]
45
+ verbose: true
46
+ cache: false
47
+
48
+ # 气象变量(论文 t/z/q/u/v/w 大气变量 + 2t/10u/10v/msl/sst/tp 表面变量;
49
+ # 此处使用 6 个表面变量占位)
50
+ channels: ['2m_temperature', '10m_u_component_of_wind', '10m_v_component_of_wind',
51
+ 'mean_sea_level_pressure', 'sea_surface_temperature', 'total_precipitation']
52
+
53
+ # DataLoader 配置
54
+ dataloader:
55
+ mask_dtype: "float32"
56
+ batch_size: 2
57
+ num_workers: 1
58
+ pin_memory: true
59
+ drop_last: true
60
+ shuffle: false
61
+ prefetch_factor: 2
62
+ persistent_workers: true
63
+
64
+ # 分布式配置
65
+ distributed:
66
+ enabled: true
67
+ sampler: "DistributedSampler"
68
+ rank: 0
69
+ world_size: 2
70
+ shuffle: true
71
+ seed: 42
72
+ drop_last: true
config.json ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "WeatherNext2",
3
+ "model_type": "fgn",
4
+ "architectures": [
5
+ "FGN"
6
+ ],
7
+ "framework": "PyTorch",
8
+ "domain": "climate-and-atmosphere",
9
+ "task": "probabilistic-weather-forecasting",
10
+ "implementation": {
11
+ "entry_point": "model/fgn.py",
12
+ "scope": "minimal PyTorch FGN reproduction with regular-grid graph operations, conditional LayerNorm noise injection, autoregressive ensembles, and fair CRPS"
13
+ },
14
+ "architecture": {
15
+ "family": "probabilistic GNN encoder, graph-Transformer processor, and GNN decoder",
16
+ "attention_mechanism": "multi-head self-attention over a regular latent mesh with fixed wrap-around 8-neighbor graph message passing",
17
+ "input_format": "BSTCHW",
18
+ "output_format": "BMTCHW",
19
+ "encoder": "per-cell MLP, adaptive pooling from the observation grid to the latent mesh, and GNN message passing",
20
+ "processor": "graph-Transformer blocks conditioned by a sampled global noise vector",
21
+ "decoder": "latent-mesh GNN, bilinear upsampling, and per-cell output MLP",
22
+ "activation": "GELU",
23
+ "normalization": "ConditionalLayerNorm",
24
+ "repository_default_config": {
25
+ "purpose": "small connectivity-validation configuration",
26
+ "in_channels": 6,
27
+ "out_channels": 6,
28
+ "input_steps": 2,
29
+ "output_steps": 2,
30
+ "grid_shape": [
31
+ 32,
32
+ 32
33
+ ],
34
+ "mesh_shape": [
35
+ 8,
36
+ 8
37
+ ],
38
+ "latent_dim": 64,
39
+ "num_encoder_layers": 2,
40
+ "num_decoder_layers": 2,
41
+ "num_processor_blocks": 2,
42
+ "n_heads": 4,
43
+ "hidden_dim": 64,
44
+ "noise_dim": 32,
45
+ "num_ensemble_models": 1,
46
+ "num_members": 2,
47
+ "channel_weights": [
48
+ 1,
49
+ 1,
50
+ 1,
51
+ 1,
52
+ 1,
53
+ 1
54
+ ]
55
+ },
56
+ "paper_configuration": {
57
+ "in_channels": 84,
58
+ "out_channels": 84,
59
+ "input_steps": 2,
60
+ "output_steps": 60,
61
+ "grid_shape": [
62
+ 721,
63
+ 1440
64
+ ],
65
+ "latent_mesh": "six-times-subdivided icosahedral grid with approximately 40,000 nodes",
66
+ "latent_dim": 768,
67
+ "num_processor_blocks": 24,
68
+ "n_heads": 6,
69
+ "noise_dim": 32,
70
+ "num_ensemble_models": 4,
71
+ "num_members_per_model": 14
72
+ }
73
+ },
74
+ "data": {
75
+ "dataset": "ERA5-format HDF5",
76
+ "variables": [
77
+ "2m_temperature",
78
+ "10m_u_component_of_wind",
79
+ "10m_v_component_of_wind",
80
+ "mean_sea_level_pressure",
81
+ "sea_surface_temperature",
82
+ "total_precipitation"
83
+ ],
84
+ "frame_interval_hours": 6,
85
+ "input_length": 2,
86
+ "output_length": 2,
87
+ "channels": 6,
88
+ "default_smoke_spatial_size": [
89
+ 32,
90
+ 32
91
+ ],
92
+ "paper_spatial_size": [
93
+ 721,
94
+ 1440
95
+ ],
96
+ "paper_output_length": 60,
97
+ "normalization": "per-channel means and standard deviations stored in HDF5"
98
+ },
99
+ "configuration_sources": [
100
+ "conf/config.yaml",
101
+ "model/fgn.py",
102
+ "scripts/train.py",
103
+ "scripts/inference.py",
104
+ "scripts/fake_data.py",
105
+ "README.md"
106
+ ]
107
+ }
configuration.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"framework":"Pytorch","task":"other"}
model/__pycache__/fgn.cpython-311.pyc ADDED
Binary file (26.8 kB). View file
 
model/fgn.py ADDED
@@ -0,0 +1,397 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # coding=utf-8
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+ # Minimal reproduction of FGN (Functional Generative Networks, Google DeepMind
6
+ # "WeatherNext2", arXiv 2506.14285, 2025) following the paper's architecture:
7
+ #
8
+ # * Grid encoder: a GNN that maps the two-prior-state gridded input onto a
9
+ # latent mesh (a coarse regular lat/lon grid).
10
+ # * Processor: a graph-transformer that operates on the latent mesh nodes
11
+ # with conditional layer-norm layers.
12
+ # * Grid decoder: a GNN that maps the latent mesh back onto the target grid.
13
+ #
14
+ # The probabilistic core of FGN is preserved:
15
+ # * a global noise vector n ~ N(0, I)^32 is sampled per ensemble member and
16
+ # per autoregressive step, embedded by a single matrix multiplication and
17
+ # passed into *all* conditional layer-norm layers (learned functional
18
+ # perturbations). This models aleatoric uncertainty.
19
+ # * epistemic uncertainty is modelled by an ensemble of independently
20
+ # trained models (deep ensembles); the mini constant model here uses one
21
+ # seed by default (see conf/config.yaml).
22
+ # * training objective is the fair CRPS estimator (Eq. 4) with N=2 samples.
23
+ #
24
+ # Differences from the paper (documented in README.md): the paper uses a
25
+ # spherical 6-times-refined icosahedral mesh and a full 768-latent / 24-layer
26
+ # / 6-head processor (about 180M params per seed); here the latent mesh is a
27
+ # fixed regular grid with a wrap-around neighbor graph, and the hyper
28
+ # parameters are reduced to CPU-friendly sizes for connectivity validation.
29
+ # sampled-per-step noise inside a single model plus the AR rollout are kept.
30
+ import math
31
+
32
+ import torch
33
+ import torch.nn as nn
34
+ import torch.nn.functional as F
35
+
36
+
37
+ def _latlon_grid(shape):
38
+ """Regular lat/lon coordinates for a (H, W) grid, North-to-South rows."""
39
+ H, W = shape
40
+ lat = torch.linspace(90.0, -90.0, H)
41
+ lon = torch.linspace(0.0, 360.0 - 360.0 / W, W)
42
+ return lat, lon
43
+
44
+
45
+ def _haversine(lat1, lon1, lat2, lon2):
46
+ """Haversine distance in metres given points in degrees."""
47
+ R = 6371000.0
48
+ p1 = torch.deg2rad(lat1)
49
+ p2 = torch.deg2rad(lat2)
50
+ dp = torch.deg2rad(lat2 - lat1)
51
+ dl = torch.deg2rad(lon2 - lon1)
52
+ a = torch.sin(dp / 2) ** 2 + torch.cos(p1) * torch.cos(p2) * torch.sin(dl / 2) ** 2
53
+ return 2 * R * torch.asin(torch.sqrt(a.clamp(0, 1)))
54
+
55
+
56
+ def _bearing(lat1, lon1, lat2, lon2):
57
+ """Initial forward bearing in radians from point 1 to point 2."""
58
+ p1 = torch.deg2rad(lat1)
59
+ p2 = torch.deg2rad(lat2)
60
+ dl = torch.deg2rad(lon2 - lon1)
61
+ y = torch.sin(dl) * torch.cos(p2)
62
+ x = torch.cos(p1) * torch.sin(p2) - torch.sin(p1) * torch.cos(p2) * torch.cos(dl)
63
+ return torch.atan2(y, x)
64
+
65
+
66
+ def build_mesh_graph(mesh_shape):
67
+ """
68
+ Build a fixed 8-neighbourhood graph over a regular latent mesh.
69
+ Longitude wraps around; edge features are (forward bearing [rad],
70
+ haversine distance [km]).
71
+ """
72
+ H, W = mesh_shape
73
+ lat, lon = _latlon_grid(mesh_shape)
74
+ lat = lat.view(-1, 1).expand(H, W)
75
+ lon = lon.view(1, -1).expand(H, W)
76
+
77
+ src_list, dst_list, feat_list = [], [], []
78
+ for i in range(H):
79
+ for j in range(W):
80
+ for di, dj in ((-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)):
81
+ ni, nj = i + di, (j + dj) % W
82
+ if not (0 <= ni < H):
83
+ continue
84
+ s = i * W + j
85
+ d = ni * W + nj
86
+ dist_km = _haversine(lat[i, j], lon[i, j], lat[ni, nj], lon[ni, nj]) / 1000.0
87
+ bear = _bearing(lat[i, j], lon[i, j], lat[ni, nj], lon[ni, nj])
88
+ src_list.append(s)
89
+ dst_list.append(d)
90
+ feat_list.append(torch.stack([bear / math.pi, dist_km / 1000.0]))
91
+ edge_index = torch.stack([torch.as_tensor(src_list), torch.as_tensor(dst_list)], dim=0)
92
+ edge_attr = torch.stack(feat_list)
93
+ return edge_index, edge_attr
94
+
95
+
96
+ def _mlp(in_dim, out_dim, hidden_dim, n_layers=2):
97
+ dims = [in_dim] + [hidden_dim] * (n_layers - 1) + [out_dim]
98
+ layers = []
99
+ for i in range(len(dims) - 1):
100
+ layers.append(nn.Linear(dims[i], dims[i + 1]))
101
+ if i < len(dims) - 2:
102
+ layers.append(nn.GELU())
103
+ return nn.Sequential(*layers)
104
+
105
+
106
+ class ConditionalLayerNorm(nn.Module):
107
+ """
108
+ Conditional layer-norm as used by FGN: a global noise vector is embedded
109
+ (single matrix multiplication) and injected, via learned scale/shift, into
110
+ every normalised module of the network. Sampling different noise vectors
111
+ n for each ensemble member / timestep is what generates the variance
112
+ across the ensemble (learned functional perturbations in weight space).
113
+ """
114
+
115
+ def __init__(self, dim, noise_dim=32):
116
+ super().__init__()
117
+ self.norm = nn.LayerNorm(dim, elementwise_affine=False)
118
+ self.gamma = nn.Linear(noise_dim, dim)
119
+ self.beta = nn.Linear(noise_dim, dim)
120
+ nn.init.zeros_(self.gamma.weight)
121
+ nn.init.zeros_(self.beta.weight)
122
+ nn.init.zeros_(self.gamma.bias)
123
+ nn.init.zeros_(self.beta.bias)
124
+
125
+ def forward(self, x, noise_emb):
126
+ # x: [B, N, D]; noise_emb: [B, noise_dim]
127
+ scale = self.gamma(noise_emb).unsqueeze(1) # [B, 1, D]
128
+ shift = self.beta(noise_emb).unsqueeze(1) # [B, 1, D]
129
+ return self.norm(x) * (1.0 + scale) + shift
130
+
131
+
132
+ class GNNLayer(nn.Module):
133
+ """
134
+ Message-passing layer with edge features (mean-aggregate, residual).
135
+ For the grid->mesh encoder the message function knly conditions on the
136
+ sender node features and the edge features, mirroring FGN's removal of
137
+ the receiver-mesh-node conditioning in the encoder message function.
138
+ """
139
+
140
+ def __init__(self, dim, edge_dim=2, hidden_dim=64, noise_dim=32, receiver_cond=True):
141
+ super().__init__()
142
+ msg_in = 2 * dim + edge_dim if receiver_cond else dim + edge_dim
143
+ self.edge_mlp = _mlp(msg_in, dim, hidden_dim)
144
+ self.node_mlp = _mlp(dim, dim, hidden_dim)
145
+ self.norm = ConditionalLayerNorm(dim, noise_dim)
146
+ self.receiver_cond = receiver_cond
147
+
148
+ def forward(self, x, edge_index, edge_attr, noise_emb):
149
+ B, N, D = x.shape
150
+ src, dst = edge_index
151
+ offsets = torch.arange(B, device=x.device) * N
152
+ src_b = (src.unsqueeze(0) + offsets.view(B, 1)).reshape(-1)
153
+ dst_b = (dst.unsqueeze(0) + offsets.view(B, 1)).reshape(-1)
154
+ edge_attr_b = edge_attr.unsqueeze(0).expand(B, -1, -1).reshape(-1, edge_attr.size(1))
155
+ xb = x.reshape(B * N, D)
156
+ if self.receiver_cond:
157
+ msg = self.edge_mlp(torch.cat([xb[src_b], xb[dst_b], edge_attr_b], dim=1))
158
+ else:
159
+ msg = self.edge_mlp(torch.cat([xb[src_b], edge_attr_b], dim=1))
160
+ agg = torch.zeros_like(xb)
161
+ agg.index_add_(0, dst_b, msg)
162
+ cnt = torch.bincount(dst_b, minlength=B * N).clamp(min=1).unsqueeze(1)
163
+ agg = agg / cnt
164
+ agg = agg.reshape(B, N, D)
165
+ return self.norm(x + self.node_mlp(agg), noise_emb)
166
+
167
+
168
+ class GridEncoder(nn.Module):
169
+ """
170
+ Maps the gridded input states onto the latent mesh with a per-cell input
171
+ MLP, an adaptive pooling to the mesh resolution, and graph message passing
172
+ on the mesh graph (encoder message function without receiver conditioning).
173
+ """
174
+
175
+ def __init__(self, in_channels, latent_dim, mesh_shape, num_layers=2, hidden_dim=64,
176
+ edge_dim=2, noise_dim=32):
177
+ super().__init__()
178
+ self.input_mlp = _mlp(in_channels, latent_dim, hidden_dim)
179
+ self.gnn = nn.ModuleList([
180
+ GNNLayer(latent_dim, edge_dim=edge_dim, hidden_dim=hidden_dim,
181
+ noise_dim=noise_dim, receiver_cond=False)
182
+ for _ in range(num_layers)
183
+ ])
184
+ self.mesh_shape = mesh_shape
185
+ self.edge_index, self.edge_attr = build_mesh_graph(mesh_shape)
186
+
187
+ def forward(self, x, noise_emb):
188
+ B, C, H, W = x.shape
189
+ feat = x.permute(0, 2, 3, 1).reshape(-1, C)
190
+ feat = self.input_mlp(feat).reshape(B, H, W, -1).permute(0, 3, 1, 2)
191
+ mesh = F.adaptive_avg_pool2d(feat, self.mesh_shape) # [B, D, Hm, Wm]
192
+ mesh = mesh.permute(0, 2, 3, 1).reshape(B, self.mesh_shape[0] * self.mesh_shape[1], -1)
193
+ edge_index, edge_attr = self.edge_index.to(x.device), self.edge_attr.to(x.device)
194
+ for layer in self.gnn:
195
+ mesh = layer(mesh, edge_index, edge_attr, noise_emb)
196
+ return mesh
197
+
198
+
199
+ class GraphTransformerBlock(nn.Module):
200
+ """
201
+ One graph-transformer block of the processor: multi-head self-attention
202
+ over mesh tokens plus edge message passing, each with residual connection
203
+ and conditioned layer-norm.
204
+ """
205
+
206
+ def __init__(self, latent_dim, n_heads, hidden_dim, edge_dim=2, noise_dim=32):
207
+ super().__init__()
208
+ self.attn = nn.MultiheadAttention(
209
+ latent_dim, n_heads, batch_first=True, dropout=0.0
210
+ )
211
+ self.attn_norm = ConditionalLayerNorm(latent_dim, noise_dim)
212
+ self.gnn = GNNLayer(latent_dim, edge_dim=edge_dim, hidden_dim=hidden_dim,
213
+ noise_dim=noise_dim, receiver_cond=True)
214
+ self.gnn_norm = ConditionalLayerNorm(latent_dim, noise_dim)
215
+ self.ff = nn.Sequential(
216
+ nn.Linear(latent_dim, hidden_dim),
217
+ nn.GELU(),
218
+ nn.Linear(hidden_dim, latent_dim),
219
+ )
220
+ self.ff_norm = ConditionalLayerNorm(latent_dim, noise_dim)
221
+
222
+ def forward(self, x, edge_index, edge_attr, noise_emb):
223
+ # self-attention over mesh tokens
224
+ h = self.attn_norm(x, noise_emb)
225
+ h, _ = self.attn(h, h, h)
226
+ x = x + self.gnn_norm(h, noise_emb)
227
+ # edge message passing on the mesh graph
228
+ x = self.gnn(x, edge_index, edge_attr, noise_emb)
229
+ # feed-forward
230
+ h = self.ff_norm(x, noise_emb)
231
+ x = x + self.ff(h)
232
+ return x
233
+
234
+
235
+ class GridDecoder(nn.Module):
236
+ """
237
+ Maps the latent mesh back onto the target grid (bilinear upsample) and
238
+ predicts per-channel fields with an output MLP.
239
+ """
240
+
241
+ def __init__(self, latent_dim, out_channels, grid_shape, mesh_shape, num_layers=2,
242
+ hidden_dim=64, edge_dim=2, noise_dim=32):
243
+ super().__init__()
244
+ self.gnn = nn.ModuleList([
245
+ GNNLayer(latent_dim, edge_dim=edge_dim, hidden_dim=hidden_dim,
246
+ noise_dim=noise_dim, receiver_cond=True)
247
+ for _ in range(num_layers)
248
+ ])
249
+ self.grid_shape = grid_shape
250
+ self.mesh_shape = mesh_shape
251
+ self.edge_index, self.edge_attr = build_mesh_graph(mesh_shape)
252
+ self.output_mlp = _mlp(latent_dim, out_channels, hidden_dim)
253
+
254
+ def forward(self, mesh, noise_emb):
255
+ B, N, D = mesh.shape
256
+ edge_index = self.edge_index.to(mesh.device)
257
+ edge_attr = self.edge_attr.to(mesh.device)
258
+ for layer in self.gnn:
259
+ mesh = layer(mesh, edge_index, edge_attr, noise_emb)
260
+ H, W = self.grid_shape
261
+ Hm, Wm = self.mesh_shape
262
+ mesh = mesh.transpose(1, 2).reshape(B, D, Hm, Wm)
263
+ grid = F.interpolate(mesh, size=self.grid_shape, mode="bilinear", align_corners=False)
264
+ grid = grid.permute(0, 2, 3, 1).reshape(B, H * W, D)
265
+ return self.output_mlp(grid).reshape(B, H, W, -1).permute(0, 3, 1, 2)
266
+
267
+
268
+ class FGN(nn.Module):
269
+ """
270
+ Config-driven FGN (Functional Generative Networks) wrapper.
271
+
272
+ Args:
273
+ in_channels: Number of state channels per frame (concatenated prior
274
+ states fed to the grid encoder).
275
+ out_channels: Number of forecast channels per frame.
276
+ input_steps: Number of input (prior weather state) frames. FGN uses a
277
+ second-order Markov assumption, input_steps=2.
278
+ output_steps: Number of autoregressive forecast frames.
279
+ grid_shape: Spatial shape of the (gridded) input state.
280
+ mesh_shape: Latent mesh resolution (each dimension).
281
+ latent_dim: Feature dimension of latent mesh tokens.
282
+ num_encoder_layers / num_decoder_layers: GNN message-passing layers.
283
+ num_processor_blocks: Graph-transformer blocks in the processor.
284
+ n_heads: Attention heads of the processor.
285
+ hidden_dim: Feed-forward / MLP hidden size.
286
+ noise_dim: Dimension of the global noise vector injected through the
287
+ conditional layer-norm layers (paper: 32).
288
+ channel_weights: Per-channel weights for the fair-CRPS objective
289
+ (taken from the GenCast/GraphCast loss weighting by default).
290
+ """
291
+
292
+ def __init__(
293
+ self,
294
+ in_channels=6,
295
+ out_channels=6,
296
+ input_steps=2,
297
+ output_steps=2,
298
+ grid_shape=(32, 32),
299
+ mesh_shape=(8, 8),
300
+ latent_dim=64,
301
+ num_encoder_layers=2,
302
+ num_decoder_layers=2,
303
+ num_processor_blocks=1,
304
+ n_heads=4,
305
+ hidden_dim=64,
306
+ noise_dim=32,
307
+ channel_weights=None,
308
+ ):
309
+ super().__init__()
310
+ self.in_channels = int(in_channels)
311
+ self.out_channels = int(out_channels)
312
+ self.input_steps = int(input_steps)
313
+ self.output_steps = int(output_steps)
314
+ self.grid_shape = (int(grid_shape[0]), int(grid_shape[1]))
315
+ self.mesh_shape = (int(mesh_shape[0]), int(mesh_shape[1]))
316
+ self.noise_dim = int(noise_dim)
317
+
318
+ self.noise_embed = nn.Linear(self.noise_dim, self.noise_dim)
319
+
320
+ self.encoder = GridEncoder(
321
+ self.in_channels * self.input_steps, int(latent_dim), self.mesh_shape,
322
+ num_layers=int(num_encoder_layers), hidden_dim=int(hidden_dim),
323
+ noise_dim=self.noise_dim,
324
+ )
325
+ self.processor = nn.ModuleList([
326
+ GraphTransformerBlock(
327
+ int(latent_dim), int(n_heads), int(hidden_dim), noise_dim=self.noise_dim
328
+ )
329
+ for _ in range(int(num_processor_blocks))
330
+ ])
331
+ self.decoder = GridDecoder(
332
+ int(latent_dim), self.out_channels, self.grid_shape, self.mesh_shape,
333
+ num_layers=int(num_decoder_layers), hidden_dim=int(hidden_dim),
334
+ noise_dim=self.noise_dim,
335
+ )
336
+
337
+ if channel_weights is None:
338
+ channel_weights = torch.ones(self.out_channels)
339
+ self.register_buffer("channel_weights", torch.as_tensor(channel_weights, dtype=torch.float32))
340
+
341
+ def _rollout(self, x, noise):
342
+ """
343
+ Autoregressive rollout: at each output step sample the next state
344
+ conditional on the last `input_steps` prior states x_{t-2}, x_{t-1}
345
+ (second-order Markov), sampling a fresh global noise vector per step.
346
+ """
347
+ B, S, C, H, W = x.shape
348
+ state = list(torch.unbind(x, dim=1))
349
+ outs = []
350
+ for t in range(self.output_steps):
351
+ embrace = self.noise_embed(noise[t]) # [B, noise_dim]
352
+ inp = torch.cat(state, dim=1) # [B, S*C, H, W]
353
+ latent = self.encoder(inp, embrace)
354
+ edge_index, edge_attr = self.encoder.edge_index, self.encoder.edge_attr
355
+ edge_index = edge_index.to(x.device)
356
+ edge_attr = edge_attr.to(x.device)
357
+ for block in self.processor:
358
+ latent = block(latent, edge_index, edge_attr, embrace)
359
+ frame = self.decoder(latent, embrace) # [B, C, H, W]
360
+ outs.append(frame)
361
+ state.append(frame)
362
+ state = state[-self.input_steps:]
363
+ return torch.stack(outs, dim=1) # [B, output_steps, C, H, W]
364
+
365
+ def forward(self, x, num_members=1):
366
+ """
367
+ Args:
368
+ x: Input state frames, shape [batch, input_steps, C, H, W].
369
+ num_members: Number of independent ensemble members to generate
370
+ (each member samples independent global noise per step).
371
+ Returns:
372
+ Forecast frames, shape [batch, num_members, output_steps, C, H, W].
373
+ """
374
+ device = x.device
375
+ members = []
376
+ for _ in range(int(num_members)):
377
+ noise = torch.randn(self.output_steps, x.size(0), self.noise_dim, device=device)
378
+ members.append(self._rollout(x, noise))
379
+ return torch.stack(members, dim=1)
380
+
381
+ def crps_loss(self, pred, target):
382
+ """
383
+ Fair CRPS objective (Eq. 4 of the paper) with an N-member ensemble,
384
+ averaged over all locations, variables, levels and output steps:
385
+
386
+ fCRPS(F_1:N, y) = 1/N sum_i |F_i - y|
387
+ - 1/(2 N (N-1)) sum_{i != i'} |F_i - F_i'|
388
+
389
+ With N=2 this reduces to 0.5(|F1-y|+|F2-y|) - 0.5|F1-F2|. The loss is
390
+ weighted per channel to match the GenCast/GraphCast loss weighting.
391
+ """
392
+ N = pred.size(1)
393
+ mae = torch.abs(pred - target.unsqueeze(1)).mean(dim=1) # (1/N) sum_i |F_i - y|
394
+ per = torch.abs(pred.unsqueeze(2) - pred.unsqueeze(1)).sum(dim=(1, 2)) / (N * (N - 1))
395
+ crps = mae - 0.5 * per # [B, T, C, H, W]
396
+ w = self.channel_weights.view(1, 1, self.out_channels, 1, 1)
397
+ return (crps * w).mean()
scripts/fake_data.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import h5py
3
+ import numpy as np
4
+ from onescience.utils.YParams import YParams
5
+
6
+
7
+ # FGN 使用两个先验状态,自回归预测配置指定的后续状态数。
8
+ def get_dims(cfg_model, cfg_data):
9
+ H, W = map(int, cfg_model.grid_shape)
10
+ if tuple(map(int, cfg_data.dataset.img_size)) != (H, W):
11
+ raise ValueError("model.grid_shape and datapipe.dataset.img_size must match")
12
+ input_steps = int(cfg_model.input_steps)
13
+ output_steps = int(cfg_model.output_steps)
14
+ samples = int(cfg_data.dataloader.batch_size)
15
+ T = input_steps + output_steps + samples - 1
16
+ return {
17
+ "T": T, "H": H, "W": W, "time_step": 6,
18
+ "input_steps": input_steps, "output_steps": output_steps,
19
+ }
20
+
21
+
22
+ def generate_fake_h5(data_dir, var_names, years, dims):
23
+ """
24
+ 为每个年份生成一个空 h5 文件。
25
+ 利用 HDF5 chunked 数据集未写入 chunk 即返回 fill_value=0 的特性,
26
+ 文件实际只含元数据,极小,但 shape 与真实数据完全一致。
27
+ 均值/标准差也作为数据集内嵌进每年的 h5,与 era5.py 新版读取方式对应。
28
+
29
+ 注意:ERA5Datapipe 要求 samples_per_year = T - input_steps - output_steps + 1 >= 1,
30
+ T 由 input_steps、output_steps 与 batch_size 自动计算。
31
+ """
32
+ os.makedirs(os.path.join(data_dir, "data"), exist_ok=True)
33
+ T, C = dims["T"], len(var_names)
34
+ H, W = dims["H"], dims["W"]
35
+
36
+ means = np.zeros((1, C, 1, 1), dtype=np.float32)
37
+ stds = np.ones((1, C, 1, 1), dtype=np.float32)
38
+
39
+ for year in years:
40
+ path = os.path.join(data_dir, "data", f"{year}.h5")
41
+ with h5py.File(path, "w") as f:
42
+ ds = f.create_dataset(
43
+ "fields",
44
+ shape=(T, C, H, W),
45
+ dtype="float32",
46
+ chunks=(1, C, H, W),
47
+ fillvalue=0.0,
48
+ )
49
+ ds.attrs["variables"] = var_names
50
+ ds.attrs["time_step"] = dims["time_step"]
51
+ f.create_dataset("global_means", data=means)
52
+ f.create_dataset("global_stds", data=stds)
53
+
54
+ size_kb = os.path.getsize(path) / 1024
55
+ print(f" {year}.h5 shape=({T},{C},{H},{W}) "
56
+ f"logical={T*C*H*W*4/1024**3:.1f}GB actual={size_kb:.1f}KB")
57
+
58
+
59
+ if __name__ == "__main__":
60
+ cfg_model = YParams("conf/config.yaml", "model")
61
+ cfg_datapipe = YParams("conf/config.yaml", "datapipe")
62
+
63
+ if cfg_datapipe.dataset.data_dir.startswith("/public/") or cfg_datapipe.dataset.data_dir.startswith("/work2/"):
64
+ print("请检查 config,确保各 *_dir 指向本地测试路径而非生产路径。")
65
+ exit()
66
+
67
+ years = cfg_datapipe.dataset.train_time + cfg_datapipe.dataset.val_time + cfg_datapipe.dataset.test_time
68
+ atm_vars = cfg_datapipe.dataset.channels
69
+ if len(atm_vars) != int(cfg_model.in_channels) or len(atm_vars) != int(cfg_model.out_channels):
70
+ raise ValueError("channel count must match model input/output channels")
71
+
72
+ generate_fake_h5(cfg_datapipe.dataset.data_dir, atm_vars, years, get_dims(cfg_model, cfg_datapipe))
73
+
74
+ print("\n✅ Fake datasets generated.")
scripts/inference.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ # 获取项目根目录(inference.py上级的上级)
5
+ root_path = Path(__file__).parent.parent
6
+ sys.path.append(str(root_path))
7
+ import torch
8
+ import os
9
+ import glob
10
+ import numpy as np
11
+ import h5py
12
+ from tqdm import tqdm
13
+ from model.fgn import FGN
14
+ from onescience.utils.YParams import YParams
15
+ from onescience.datapipes.climate import ERA5Datapipe
16
+
17
+
18
+ def get_stats(data_dir, channels):
19
+ """从新版 h5 中读取变量列表与归一化参数(均值/标准差)"""
20
+ h5_files = sorted(glob.glob(os.path.join(data_dir, "data", "*.h5")))
21
+ with h5py.File(h5_files[0], "r") as f:
22
+ ds = f["fields"]
23
+ all_variables = [v.decode() if isinstance(v, bytes) else v for v in ds.attrs["variables"]]
24
+ mu = f["global_means"][:] # [1, C, 1, 1]
25
+ std = f["global_stds"][:]
26
+
27
+ channel_indices = [all_variables.index(v) for v in channels]
28
+ means = mu[:, channel_indices, :, :]
29
+ stds = std[:, channel_indices, :, :]
30
+ return means, stds
31
+
32
+
33
+ if __name__ == "__main__":
34
+ current_path = os.getcwd()
35
+ sys.path.append(current_path)
36
+
37
+ ## Model config init
38
+ config_file_path = os.path.join(current_path, "conf/config.yaml")
39
+ cfg = YParams(config_file_path, "model")
40
+
41
+ ## DataLoader init
42
+ cfg_data = YParams(config_file_path, "datapipe")
43
+ means, stds = get_stats(cfg_data.dataset.data_dir, cfg_data.dataset.channels)
44
+
45
+ cfg['N_in_channels'] = len(cfg_data.dataset.channels)
46
+ cfg['N_out_channels'] = len(cfg_data.dataset.channels)
47
+
48
+ datapipe = ERA5Datapipe(
49
+ dataset_dir=cfg_data.dataset.data_dir,
50
+ used_variables=cfg_data.dataset.channels,
51
+ used_years=cfg_data.dataset.test_time,
52
+ distributed=False,
53
+ input_steps=cfg.input_steps,
54
+ output_steps=cfg.output_steps,
55
+ batch_size=1,
56
+ num_workers=4,
57
+ )
58
+ test_dataloader, _ = datapipe.get_dataloader("test")
59
+
60
+ device = "cuda:0" if torch.cuda.is_available() else "cpu"
61
+ ckpt = torch.load(f"{cfg.checkpoint_dir}/model_bak.pth", map_location=device, weights_only=False)
62
+ model = FGN(
63
+ in_channels=cfg['N_in_channels'],
64
+ out_channels=cfg['N_out_channels'],
65
+ input_steps=cfg.input_steps,
66
+ output_steps=cfg.output_steps,
67
+ grid_shape=cfg.grid_shape,
68
+ mesh_shape=cfg.mesh_shape,
69
+ latent_dim=cfg.latent_dim,
70
+ num_encoder_layers=cfg.num_encoder_layers,
71
+ num_decoder_layers=cfg.num_decoder_layers,
72
+ num_processor_blocks=cfg.num_processor_blocks,
73
+ n_heads=cfg.n_heads,
74
+ hidden_dim=cfg.hidden_dim,
75
+ noise_dim=cfg.noise_dim,
76
+ channel_weights=cfg.channel_weights,
77
+ ).to(device)
78
+ model.load_state_dict(ckpt["model_state_dict"])
79
+
80
+ model.eval()
81
+ os.makedirs('result/output/', exist_ok=True)
82
+ print(f"📂 infer results will be generated to './result/output/'")
83
+ print(f"📂 generating {cfg.num_members} ensemble members per init, saving their mean per frame")
84
+ with torch.no_grad():
85
+ for data in tqdm(test_dataloader, desc="Inferring testset", unit="batch"):
86
+ invar = data[0].to(device, dtype=torch.float32) # [1, input_steps, C, H, W]
87
+ members = model(invar, num_members=cfg.num_members) # [1, M, output_steps, C, H, W]
88
+ pred = members[0].mean(dim=0).cpu().numpy() # 集合成员均值 [T, C, H, W]
89
+ for t in range(pred.shape[0]):
90
+ fname = data[4][cfg.input_steps + t][0] # 该预测帧对应的时刻
91
+ pred_var = pred[t] # [C, H, W]
92
+ pred_var = pred_var * stds + means
93
+ np.save(f"result/output/{fname}.npy", pred_var)
scripts/result.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import matplotlib.pyplot as plt
3
+ import os
4
+ import sys
5
+ import glob
6
+ import h5py
7
+ from datetime import datetime
8
+ from tqdm import tqdm
9
+ from onescience.utils.fcn.YParams import YParams
10
+ from matplotlib import rcParams
11
+
12
+ # rcParams['font.family'] = 'serif'
13
+ # rcParams['font.serif'] = ['DejaVu Serif']
14
+ rcParams['mathtext.fontset'] = 'stix'
15
+ rcParams['axes.linewidth'] = 0.9
16
+ rcParams['xtick.major.width'] = 0.9
17
+ rcParams['ytick.major.width'] = 0.9
18
+
19
+
20
+ def get_metadata(data_dir, channels):
21
+ """从新版 h5 attrs 中读取变量列表和 time_step"""
22
+ h5_files = sorted(glob.glob(os.path.join(data_dir, "data", "*.h5")))
23
+ with h5py.File(h5_files[0], "r") as f:
24
+ ds = f["fields"]
25
+ all_variables = [v.decode() if isinstance(v, bytes) else v for v in ds.attrs["variables"]]
26
+ time_step = int(ds.attrs["time_step"])
27
+
28
+ channel_indices = [all_variables.index(v) for v in channels]
29
+
30
+ total_files = [f for f in os.listdir('./result/output/') if f.endswith('.npy')]
31
+ total_files.sort()
32
+ return total_files, channel_indices, time_step
33
+
34
+
35
+ def filename_to_index(filename, time_step):
36
+ """将 YYYYMMDDHH 格式的文件名转换为年度 h5 文件中的时间步索引"""
37
+ dt = datetime.strptime(filename, "%Y%m%d%H")
38
+ year_start = datetime(dt.year, 1, 1)
39
+ hours = (dt - year_start).total_seconds() / 3600
40
+ return int(hours / time_step)
41
+
42
+
43
+ def get_result(total_files, channel_indices, time_step, data_dir, clim_mean):
44
+ channel_rmse = np.zeros(len(channel_indices))
45
+ channel_acc = np.zeros(len(channel_indices))
46
+ clim_mean = clim_mean[0, :, :, :]
47
+ if not os.path.exists('./result/rmse.npy') or not os.path.exists('result/acc.npy'):
48
+ numerator = np.zeros(len(channel_indices))
49
+ pred_sq_sum = np.zeros(len(channel_indices))
50
+ label_sq_sum = np.zeros(len(channel_indices))
51
+ for file in tqdm(total_files, unit="files"):
52
+ fname = file[:-4] # 去掉 .npy
53
+ year = fname[:4]
54
+ t_idx = filename_to_index(fname, time_step)
55
+ with h5py.File(os.path.join(data_dir, 'data', f'{year}.h5'), "r") as f:
56
+ label = f["fields"][t_idx] # [C, H, W]
57
+ label = label[channel_indices]
58
+ pred = np.load(f'result/output/{file}').squeeze()
59
+ if pred.ndim == 2:
60
+ pred = pred[np.newaxis] # 单通道时 squeeze 会压缩掉通道维,恢复为 [C, H, W]
61
+
62
+ label_anom = label - clim_mean
63
+ pred_anom = pred - clim_mean
64
+ # 累加
65
+ numerator += np.sum(pred_anom * label_anom, axis=(1, 2))
66
+ pred_sq_sum += np.sum(pred_anom ** 2, axis=(1, 2))
67
+ label_sq_sum += np.sum(label_anom ** 2, axis=(1, 2))
68
+
69
+ channel_rmse += np.sqrt(np.mean((label - pred) ** 2, axis=(1, 2)))
70
+ channel_rmse /= len(total_files)
71
+ channel_acc = numerator / (np.sqrt(pred_sq_sum * label_sq_sum) + 1e-8)
72
+ np.save('./result/acc.npy', channel_acc)
73
+ np.save('./result/rmse.npy', channel_rmse)
74
+
75
+
76
+ def show_result():
77
+ channel_rmse = np.load('./result/rmse.npy')
78
+ channel_acc = np.load('./result/acc.npy')
79
+
80
+ channels = [cfg_data.dataset.channels[i] for i in range(len(channel_indices))]
81
+ w = 24 # 最长 channel 名宽度
82
+
83
+ # 表头
84
+ print(f"┌{'─' * (w + 2)}┬{'─' * 14}┬{'─' * 14}┐")
85
+ print(f"│ {'Channel':<{w}} │ {'RMSE':>12} │ {'ACC':>12} │")
86
+ print(f"├{'─' * (w + 2)}┼{'─' * 14}┼{'─' * 14}┤")
87
+ # 数据行
88
+ for i, ch in enumerate(channels):
89
+ print(f"│ {ch:<{w}} │ {channel_rmse[i]:>12.4f} | {channel_acc[i]:>12.4f} |")
90
+ print(f"├{'─' * (w + 2)}┼{'─' * 14}┼{'─' * 14}┤")
91
+ print(f"│ {'Average':<{w}} │ {np.mean(channel_rmse):>12.4f} │ {np.mean(channel_acc):>12.4f} │")
92
+ print(f"└{'─' * (w + 2)}┴{'─' * 14}┴{'─' * 14}┘")
93
+
94
+
95
+ def plot(label, pred, var, filename):
96
+ fig, axes = plt.subplots(1, 3, figsize=(15, 4))
97
+
98
+ xtick_labels = ['180°W', '90°W', '0°', '90°E', '180°E']
99
+ ytick_labels = ['90°S', '45°S', '0°', '45°N', '90°N']
100
+ xticks = np.linspace(0, label.shape[-1] - 1, 5)
101
+ yticks = np.linspace(0, label.shape[-2] - 1, 5)
102
+
103
+ vmin = min(label.min(), pred.min())
104
+ vmax = max(label.max(), pred.max())
105
+
106
+ diff = label - pred
107
+ rmse = np.sqrt(np.mean(diff ** 2))
108
+ diff_abs_max = np.abs(diff).max()
109
+
110
+ plot_configs = [
111
+ {'data': label, 'title': 'Truth', 'cmap': 'viridis', 'vmin': vmin, 'vmax': vmax},
112
+ {'data': pred, 'title': 'Prediction', 'cmap': 'viridis', 'vmin': vmin, 'vmax': vmax},
113
+ {'data': diff, 'title': f'Difference (RMSE={rmse:.2f})', 'cmap': 'RdBu_r', 'vmin': -diff_abs_max, 'vmax': diff_abs_max},
114
+ ]
115
+
116
+ for ax, cfg2 in zip(axes, plot_configs):
117
+ im = ax.imshow(cfg2['data'], cmap=cfg2['cmap'], vmin=cfg2['vmin'], vmax=cfg2['vmax'])
118
+ ax.set_title(cfg2['title'], fontsize=12, pad=4)
119
+ ax.set_xlabel('Longitude')
120
+ ax.set_ylabel('Latitude')
121
+ ax.set_xticks(xticks)
122
+ ax.set_xticklabels(xtick_labels)
123
+ ax.set_yticks(yticks)
124
+ ax.set_yticklabels(ytick_labels)
125
+ plt.colorbar(im, ax=ax, orientation='horizontal')
126
+
127
+ fig.suptitle(var, fontsize=14, fontweight='bold', y=0.98)
128
+ plt.savefig(filename, dpi=300, bbox_inches='tight')
129
+ plt.close()
130
+
131
+
132
+ def plot_loss(train_loss, valid_loss):
133
+ mask = ~(np.isnan(train_loss) | np.isnan(valid_loss))
134
+ train_loss = train_loss[mask]
135
+ valid_loss = valid_loss[mask]
136
+
137
+ fig, ax = plt.subplots(figsize=(5, 3.5))
138
+ colors = {'train': '#2563EB', 'valid': '#EA580C'}
139
+ epochs = np.arange(1, len(train_loss) + 1)
140
+
141
+ ax.plot(epochs, train_loss, color=colors['train'], linewidth=1.5, label='Train')
142
+ ax.plot(epochs, valid_loss, color=colors['valid'], linewidth=1.5, label='Valid', linestyle='--')
143
+ min_idx = np.argmin(valid_loss)
144
+ ax.scatter(epochs[min_idx], valid_loss[min_idx],
145
+ color=colors['valid'], s=40, zorder=5, edgecolors='white')
146
+ ax.annotate(f'Best: {valid_loss[min_idx]:.3f}',
147
+ xy=(epochs[min_idx], valid_loss[min_idx]),
148
+ xytext=(10, 10), textcoords='offset points', fontsize=8, color=colors['valid'],
149
+ arrowprops=dict(arrowstyle='-', color=colors['valid'], lw=0.5))
150
+
151
+ ax.set(xlabel='Epoch', ylabel='Loss', xlim=(0, len(train_loss) + 1))
152
+ ax.legend(frameon=False, loc='upper right')
153
+ ax.grid(True, linestyle='--', alpha=0.3)
154
+ ax.spines[['top', 'right']].set_visible(False)
155
+
156
+ plt.tight_layout()
157
+ plt.savefig('./result/loss.png', dpi=300, bbox_inches='tight')
158
+ plt.close()
159
+
160
+
161
+ if __name__ == "__main__":
162
+ current_path = os.getcwd()
163
+ sys.path.append(current_path)
164
+ config_file_path = os.path.join(current_path, 'conf/config.yaml')
165
+ cfg = YParams(config_file_path, 'model')
166
+ cfg_data = YParams(config_file_path, "datapipe")
167
+
168
+ train_loss = np.load('./data/checkpoints/trloss.npy')
169
+ valid_loss = np.load('./data/checkpoints/valoss.npy')
170
+ plot_loss(train_loss, valid_loss)
171
+
172
+ data_dir = cfg_data.dataset.data_dir
173
+ total_files, channel_indices, time_step = get_metadata(data_dir, cfg_data.dataset.channels)
174
+
175
+ # Load data & Compute RMSE/ACC per channel
176
+ h5_files = sorted(glob.glob(os.path.join(data_dir, "data", "*.h5")))
177
+ with h5py.File(h5_files[0], "r") as f:
178
+ mu = f["global_means"][:]
179
+ clim_mean = mu[:, channel_indices, :, :]
180
+ get_result(total_files, channel_indices, time_step, data_dir, clim_mean)
181
+ show_result()
182
+
183
+ ##### 默认绘制第一个预测输出的时刻与全部通道,用户可自行指定 #####
184
+ eg_files = [total_files[0][:-4]]
185
+ # 最多绘制 3 个通道的对比图
186
+ channel_index = list(range(min(3, len(cfg_data.dataset.channels))))
187
+
188
+ selected_var = [cfg_data.dataset.channels[int(i)] for i in channel_index]
189
+ print(f"seleted date: {eg_files}")
190
+ print(f"selected channels: {selected_var}")
191
+ for file in eg_files:
192
+ year = file[:4]
193
+ t_idx = filename_to_index(file, time_step)
194
+ with h5py.File(os.path.join(data_dir, 'data', f'{year}.h5'), "r") as f:
195
+ label = f["fields"][t_idx] # [C, H, W]
196
+ label = label[channel_indices]
197
+ pred = np.load(f'result/output/{file}.npy').squeeze()
198
+ if pred.ndim == 2:
199
+ pred = pred[np.newaxis] # 单通道时 squeeze 会压缩掉通道维,恢复为 [C, H, W]
200
+ for i in range(len(selected_var)):
201
+ filename = f'./result/{file}_{selected_var[i]}.png'
202
+ plot(label[channel_index[i]], pred[channel_index[i]], selected_var[i], filename)
203
+ print(f'✅plot {filename}')
scripts/train.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ # 获取项目根目录(train.py上级的上级)
5
+ root_path = Path(__file__).parent.parent
6
+ sys.path.append(str(root_path))
7
+ import torch
8
+ import os
9
+ import numpy as np
10
+ import torch.distributed as dist
11
+ import logging
12
+ import time
13
+
14
+ from model.fgn import FGN
15
+ from onescience.datapipes.climate import ERA5Datapipe
16
+ from onescience.utils.YParams import YParams
17
+
18
+ try:
19
+ from apex import optimizers
20
+ _FUSED_ADAM = True
21
+ except Exception:
22
+ _FUSED_ADAM = False
23
+
24
+
25
+ def main():
26
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
27
+ logger = logging.getLogger()
28
+
29
+ ## Model config init
30
+ config_file_path = os.path.join(current_path, "conf/config.yaml")
31
+ cfg = YParams(config_file_path, "model")
32
+
33
+ ## Distributed config init
34
+ cfg.world_size = 1
35
+ if "WORLD_SIZE" in os.environ:
36
+ cfg.world_size = int(os.environ["WORLD_SIZE"])
37
+ world_rank = 0
38
+ local_rank = 0
39
+ if cfg.world_size > 1 and torch.cuda.is_available():
40
+ dist.init_process_group(backend="nccl", init_method="env://")
41
+ local_rank = int(os.environ["LOCAL_RANK"])
42
+ world_rank = dist.get_rank()
43
+ device = f"cuda:{local_rank}" if torch.cuda.is_available() else "cpu"
44
+
45
+ ## DataLoader init
46
+ cfg_data = YParams(config_file_path, "datapipe")
47
+ cfg['N_in_channels'] = len(cfg_data.dataset.channels)
48
+ cfg['N_out_channels'] = len(cfg_data.dataset.channels)
49
+ datapipe = ERA5Datapipe(
50
+ dataset_dir=cfg_data.dataset.data_dir,
51
+ used_variables=cfg_data.dataset.channels,
52
+ used_years=cfg_data.dataset.train_time,
53
+ distributed=dist.is_initialized(),
54
+ input_steps=cfg.input_steps,
55
+ output_steps=cfg.output_steps,
56
+ batch_size=cfg_data.dataloader.batch_size,
57
+ num_workers=cfg_data.dataloader.num_workers,
58
+ )
59
+ train_dataloader, train_sampler = datapipe.get_dataloader("train")
60
+ datapipe = ERA5Datapipe(
61
+ dataset_dir=cfg_data.dataset.data_dir,
62
+ used_variables=cfg_data.dataset.channels,
63
+ used_years=cfg_data.dataset.val_time,
64
+ distributed=dist.is_initialized(),
65
+ input_steps=cfg.input_steps,
66
+ output_steps=cfg.output_steps,
67
+ batch_size=cfg_data.dataloader.batch_size,
68
+ num_workers=cfg_data.dataloader.num_workers,
69
+ )
70
+ val_dataloader, val_sampler = datapipe.get_dataloader("valid")
71
+
72
+ # Model init
73
+ model = FGN(
74
+ in_channels=cfg['N_in_channels'],
75
+ out_channels=cfg['N_out_channels'],
76
+ input_steps=cfg.input_steps,
77
+ output_steps=cfg.output_steps,
78
+ grid_shape=cfg.grid_shape,
79
+ mesh_shape=cfg.mesh_shape,
80
+ latent_dim=cfg.latent_dim,
81
+ num_encoder_layers=cfg.num_encoder_layers,
82
+ num_decoder_layers=cfg.num_decoder_layers,
83
+ num_processor_blocks=cfg.num_processor_blocks,
84
+ n_heads=cfg.n_heads,
85
+ hidden_dim=cfg.hidden_dim,
86
+ noise_dim=cfg.noise_dim,
87
+ channel_weights=cfg.channel_weights,
88
+ ).to(device)
89
+
90
+ if _FUSED_ADAM:
91
+ optimizer = optimizers.FusedAdam(model.parameters(), lr=cfg.lr)
92
+ else:
93
+ optimizer = torch.optim.Adam(model.parameters(), lr=cfg.lr)
94
+ scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, factor=0.2, patience=5, mode='min')
95
+
96
+ ## Train process init
97
+ os.makedirs(cfg.checkpoint_dir, exist_ok=True)
98
+ train_loss_file = f"{cfg.checkpoint_dir}/trloss.npy"
99
+ valid_loss_file = f"{cfg.checkpoint_dir}/valoss.npy"
100
+ best_valid_loss = float("inf")
101
+ best_loss_epoch = 0
102
+ train_losses = np.empty((0,), dtype=np.float32)
103
+ valid_losses = np.empty((0,), dtype=np.float32)
104
+
105
+ ## Get model params count
106
+ if cfg.world_size == 1:
107
+ total_params = sum(p.numel() for p in model.parameters())
108
+ print("\n\n")
109
+ print("-" * 50)
110
+ print(f"📂 now params is {total_params}, {total_params / 1e6:.2f}M, {total_params / 1e9:.2f}B")
111
+ print("-" * 50, "\n")
112
+
113
+ ## Load model weight if there exist well-trained model
114
+ if os.path.exists(f"{cfg.checkpoint_dir}/model_bak.pth"):
115
+ if world_rank == 0:
116
+ print("\n\n")
117
+ print("-" * 50)
118
+ print(f"✅ There has a model weight, load and continue training...")
119
+ print(f'If you want to train a new model, ensure there is no *.pth file in {cfg.checkpoint_dir}')
120
+ print("-" * 50, "\n")
121
+ ckpt = torch.load(f"{cfg.checkpoint_dir}/model_bak.pth", map_location=device, weights_only=False)
122
+ model.load_state_dict(ckpt["model_state_dict"])
123
+ optimizer.load_state_dict(ckpt["optimizer_state_dict"])
124
+ scheduler.load_state_dict(ckpt["scheduler_state_dict"])
125
+ best_valid_loss = ckpt["best_valid_loss"]
126
+ best_loss_epoch = ckpt["best_loss_epoch"]
127
+ train_losses = np.load(train_loss_file)
128
+ valid_losses = np.load(valid_loss_file)
129
+
130
+ ## Distributed model
131
+ if dist.is_initialized():
132
+ model = torch.nn.parallel.DistributedDataParallel(model, device_ids=[local_rank], output_device=local_rank)
133
+ world_rank == 0 and logger.info(f"start training ...")
134
+
135
+ for epoch in range(cfg.max_epoch):
136
+ if dist.is_initialized():
137
+ train_sampler.set_epoch(epoch)
138
+ val_sampler.set_epoch(epoch)
139
+ model.train()
140
+ train_loss = 0
141
+ start_time = time.time()
142
+ for j, data in enumerate(train_dataloader):
143
+ invar = data[0].to(device, dtype=torch.float32) # [B, input_steps, C, H, W]
144
+ outvar = data[1].to(device, dtype=torch.float32) # [B, output_steps, C, H, W]
145
+ outvar_pred = model(invar, num_members=cfg.num_members) # [B, M, output_steps, C, H, W]
146
+ loss = model.crps_loss(outvar_pred, outvar) # 论文式(4) 公平 CRPS
147
+ optimizer.zero_grad()
148
+ loss.backward()
149
+ optimizer.step()
150
+ train_loss += loss.item()
151
+ if world_rank == 0:
152
+ logger.info(f'Train: Epoch {epoch}-{j+1}/{len(train_dataloader)} '
153
+ f'[cost {int((time.time()-start_time) // 60):02}:{int((time.time()-start_time) % 60):02}] '
154
+ f'[{(time.time()-start_time)/(j+1): .02f}s/{cfg_data.dataloader.batch_size}batch] '
155
+ f'loss:{train_loss / (j+1): .04f}')
156
+
157
+ train_loss /= len(train_dataloader)
158
+
159
+ model.eval()
160
+ valid_loss = 0
161
+ with torch.no_grad():
162
+ start_time = time.time()
163
+ for j, data in enumerate(val_dataloader):
164
+ invar = data[0].to(device, dtype=torch.float32)
165
+ outvar = data[1].to(device, dtype=torch.float32)
166
+ outvar_pred = model(invar, num_members=cfg.num_members)
167
+ loss = model.crps_loss(outvar_pred, outvar)
168
+
169
+ if dist.is_initialized():
170
+ loss_tensor = loss.detach().to(device)
171
+ dist.all_reduce(loss_tensor)
172
+ loss = loss_tensor.item() / cfg.world_size
173
+ valid_loss += loss
174
+ else:
175
+ valid_loss += loss.item()
176
+ if world_rank == 0:
177
+ logger.info(f'Valid: Epoch {epoch}-{j+1}/{len(val_dataloader)} '
178
+ f'[cost {int((time.time()-start_time) // 60):02}:{int((time.time()-start_time) % 60):02}] '
179
+ f'loss:{valid_loss / (j+1): .04f}')
180
+
181
+ valid_loss /= len(val_dataloader)
182
+ is_save_ckp = False
183
+ if valid_loss < best_valid_loss:
184
+ best_valid_loss = valid_loss
185
+ best_loss_epoch = epoch
186
+ world_rank == 0 and save_checkpoint(model, optimizer, scheduler, best_valid_loss, best_loss_epoch, cfg.checkpoint_dir)
187
+ is_save_ckp = True
188
+ scheduler.step(valid_loss)
189
+
190
+ if world_rank == 0:
191
+ logger.info(f"Epoch [{epoch + 1}/{cfg.max_epoch}], "
192
+ f"Train Loss: {train_loss:.4f}, "
193
+ f"Valid Loss: {valid_loss:.4f}, "
194
+ f"Best loss at Epoch: {best_loss_epoch + 1}"
195
+ + (", saving checkpoint" if is_save_ckp else "")
196
+ )
197
+ train_losses = np.append(train_losses, train_loss)
198
+ valid_losses = np.append(valid_losses, valid_loss)
199
+ np.save(train_loss_file, train_losses)
200
+ np.save(valid_loss_file, valid_losses)
201
+
202
+ if epoch - best_loss_epoch > cfg.patience:
203
+ print(f"Loss has not decrease in {cfg.patience} epochs, stopping training...")
204
+ exit()
205
+
206
+
207
+ def save_checkpoint(model, optimizer, scheduler, best_valid_loss, best_loss_epoch, model_path):
208
+ model_to_save = model.module if hasattr(model, "module") else model
209
+ state = {"model_state_dict": model_to_save.state_dict(),
210
+ "optimizer_state_dict": optimizer.state_dict(),
211
+ "scheduler_state_dict": scheduler.state_dict(),
212
+ "best_valid_loss": best_valid_loss,
213
+ "best_loss_epoch": best_loss_epoch,
214
+ }
215
+ torch.save(state, f"{model_path}/model.pth")
216
+ ### the weight file saving may interrupted due to DCU queue limit, get a backup to ensure there at least has one model
217
+ os.system(f"mv {model_path}/model.pth {model_path}/model_bak.pth")
218
+
219
+
220
+ if __name__ == "__main__":
221
+ current_path = os.getcwd()
222
+ sys.path.append(current_path)
223
+ main()
weight/.gitkeep ADDED
File without changes