Zhongning commited on
Commit
92d0b3f
·
verified ·
1 Parent(s): 4da7569

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/GraphDOP", "files": {"README.md|1787118190.0|6673": {"hash": "014371f316b8c1a1c437e6c5fedd7a93415921b4871ad37485cf1e682df834e8", "size": 6673, "status": "c"}, "conf/config.yaml|1787189077.0|2517": {"hash": "04ee6d048c400bf199bea31516dfd7c8db381cdbfd34cd9ec37b7bc1e2b1e922", "size": 2517, "status": "c"}, "model/__pycache__/graphdop.cpython-311.pyc|1787129468.0|20086": {"hash": "2ca2bd82b23d7b8a3e58209ebd5ddf898689f9929949fde90ffc7474f13fa439", "size": 20086, "status": "c"}, "scripts/fake_data.py|1787118238.0|3109": {"hash": "9ecb538acc95858926ef9f0a1f3bcbe0a228cc6a4122c4872ec946179ac885ba", "size": 3109, "status": "c"}, "scripts/inference.py|1787060565.0|3460": {"hash": "c3623c7078ace0c3c91407a7c835131adb287015f7dd84a938d473058be3bef4", "size": 3460, "status": "c"}, "scripts/result.py|1787060586.0|8607": {"hash": "2b2b68ec178f50b303f66c4a3e89d40e133a3a1559da3aaf77ad9fbcffff74e2", "size": 8607, "status": "c"}, "configuration.json|1787060533.0|39": {"hash": "6b6f30f1e82ad5243c2ff583dcd33b77949442fbf942d80d9f236ffd7a9fe796", "size": 39, "status": "c"}, "model/graphdop.py|1787060478.0|11202": {"hash": "3ed51f897038bbad17a8fcd215dbee3e37a50f6d2e66353222175132b1fe608d", "size": 11202, "status": "c"}, "scripts/train.py|1787060554.0|9302": {"hash": "b17e5ae03dedb73ae34643054dc1b1e3c89f1468dac6a8804b4966e6e90c8b33", "size": 9302, "status": "c"}, "weight/.gitkeep|1787060604.0|1": {"hash": "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b", "size": 1, "status": "c"}}}
README.md ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - en
5
+ - zh
6
+ tags:
7
+ - OneScience
8
+ - Earth science
9
+ - Weather forecasting
10
+ - Medium-range weather forecasting
11
+ - Observation-driven
12
+ - Graph neural networks
13
+ - ERA5
14
+ frameworks: PyTorch
15
+ datasets:
16
+ - OneScience/ERA5
17
+ ---
18
+ <p align="center">
19
+ <strong>
20
+ <span style="font-size: 30px;">GraphDOP</span>
21
+ </strong>
22
+ </p>
23
+
24
+
25
+ # Model Introduction
26
+
27
+ GraphDOP (Graph-based Direct Observation Prediction) was proposed by ECMWF. It is an end-to-end observation-driven (AI-DOP) weather forecasting model based on graph neural networks (GNNs). The model uses only Earth-system observations (polar-orbiting/geostationary satellite brightness temperatures, occultation bending angles, scatterometer backscatter, radar altimetry, radiosonde and conventional surface observations, and so on) as inputs and training targets. It does not use any physics-based reanalysis field and can produce medium-range forecasts beyond 5 days.
28
+
29
+ Paper:GraphDOP: Towards skilful data-driven medium-range weather forecasts learnt and initialised directly from observations
30
+
31
+ https://arxiv.org/abs/2412.15687
32
+
33
+ # Model Description
34
+
35
+ GraphDOP uses an encoder-processor-decoder architecture: the GNN encoder maps observations in the input window to an O96 (approximately 1°) latent grid according to spatial proximity, the Transformer processor advances the atmospheric state in latent space, and the GNN decoder maps the latent grid back to target observation locations and outputs forecasts channel by channel. The training objective is channel-weighted mean squared error (WMSE). This repository is a minimal reproduction based on the paper and is integrated with the OneScience data loading and training workflow.
36
+
37
+ # Use Cases
38
+
39
+ | Scenario | Description |
40
+ | :---: | :--- |
41
+ | Observation-driven medium-range weather forecasting research | Learn atmospheric-state representations directly from observations and forecast future windows. |
42
+ | Graph + Transformer latent-space model research | Reproducible encoder-processor-decoder architecture and WMSE objective. |
43
+ | Local quick validation | Use synthetic data to check data loading, training, inference, and result scripts. |
44
+ | ModelScope/OneCode execution | Download the model package, install dependencies, and run the scripts directly. |
45
+ | Multi-card training | Launch multi-process training with `torchrun`. |
46
+
47
+
48
+ # Usage
49
+
50
+ ## 1. OneCode Usage
51
+
52
+ Use the OneCode online environment for intelligent one-click AI4S programming:
53
+
54
+ [Try intelligent one-click AI4S programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
55
+
56
+ ## 2. Manual Installation and Usage
57
+
58
+ **Hardware Requirements**
59
+
60
+ - GPU or DCU is recommended.
61
+ - CPU can be used for imports and small-configuration connectivity validation, but full training and inference are slower.
62
+ - DCU users must install DTK beforehand. DTK 25.04.2 or later, or the OneScience-recommended version matching the current cluster, is recommended.
63
+
64
+
65
+ ### Download the Model Package
66
+
67
+ ```bash
68
+ hf download OneScience-Group/GraphDOP --local-dir ./GraphDOP
69
+ cd GraphDOP
70
+ ```
71
+
72
+ ### Install the Runtime Environment
73
+
74
+ **DCU Environment**
75
+
76
+ ```bash
77
+ # Activate DTK and CONDA first
78
+ conda create -n onescience311 python=3.11 -y
79
+ conda activate onescience311
80
+ # uv installation is supported
81
+ pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
82
+ ```
83
+
84
+ **GPU Environment**
85
+ ```bash
86
+ # Activate CONDA first
87
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
88
+ conda activate onescience311
89
+ # uv installation is supported
90
+ pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
91
+ ```
92
+
93
+ ### Training Data
94
+
95
+ 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:
96
+
97
+ ```bash
98
+ hf download --repo-type dataset OneScience-Group/ERA5 --local-dir ./data
99
+ ```
100
+
101
+ For a quick workflow validation, run the synthetic data script first:
102
+
103
+ ```bash
104
+ python scripts/fake_data.py
105
+ ```
106
+
107
+ > Note: `scripts/fake_data.py` generates `[T, C, H, W]` data from the model input/output windows and `grid_shape`. Because ERA5Datapipe supports only regular grids, this project approximates the irregular Level-1 observations in the paper with six observation-grid channels.
108
+
109
+ ### Training
110
+
111
+ Single card:
112
+
113
+ ```bash
114
+ python scripts/train.py
115
+ ```
116
+
117
+ Multiple cards:
118
+
119
+ ```bash
120
+ 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
121
+ ```
122
+
123
+ Training outputs:
124
+
125
+ ```text
126
+ data/checkpoints/model_bak.pth
127
+ data/checkpoints/trloss.npy
128
+ data/checkpoints/valoss.npy
129
+ ```
130
+
131
+ ### Training Weights
132
+ 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 (1024 latent channels, an O96 latent grid, and 70k steps on 64×H100 GPUs) has no publicly released weights.
133
+
134
+ ### Inference
135
+
136
+ Inference reads `data/checkpoints/model_bak.pth`:
137
+
138
+ ```bash
139
+ python scripts/inference.py
140
+ ```
141
+
142
+ Prediction results are written frame by frame to:
143
+
144
+ ```text
145
+ result/output/
146
+ ```
147
+
148
+ ### Evaluation and Visualization
149
+
150
+ ```bash
151
+ python scripts/result.py
152
+ ```
153
+
154
+ Outputs include:
155
+
156
+ - `result/rmse.npy`
157
+ - `result/acc.npy`
158
+ - `result/loss.png`
159
+ - Forecast comparison plots for the specified date and variables
160
+
161
+
162
+ # Official Source and Reproduction Notes
163
+
164
+ - The paper is an ECMWF AI-DOP preprint, and no official implementation is publicly available (the source is based on PyTorch Geometric and builds dynamic observation graphs per batch). This repository's `model/graphdop.py` is a pure PyTorch minimal reproduction that preserves the paper's encoder-processor-decoder GNN structure and WMSE objective.
165
+ - Differences from the paper (due to limitations of the OneScience gridded data pipeline): the paper consumes irregular raw Level-1 observations, with each observation connected to its nearest latent-grid neighbors using a dynamically constructed graph. This reproduction uses ERA5 gridded h5 channels as a placeholder observation field and fixes the latent grid to a regular 8-neighbor graph. The edge features (azimuth and Haversine distance) match the paper. Training augmentations such as random observation dropout (25% for satellites/50% for conventional observations) are not implemented.
166
+ - `conf/config.yaml` uses a small configuration (32×32 grid, 8×8 latent grid, latent_dim=64) for connectivity validation by default; paper-level reproduction requires an O96 latent grid, latent_dim=1024, and a larger data scale.
167
+ - The following details are not disclosed in the paper and are assumptions in this reproduction:per-channel weights `w_{c,i}` (currently all 1 by default), graph-construction details, and the specific implementation of windowed attention in the processor.
168
+
169
+ # Official OneScience Information
170
+
171
+ | Platform | OneScience Main Repository | Skills Repository |
172
+ | --- | --- | --- |
173
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
174
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
175
+
176
+ # Citation and License
177
+
178
+ - This repository is an independent GraphDOP reproduction (the model code is an original minimal implementation), with the architecture design based on the paper by Alexe et al. (2024).
179
+ - Please cite:Alexe, M., E. Boucher, P. Lean, E. Pinnington, P. Laloyaux, A. McNally et al. GraphDOP: Towards skilful data-driven medium-range weather forecasts learnt and initialised directly from observations. arXiv:2412.15687, 2024.
conf/config.yaml ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GraphDOP 训练配置示例
2
+ # 论文配置:输入一个 12 小时观测窗口(O96 reduced Gaussian 网格约 1°、40320 潜节点),
3
+ # 经 GNN 编码器映射到潜网格、Transformer 处理器推进时间、GNN 解码器预测下一窗口观测;
4
+ # 潜空间通道 1024,WMSE 目标,18 年数据(2004-2021)训练,64×H100 70k 步。
5
+ # 当前为连通性验证小配置:虚拟数据 32×32 网格、潜网格 8×8、latent_dim=64。
6
+ model:
7
+ start_epoch: 0
8
+ max_epoch: 100
9
+ lr: 1E-3 # 论文起始 lr=1e-3,cosine 退火到 3e-7(warmup 1000 步)
10
+ patience: 50
11
+ checkpoint_dir: "./data/checkpoints"
12
+
13
+ # GraphDOP 结构参数(论文值见注释)
14
+ in_channels: 6 # 观测通道数(论文为多仪器通道,如 ATMS/AMSU-A/IASI 等)
15
+ out_channels: 6 # 预报通道数
16
+ input_steps: 2 # 输入窗口帧数(论文为单个 12h 窗口;time_step=6h 故取 2 帧)
17
+ output_steps: 2 # 输出窗口帧数(论文为下一个 12h 窗口)
18
+ grid_shape: [32, 32] # 观测网格尺寸(论文 O96 约 1°)
19
+ mesh_shape: [8, 8] # 潜网格尺寸(论文 O96 reduced Gaussian 40320 节点)
20
+ latent_dim: 64 # 潜空间通道数(论文 1024)
21
+ num_encoder_layers: 2 # 编码器 GNN 消息传递层数
22
+ num_decoder_layers: 2 # 解码器 GNN 消息传递层数
23
+ num_processor_blocks: 1 # 处理器 Transformer 块数
24
+ n_heads: 4
25
+ hidden_dim: 64
26
+ channel_weights: [1, 1, 1, 1, 1, 1] # WMSE 逐通道权重(论文 w_{c,i} 经验值)
27
+
28
+ # 整个数据读取流程
29
+ datapipe:
30
+ name: "ERA5"
31
+ task: "weather_forecasting"
32
+
33
+ dataset:
34
+ type: "hdf5"
35
+ data_dir: './data/'
36
+ train_time: [1951, 1952]
37
+ val_time: [1953]
38
+ test_time: [1954]
39
+ img_size: [32, 32]
40
+ verbose: true
41
+ cache: false
42
+
43
+ # 气象变量(论文观测类型占位:ATMS 亮温/掩星弯角/散射计后向散射/雷达高度计有效波高/常规观测)
44
+ channels: ['atms_brightness_temperature', 'gpsro_bending_angle', 'ascat_sigma0',
45
+ 'significant_wave_height', '2m_temperature', '10m_wind_speed']
46
+
47
+ # DataLoader 配置
48
+ dataloader:
49
+ mask_dtype: "float32"
50
+ batch_size: 4
51
+ num_workers: 1
52
+ pin_memory: true
53
+ drop_last: true
54
+ shuffle: false
55
+ prefetch_factor: 2
56
+ persistent_workers: true
57
+
58
+ # 分布式配置
59
+ distributed:
60
+ enabled: true
61
+ sampler: "DistributedSampler"
62
+ rank: 0
63
+ world_size: 2
64
+ shuffle: true
65
+ seed: 42
66
+ drop_last: true
config.json ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "GraphDOP",
3
+ "model_type": "graphdop",
4
+ "architectures": [
5
+ "GraphDOP"
6
+ ],
7
+ "framework": "PyTorch",
8
+ "domain": "climate-and-atmosphere",
9
+ "task": "observation-driven-medium-range-weather-forecasting",
10
+ "implementation": {
11
+ "entry_point": "model/graphdop.py",
12
+ "scope": "pure-PyTorch minimal reproduction using gridded ERA5 placeholders and fixed regular-mesh graphs instead of the paper's irregular Level-1 observations and dynamic graphs"
13
+ },
14
+ "architecture": {
15
+ "family": "GNN encoder-Transformer processor-GNN decoder",
16
+ "input_format": "B T C H W",
17
+ "encoder": "per-grid-cell MLP, adaptive pooling to the latent mesh, then residual mean-aggregation GNN layers",
18
+ "processor": "pre-normalized Transformer encoder over latent-mesh tokens with learned positional embeddings",
19
+ "decoder": "latent-mesh GNN, bilinear upsampling, and a per-grid-cell output MLP",
20
+ "edge_features": [
21
+ "forward bearing",
22
+ "Haversine distance"
23
+ ],
24
+ "activation": "GELU",
25
+ "normalization": "LayerNorm",
26
+ "loss": "channel-weighted mean squared error",
27
+ "repository_default_config": {
28
+ "purpose": "connectivity validation with synthetic gridded data",
29
+ "grid_shape": [
30
+ 32,
31
+ 32
32
+ ],
33
+ "mesh_shape": [
34
+ 8,
35
+ 8
36
+ ],
37
+ "in_channels": 6,
38
+ "out_channels": 6,
39
+ "input_steps": 2,
40
+ "output_steps": 2,
41
+ "latent_dim": 64,
42
+ "num_encoder_layers": 2,
43
+ "num_decoder_layers": 2,
44
+ "num_processor_blocks": 1,
45
+ "attention_heads": 4,
46
+ "hidden_dim": 64,
47
+ "channel_weights": [
48
+ 1,
49
+ 1,
50
+ 1,
51
+ 1,
52
+ 1,
53
+ 1
54
+ ]
55
+ },
56
+ "paper_reference_config": {
57
+ "latent_grid": "O96 reduced Gaussian grid with 40320 nodes",
58
+ "latent_dim": 1024,
59
+ "observation_graph": "dynamic graph over irregular Level-1 observations",
60
+ "training_steps": 70000,
61
+ "training_hardware": "64 H100 GPUs"
62
+ }
63
+ },
64
+ "data": {
65
+ "dataset": "ERA5",
66
+ "role": "regular-grid placeholder for the paper's multi-instrument observations",
67
+ "variables": [
68
+ "atms_brightness_temperature",
69
+ "gpsro_bending_angle",
70
+ "ascat_sigma0",
71
+ "significant_wave_height",
72
+ "2m_temperature",
73
+ "10m_wind_speed"
74
+ ],
75
+ "time_step_hours": 6,
76
+ "input_length": 2,
77
+ "output_length": 2,
78
+ "channels": 6,
79
+ "spatial_size": [
80
+ 32,
81
+ 32
82
+ ],
83
+ "storage_format": "HDF5 fields with T C H W layout",
84
+ "train_years": [
85
+ 1951,
86
+ 1952
87
+ ],
88
+ "validation_years": [
89
+ 1953
90
+ ],
91
+ "test_years": [
92
+ 1954
93
+ ]
94
+ },
95
+ "configuration_sources": [
96
+ "README.md",
97
+ "conf/config.yaml",
98
+ "model/graphdop.py",
99
+ "scripts/train.py",
100
+ "scripts/inference.py",
101
+ "scripts/fake_data.py",
102
+ "configuration.json"
103
+ ]
104
+ }
configuration.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"framework":"Pytorch","task":"other"}
model/__pycache__/graphdop.cpython-311.pyc ADDED
Binary file (20.1 kB). View file
 
