Upload folder using huggingface_hub
Browse files- .DS_Store +0 -0
- .ms_upload_cache +1 -0
- README.md +182 -0
- conf/config.yaml +72 -0
- config.json +107 -0
- configuration.json +1 -0
- model/__pycache__/fgn.cpython-311.pyc +0 -0
- model/fgn.py +397 -0
- scripts/fake_data.py +74 -0
- scripts/inference.py +93 -0
- scripts/result.py +203 -0
- scripts/train.py +223 -0
- weight/.gitkeep +0 -0
.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
|