model/graphdop.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # coding=utf-8
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ #
5
+ # Minimal reproduction of GraphDOP (ECMWF, "Towards skilful medium-range
6
+ # forecasts learnt directly from observations", 2025 preprint) following the
7
+ # encoder -- processor -- decoder design:
8
+ #
9
+ # * Encoder: a GNN that projects gridded "observations" inside the input
10
+ # window onto a latent mesh (a coarse regular lat/lon grid), using graph
11
+ # edges with (forward bearing, haversine distance) features.
12
+ # * Processor: a transformer that advances the latent atmospheric state
13
+ # forward in time, once per output frame (latent-space rollout).
14
+ # * Decoder: a GNN that maps the latent mesh back onto the target grid and
15
+ # predicts per-channel observations with instrument-like output MLPs.
16
+ #
17
+ # Differences from the paper (documented in README.md): the paper consumes
18
+ # irregular, instrument-specific Level-1 observations with dynamic graphs built
19
+ # per batch (PyTorch Geometric); here the OneScience ERA5-h5 gridded pipeline is
20
+ # used as the observation placeholder, and the graphs are fixed regular-grid
21
+ # meshes. The weighted MSE objective is kept (per-channel weights).
22
+ import math
23
+
24
+ import torch
25
+ import torch.nn as nn
26
+ import torch.nn.functional as F
27
+
28
+
29
+ def _latlon_grid(shape):
30
+ """Regular lat/lon coordinates for a (H, W) grid, North-to-South rows."""
31
+ H, W = shape
32
+ lat = torch.linspace(90.0, -90.0, H)
33
+ lon = torch.linspace(0.0, 360.0 - 360.0 / W, W)
34
+ return lat, lon
35
+
36
+
37
+ def _haversine(lat1, lon1, lat2, lon2):
38
+ """Haversine distance in metres between points given in degrees."""
39
+ R = 6371000.0
40
+ p1 = torch.deg2rad(lat1)
41
+ p2 = torch.deg2rad(lat2)
42
+ dp = torch.deg2rad(lat2 - lat1)
43
+ dl = torch.deg2rad(lon2 - lon1)
44
+ a = torch.sin(dp / 2) ** 2 + torch.cos(p1) * torch.cos(p2) * torch.sin(dl / 2) ** 2
45
+ return 2 * R * torch.asin(torch.sqrt(a.clamp(0, 1)))
46
+
47
+
48
+ def _bearing(lat1, lon1, lat2, lon2):
49
+ """Initial forward bearing in radians from point 1 to point 2."""
50
+ p1 = torch.deg2rad(lat1)
51
+ p2 = torch.deg2rad(lat2)
52
+ dl = torch.deg2rad(lon2 - lon1)
53
+ y = torch.sin(dl) * torch.cos(p2)
54
+ x = torch.cos(p1) * torch.sin(p2) - torch.sin(p1) * torch.cos(p2) * torch.cos(dl)
55
+ return torch.atan2(y, x)
56
+
57
+
58
+ def build_mesh_graph(mesh_shape):
59
+ """
60
+ Build a fixed 8-neighbourhood graph over a regular latent mesh.
61
+ Longitude wraps around; edge features are (forward bearing [rad],
62
+ haversine distance [km]).
63
+ """
64
+ H, W = mesh_shape
65
+ lat, lon = _latlon_grid(mesh_shape)
66
+ lat = lat.view(-1, 1).expand(H, W)
67
+ lon = lon.view(1, -1).expand(H, W)
68
+
69
+ src_list, dst_list, feat_list = [], [], []
70
+ for i in range(H):
71
+ for j in range(W):
72
+ for di, dj in ((-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)):
73
+ ni, nj = i + di, (j + dj) % W
74
+ if not (0 <= ni < H):
75
+ continue
76
+ s = i * W + j
77
+ d = ni * W + nj
78
+ dist_km = _haversine(lat[i, j], lon[i, j], lat[ni, nj], lon[ni, nj]) / 1000.0
79
+ bear = _bearing(lat[i, j], lon[i, j], lat[ni, nj], lon[ni, nj])
80
+ src_list.append(s)
81
+ dst_list.append(d)
82
+ feat_list.append(torch.stack([bear / math.pi, dist_km / 1000.0]))
83
+ edge_index = torch.stack([torch.as_tensor(src_list), torch.as_tensor(dst_list)], dim=0)
84
+ edge_attr = torch.stack(feat_list)
85
+ return edge_index, edge_attr
86
+
87
+
88
+ def _mlp(in_dim, out_dim, hidden_dim, n_layers=2):
89
+ dims = [in_dim] + [hidden_dim] * (n_layers - 1) + [out_dim]
90
+ layers = []
91
+ for i in range(len(dims) - 1):
92
+ layers.append(nn.Linear(dims[i], dims[i + 1]))
93
+ if i < len(dims) - 2:
94
+ layers.append(nn.GELU())
95
+ return nn.Sequential(*layers)
96
+
97
+
98
+ class GNNLayer(nn.Module):
99
+ """Message-passing layer with edge features (mean-aggregate, residual)."""
100
+
101
+ def __init__(self, dim, edge_dim=2, hidden_dim=64):
102
+ super().__init__()
103
+ self.edge_mlp = _mlp(2 * dim + edge_dim, dim, hidden_dim)
104
+ self.node_mlp = _mlp(dim, dim, hidden_dim)
105
+ self.norm = nn.LayerNorm(dim)
106
+
107
+ def forward(self, x, edge_index, edge_attr):
108
+ B, N, D = x.shape
109
+ src, dst = edge_index
110
+ offsets = torch.arange(B, device=x.device) * N
111
+ src_b = (src.unsqueeze(0) + offsets.view(B, 1)).reshape(-1)
112
+ dst_b = (dst.unsqueeze(0) + offsets.view(B, 1)).reshape(-1)
113
+ edge_attr_b = edge_attr.unsqueeze(0).expand(B, -1, -1).reshape(-1, edge_attr.size(1))
114
+ xb = x.reshape(B * N, D)
115
+ msg = self.edge_mlp(torch.cat([xb[src_b], xb[dst_b], edge_attr_b], dim=1))
116
+ agg = torch.zeros_like(xb)
117
+ agg.index_add_(0, dst_b, msg)
118
+ cnt = torch.bincount(dst_b, minlength=B * N).clamp(min=1).unsqueeze(1)
119
+ agg = agg / cnt
120
+ agg = agg.reshape(B, N, D)
121
+ return self.norm(x + self.node_mlp(agg))
122
+
123
+
124
+ class ObsEncoder(nn.Module):
125
+ """
126
+ Maps the observation grid onto the latent mesh with a per-cell input MLP,
127
+ an adaptive pooling to the mesh resolution, and graph message passing.
128
+ """
129
+
130
+ def __init__(self, in_channels, latent_dim, mesh_shape, num_layers=2, hidden_dim=64):
131
+ super().__init__()
132
+ self.in_channels = in_channels
133
+ self.input_mlp = _mlp(in_channels, latent_dim, hidden_dim)
134
+ self.gnn = nn.ModuleList([GNNLayer(latent_dim, hidden_dim=hidden_dim) for _ in range(num_layers)])
135
+ self.mesh_shape = mesh_shape
136
+ self.edge_index, self.edge_attr = build_mesh_graph(mesh_shape)
137
+
138
+ def forward(self, x):
139
+ B, C, H, W = x.shape
140
+ feat = x.permute(0, 2, 3, 1).reshape(-1, C)
141
+ feat = self.input_mlp(feat).reshape(B, H, W, -1).permute(0, 3, 1, 2)
142
+ mesh = F.adaptive_avg_pool2d(feat, self.mesh_shape)
143
+ mesh = mesh.permute(0, 2, 3, 1).reshape(B, -1, mesh.size(1))
144
+ edge_index, edge_attr = self.edge_index.to(x.device), self.edge_attr.to(x.device)
145
+ for layer in self.gnn:
146
+ mesh = layer(mesh, edge_index, edge_attr)
147
+ return mesh
148
+
149
+
150
+ class LatentProcessor(nn.Module):
151
+ """Transformer over latent mesh tokens that advances the state in time."""
152
+
153
+ def __init__(self, latent_dim, mesh_shape, num_blocks=1, n_heads=4, hidden_dim=128):
154
+ super().__init__()
155
+ n_nodes = mesh_shape[0] * mesh_shape[1]
156
+ self.pos_emb = nn.Parameter(torch.zeros(1, n_nodes, latent_dim))
157
+ nn.init.trunc_normal_(self.pos_emb, std=0.02)
158
+ block = nn.TransformerEncoderLayer(
159
+ d_model=latent_dim, nhead=n_heads, dim_feedforward=hidden_dim,
160
+ dropout=0.0, activation="gelu", batch_first=True, norm_first=True,
161
+ )
162
+ self.blocks = nn.ModuleList([block for _ in range(num_blocks)])
163
+
164
+ def forward(self, mesh):
165
+ tokens = mesh + self.pos_emb
166
+ for block in self.blocks:
167
+ tokens = block(tokens)
168
+ return tokens
169
+
170
+
171
+ class ObsDecoder(nn.Module):
172
+ """
173
+ Maps the latent mesh back onto the target grid (bilinear upsample) and
174
+ predicts per-channel observations with an output MLP.
175
+ """
176
+
177
+ def __init__(self, latent_dim, out_channels, grid_shape, mesh_shape, num_layers=2, hidden_dim=64):
178
+ super().__init__()
179
+ self.gnn = nn.ModuleList([GNNLayer(latent_dim, hidden_dim=hidden_dim) for _ in range(num_layers)])
180
+ self.grid_shape = grid_shape
181
+ self.mesh_shape = mesh_shape
182
+ self.edge_index, self.edge_attr = build_mesh_graph(mesh_shape)
183
+ self.output_mlp = _mlp(latent_dim, out_channels, hidden_dim)
184
+
185
+ def forward(self, mesh):
186
+ B, N, D = mesh.shape
187
+ edge_index = self.edge_index.to(mesh.device)
188
+ edge_attr = self.edge_attr.to(mesh.device)
189
+ for layer in self.gnn:
190
+ mesh = layer(mesh, edge_index, edge_attr)
191
+ H, W = self.grid_shape
192
+ Hm, Wm = self.mesh_shape
193
+ mesh = mesh.transpose(1, 2).reshape(B, D, Hm, Wm)
194
+ grid = F.interpolate(mesh, size=self.grid_shape, mode="bilinear", align_corners=False)
195
+ grid = grid.permute(0, 2, 3, 1).reshape(B, H * W, D)
196
+ return self.output_mlp(grid).reshape(B, H, W, -1).permute(0, 3, 1, 2)
197
+
198
+
199
+ class GraphDOP(nn.Module):
200
+ """
201
+ Config-driven GraphDOP wrapper.
202
+
203
+ Args:
204
+ in_channels: Number of observation channels per frame.
205
+ out_channels: Number of forecast channels per frame.
206
+ input_steps: Number of input (observation window) frames.
207
+ output_steps: Number of forecast frames.
208
+ grid_shape: Spatial shape of the (gridded) observation field.
209
+ mesh_shape: Latent mesh resolution (each dimension, powers of two fine).
210
+ latent_dim: Feature dimension of latent mesh tokens.
211
+ num_encoder_layers / num_decoder_layers: GNN message-passing layers.
212
+ num_processor_blocks: Transformer blocks in the processor.
213
+ n_heads: Attention heads of the processor.
214
+ channel_weights: Per-channel weights for the weighted MSE objective.
215
+ """
216
+
217
+ def __init__(
218
+ self,
219
+ in_channels=6,
220
+ out_channels=6,
221
+ input_steps=2,
222
+ output_steps=2,
223
+ grid_shape=(32, 32),
224
+ mesh_shape=(8, 8),
225
+ latent_dim=64,
226
+ num_encoder_layers=2,
227
+ num_decoder_layers=2,
228
+ num_processor_blocks=1,
229
+ n_heads=4,
230
+ hidden_dim=64,
231
+ channel_weights=None,
232
+ ):
233
+ super().__init__()
234
+ self.in_channels = int(in_channels)
235
+ self.out_channels = int(out_channels)
236
+ self.input_steps = int(input_steps)
237
+ self.output_steps = int(output_steps)
238
+ self.grid_shape = (int(grid_shape[0]), int(grid_shape[1]))
239
+ self.mesh_shape = (int(mesh_shape[0]), int(mesh_shape[1]))
240
+
241
+ self.encoder = ObsEncoder(
242
+ self.in_channels, int(latent_dim), self.mesh_shape, num_layers=int(num_encoder_layers), hidden_dim=int(hidden_dim)
243
+ )
244
+ self.processor = LatentProcessor(
245
+ int(latent_dim), self.mesh_shape, num_blocks=int(num_processor_blocks), n_heads=int(n_heads), hidden_dim=int(hidden_dim)
246
+ )
247
+ self.decoder = ObsDecoder(
248
+ int(latent_dim), self.out_channels, self.grid_shape, self.mesh_shape,
249
+ num_layers=int(num_decoder_layers), hidden_dim=int(hidden_dim),
250
+ )
251
+
252
+ if channel_weights is None:
253
+ channel_weights = torch.ones(self.out_channels)
254
+ self.register_buffer("channel_weights", torch.as_tensor(channel_weights, dtype=torch.float32))
255
+
256
+ def forward(self, x):
257
+ """
258
+ Args:
259
+ x: Observation frames, shape [batch, input_steps, C, H, W].
260
+ Returns:
261
+ Forecast frames, shape [batch, output_steps, C, H, W].
262
+ """
263
+ latents = torch.stack([self.encoder(x[:, t]) for t in range(self.input_steps)], dim=0)
264
+ latent = latents.mean(dim=0)
265
+ outs = []
266
+ for _ in range(self.output_steps):
267
+ latent = self.processor(latent)
268
+ outs.append(self.decoder(latent))
269
+ return torch.stack(outs, dim=1)
270
+
271
+ def wmse_loss(self, pred, target):
272
+ """Weighted mean squared error objective (Eq. 1 of the paper)."""
273
+ diff = (pred - target) ** 2
274
+ w = self.channel_weights.view(1, 1, self.out_channels, 1, 1)
275
+ return (diff * 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
+ # GraphDOP 的当前 OneScience 适配层把不规则观测投影为规则网格通道。
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,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.graphdop import GraphDOP
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 = GraphDOP(
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
+ channel_weights=cfg.channel_weights,
76
+ ).to(device)
77
+ model.load_state_dict(ckpt["model_state_dict"])
78
+
79
+ model.eval()
80
+ os.makedirs('result/output/', exist_ok=True)
81
+ print(f"📂 infer results will be generated to './result/output/'")
82
+ with torch.no_grad():
83
+ for data in tqdm(test_dataloader, desc="Inferring testset", unit="batch"):
84
+ invar = data[0].to(device, dtype=torch.float32) # [1, input_steps, C, H, W]
85
+ pred = model(invar).cpu().numpy() # [1, output_steps, C, H, W]
86
+ for t in range(pred.shape[1]):
87
+ fname = data[4][cfg.input_steps + t][0] # 该预测帧对应的时刻
88
+ pred_var = pred[0, t] # [C, H, W]
89
+ pred_var = pred_var * stds + means
90
+ 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, cfg in zip(axes, plot_configs):
117
+ im = ax.imshow(cfg['data'], cmap=cfg['cmap'], vmin=cfg['vmin'], vmax=cfg['vmax'])
118
+ ax.set_title(cfg['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,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.graphdop import GraphDOP
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 = GraphDOP(
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
+ channel_weights=cfg.channel_weights,
87
+ ).to(device)
88
+
89
+ if _FUSED_ADAM:
90
+ optimizer = optimizers.FusedAdam(model.parameters(), lr=cfg.lr)
91
+ else:
92
+ optimizer = torch.optim.Adam(model.parameters(), lr=cfg.lr)
93
+ scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, factor=0.2, patience=5, mode='min')
94
+
95
+ ## Train process init
96
+ os.makedirs(cfg.checkpoint_dir, exist_ok=True)
97
+ train_loss_file = f"{cfg.checkpoint_dir}/trloss.npy"
98
+ valid_loss_file = f"{cfg.checkpoint_dir}/valoss.npy"
99
+ best_valid_loss = float("inf")
100
+ best_loss_epoch = 0
101
+ train_losses = np.empty((0,), dtype=np.float32)
102
+ valid_losses = np.empty((0,), dtype=np.float32)
103
+
104
+ ## Get model params count
105
+ if cfg.world_size == 1:
106
+ total_params = sum(p.numel() for p in model.parameters())
107
+ print("\n\n")
108
+ print("-" * 50)
109
+ print(f"📂 now params is {total_params}, {total_params / 1e6:.2f}M, {total_params / 1e9:.2f}B")
110
+ print("-" * 50, "\n")
111
+
112
+ ## Load model weight if there exist well-trained model
113
+ if os.path.exists(f"{cfg.checkpoint_dir}/model_bak.pth"):
114
+ if world_rank == 0:
115
+ print("\n\n")
116
+ print("-" * 50)
117
+ print(f"✅ There has a model weight, load and continue training...")
118
+ print(f'If you want to train a new model, ensure there is no *.pth file in {cfg.checkpoint_dir}')
119
+ print("-" * 50, "\n")
120
+ ckpt = torch.load(f"{cfg.checkpoint_dir}/model_bak.pth", map_location=device, weights_only=False)
121
+ model.load_state_dict(ckpt["model_state_dict"])
122
+ optimizer.load_state_dict(ckpt["optimizer_state_dict"])
123
+ scheduler.load_state_dict(ckpt["scheduler_state_dict"])
124
+ best_valid_loss = ckpt["best_valid_loss"]
125
+ best_loss_epoch = ckpt["best_loss_epoch"]
126
+ train_losses = np.load(train_loss_file)
127
+ valid_losses = np.load(valid_loss_file)
128
+
129
+ ## Distributed model
130
+ if dist.is_initialized():
131
+ model = torch.nn.parallel.DistributedDataParallel(model, device_ids=[local_rank], output_device=local_rank)
132
+ world_rank == 0 and logger.info(f"start training ...")
133
+
134
+ for epoch in range(cfg.max_epoch):
135
+ if dist.is_initialized():
136
+ train_sampler.set_epoch(epoch)
137
+ val_sampler.set_epoch(epoch)
138
+ model.train()
139
+ train_loss = 0
140
+ start_time = time.time()
141
+ for j, data in enumerate(train_dataloader):
142
+ invar = data[0].to(device, dtype=torch.float32) # [B, input_steps, C, H, W]
143
+ outvar = data[1].to(device, dtype=torch.float32) # [B, output_steps, C, H, W]
144
+ outvar_pred = model(invar) # [B, output_steps, C, H, W]
145
+ loss = model.wmse_loss(outvar_pred, outvar) # 论文式(1) WMSE
146
+ optimizer.zero_grad()
147
+ loss.backward()
148
+ optimizer.step()
149
+ train_loss += loss.item()
150
+ if world_rank == 0:
151
+ logger.info(f'Train: Epoch {epoch}-{j+1}/{len(train_dataloader)} '
152
+ f'[cost {int((time.time()-start_time) // 60):02}:{int((time.time()-start_time) % 60):02}] '
153
+ f'[{(time.time()-start_time)/(j+1): .02f}s/{cfg_data.dataloader.batch_size}batch] '
154
+ f'loss:{train_loss / (j+1): .04f}')
155
+
156
+ train_loss /= len(train_dataloader)
157
+
158
+ model.eval()
159
+ valid_loss = 0
160
+ with torch.no_grad():
161
+ start_time = time.time()
162
+ for j, data in enumerate(val_dataloader):
163
+ invar = data[0].to(device, dtype=torch.float32)
164
+ outvar = data[1].to(device, dtype=torch.float32)
165
+ outvar_pred = model(invar)
166
+ loss = model.wmse_loss(outvar_pred, outvar)
167
+
168
+ if dist.is_initialized():
169
+ loss_tensor = loss.detach().to(device)
170
+ dist.all_reduce(loss_tensor)
171
+ loss = loss_tensor.item() / cfg.world_size
172
+ valid_loss += loss
173
+ else:
174
+ valid_loss += loss.item()
175
+ if world_rank == 0:
176
+ logger.info(f'Valid: Epoch {epoch}-{j+1}/{len(val_dataloader)} '
177
+ f'[cost {int((time.time()-start_time) // 60):02}:{int((time.time()-start_time) % 60):02}] '
178
+ f'loss:{valid_loss / (j+1): .04f}')
179
+
180
+ valid_loss /= len(val_dataloader)
181
+ is_save_ckp = False
182
+ if valid_loss < best_valid_loss:
183
+ best_valid_loss = valid_loss
184
+ best_loss_epoch = epoch
185
+ world_rank == 0 and save_checkpoint(model, optimizer, scheduler, best_valid_loss, best_loss_epoch, cfg.checkpoint_dir)
186
+ is_save_ckp = True
187
+ scheduler.step(valid_loss)
188
+
189
+ if world_rank == 0:
190
+ logger.info(f"Epoch [{epoch + 1}/{cfg.max_epoch}], "
191
+ f"Train Loss: {train_loss:.4f}, "
192
+ f"Valid Loss: {valid_loss:.4f}, "
193
+ f"Best loss at Epoch: {best_loss_epoch + 1}"
194
+ + (", saving checkpoint" if is_save_ckp else "")
195
+ )
196
+ train_losses = np.append(train_losses, train_loss)
197
+ valid_losses = np.append(valid_losses, valid_loss)
198
+ np.save(train_loss_file, train_losses)
199
+ np.save(valid_loss_file, valid_losses)
200
+
201
+ if epoch - best_loss_epoch > cfg.patience:
202
+ print(f"Loss has not decrease in {cfg.patience} epochs, stopping training...")
203
+ exit()
204
+
205
+
206
+ def save_checkpoint(model, optimizer, scheduler, best_valid_loss, best_loss_epoch, model_path):
207
+ model_to_save = model.module if hasattr(model, "module") else model
208
+ state = {"model_state_dict": model_to_save.state_dict(),
209
+ "optimizer_state_dict": optimizer.state_dict(),
210
+ "scheduler_state_dict": scheduler.state_dict(),
211
+ "best_valid_loss": best_valid_loss,
212
+ "best_loss_epoch": best_loss_epoch,
213
+ }
214
+ torch.save(state, f"{model_path}/model.pth")
215
+ ### the weight file saving may interrupted due to DCU queue limit, get a backup to ensure there at least has one model
216
+ os.system(f"mv {model_path}/model.pth {model_path}/model_bak.pth")
217
+
218
+
219
+ if __name__ == "__main__":
220
+ current_path = os.getcwd()
221
+ sys.path.append(current_path)
222
+ main()
weight/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+