Upload folder using huggingface_hub
Browse files- README.md +170 -0
- conf/config.yaml +133 -0
- config.json +41 -0
- configuration.json +14 -0
- model/fengwu_w2s.py +252 -0
- scripts/data_loader.py +152 -0
- scripts/fake_data.py +126 -0
- scripts/inference.py +119 -0
- scripts/result.py +405 -0
- scripts/train.py +308 -0
- weight/.gitkeep +0 -0
README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
frameworks: PyTorch
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
license: apache-2.0
|
| 6 |
+
tags:
|
| 7 |
+
- OneScience
|
| 8 |
+
- Earth Science
|
| 9 |
+
- Weather Forecast
|
| 10 |
+
- Subseasonal Forecast
|
| 11 |
+
- Coupled Atmosphere-Ocean-Land
|
| 12 |
+
- ERA5
|
| 13 |
+
tasks: []
|
| 14 |
+
datasets:
|
| 15 |
+
- OneScience/ERA5
|
| 16 |
+
---
|
| 17 |
+
<p align="center">
|
| 18 |
+
<strong>
|
| 19 |
+
<span style="font-size: 30px;">FengWu-W2S</span>
|
| 20 |
+
</strong>
|
| 21 |
+
</p>
|
| 22 |
+
|
| 23 |
+
# Model Introduction
|
| 24 |
+
|
| 25 |
+
FengWu-W2S (FengWu Weather-to-Subseasonal) is a seamless global weather-to-subseasonal forecasting model extending FengWu.
|
| 26 |
+
|
| 27 |
+
Paper: FengWu-W2S: A deep learning model for seamless weather-to-subseasonal forecast of global atmosphere
|
| 28 |
+
|
| 29 |
+
https://arxiv.org/abs/2411.10191
|
| 30 |
+
|
| 31 |
+
# Model Description
|
| 32 |
+
|
| 33 |
+
The model uses a six-hour time step for autoregressive forecasts of up to 42 days. Coupled atmospheric, ocean, and land branches, together with diversity perturbations, are used to improve extended-range forecast skill. This repository contains a compact implementation of the coupled interfaces for reproducible workflow checks.
|
| 34 |
+
|
| 35 |
+
# Use Cases
|
| 36 |
+
|
| 37 |
+
| Scenario | Description |
|
| 38 |
+
| :---: | :--- |
|
| 39 |
+
| Weather-to-subseasonal forecast research | Train a 78-channel coupled model with six-hourly global atmospheric, ocean, and land fields. |
|
| 40 |
+
| Local quick validation | Use small-grid synthetic HDF5 data to check training, fine-tuning, inference, and forecast visualization. |
|
| 41 |
+
| ModelScope / OneCode execution | Download the standalone model package, install dependencies, and run the scripts directly. |
|
| 42 |
+
| Multi-GPU training | Launch PyTorch DistributedDataParallel with `torchrun`. |
|
| 43 |
+
|
| 44 |
+
# Usage Guide
|
| 45 |
+
|
| 46 |
+
## 1. OneCode Usage
|
| 47 |
+
|
| 48 |
+
Experience intelligent one-click AI4S programming through the OneCode online environment:
|
| 49 |
+
|
| 50 |
+
[Click to Experience Intelligent One-Click AI4S Programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
|
| 51 |
+
|
| 52 |
+
## 2. Manual Installation and Usage
|
| 53 |
+
|
| 54 |
+
**Hardware Requirements**
|
| 55 |
+
|
| 56 |
+
- A GPU or DCU is recommended.
|
| 57 |
+
- CPU can be used for import and small-scale connectivity verification; full training and inference will be slow.
|
| 58 |
+
- DCU users must install DTK in advance. DTK 25.04.2 or above, or the OneScience recommended version matching your cluster, is recommended.
|
| 59 |
+
|
| 60 |
+
### Download the Model Package
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
hf download OneScience-Group/FengWu-W2S --local-dir ./FengWu-W2S
|
| 64 |
+
cd FengWu-W2S
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
### Install the Runtime Environment
|
| 68 |
+
|
| 69 |
+
**DCU Environment**
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
# Please activate DTK and CONDA first
|
| 73 |
+
conda create -n onescience311 python=3.11 -y
|
| 74 |
+
conda activate onescience311
|
| 75 |
+
# uv installation is supported
|
| 76 |
+
pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
**GPU Environment**
|
| 80 |
+
```bash
|
| 81 |
+
# Please activate CONDA first
|
| 82 |
+
conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
|
| 83 |
+
conda activate onescience311
|
| 84 |
+
# uv installation is supported
|
| 85 |
+
pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
### Training Data Introduction
|
| 89 |
+
|
| 90 |
+
The OneScience community provides an ERA5 data slice for training. Download it and confirm that the data path in `conf/config.yaml` is correct:
|
| 91 |
+
|
| 92 |
+
```bash
|
| 93 |
+
hf download --repo-type dataset OneScience-Group/ERA5 --local-dir ./data
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
Real files must contain all 78 channels listed in `conf/config.yaml`, including atmospheric, ocean, and land variables, with six-hour time spacing. The configured group indices define the coupled branches.
|
| 97 |
+
|
| 98 |
+
### Generate Synthetic Data
|
| 99 |
+
|
| 100 |
+
The default configuration describes a full `721x1440` grid. For a practical local smoke test, generate a small fixture explicitly:
|
| 101 |
+
|
| 102 |
+
```bash
|
| 103 |
+
python scripts/fake_data.py --height 32 --width 64 --timesteps 12
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
Synthetic HDF5 files are for workflow validation only and do not represent the ERA5 reanalysis or the paper's forecast quality.
|
| 107 |
+
|
| 108 |
+
### Training
|
| 109 |
+
|
| 110 |
+
Single GPU:
|
| 111 |
+
|
| 112 |
+
```bash
|
| 113 |
+
python scripts/train.py
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
Multi-GPU:
|
| 117 |
+
|
| 118 |
+
```bash
|
| 119 |
+
torchrun --nproc_per_node=2 scripts/train.py
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
For a short smoke run, reduce the workload while keeping the generated data and model grid consistent:
|
| 123 |
+
|
| 124 |
+
```bash
|
| 125 |
+
python scripts/train.py --max-epoch 1 --batch-size 1 --num-workers 0 --rollout-steps 1
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
The default checkpoint is saved to `data/checkpoints/model_bak.pth`.
|
| 129 |
+
|
| 130 |
+
### Fine-tuning
|
| 131 |
+
|
| 132 |
+
Resume from the base checkpoint with the lower fine-tuning learning rate:
|
| 133 |
+
|
| 134 |
+
```bash
|
| 135 |
+
python scripts/train.py --finetune
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
### Training Weights
|
| 139 |
+
|
| 140 |
+
This repository provides a `weight/` directory for FengWu-W2S checkpoints. The weight files will be uploaded soon and are expected to be available in the near future.
|
| 141 |
+
|
| 142 |
+
### Inference
|
| 143 |
+
|
| 144 |
+
Inference reads `data/checkpoints/model_bak.pth` by default and writes forecasts grouped by initialization year to `result/output/<year>/`, together with `result/output/index.json`:
|
| 145 |
+
|
| 146 |
+
```bash
|
| 147 |
+
python scripts/inference.py
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
Use `--steps` and `--limit` to bound a local smoke test; `--stochastic` enables perturbation sampling.
|
| 151 |
+
|
| 152 |
+
### Evaluation and Visualization
|
| 153 |
+
|
| 154 |
+
```bash
|
| 155 |
+
python scripts/result.py
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
The script computes per-channel RMSE, normalized RMSE, and anomaly ACC, and generates forecast-comparison, lead-time skill, channel-ranking, and training-loss figures.
|
| 159 |
+
|
| 160 |
+
# Official OneScience Resources
|
| 161 |
+
|
| 162 |
+
| Platform | OneScience Main Repository | Skills Repository |
|
| 163 |
+
| --- | --- | --- |
|
| 164 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 165 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 166 |
+
|
| 167 |
+
# Citation and License
|
| 168 |
+
|
| 169 |
+
- Paper: https://arxiv.org/abs/2411.10191
|
| 170 |
+
- This directory is an independent reproduction of the paper method and does not represent official code, weights, or training results released by the authors.
|
conf/config.yaml
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
model:
|
| 2 |
+
name: FengWu-W2S
|
| 3 |
+
default_checkpoint: ./data/checkpoints/model_bak.pth
|
| 4 |
+
input_steps: 2
|
| 5 |
+
rollout_steps: 2
|
| 6 |
+
inference_steps: 4
|
| 7 |
+
patch_size: 4
|
| 8 |
+
hidden_channels: 32
|
| 9 |
+
latent_channels: 64
|
| 10 |
+
num_blocks: 2
|
| 11 |
+
perturbation_scale: 0.01
|
| 12 |
+
learning_rate: 0.0005
|
| 13 |
+
finetune_learning_rate: 0.00005
|
| 14 |
+
adam_beta1: 0.9
|
| 15 |
+
adam_beta2: 0.9
|
| 16 |
+
probability_loss: true
|
| 17 |
+
kl_weight: 0.0001
|
| 18 |
+
initial_perturbation_scale: 0.01
|
| 19 |
+
weight_decay: 0.00001
|
| 20 |
+
max_epoch: 10
|
| 21 |
+
finetune_epoch: 40
|
| 22 |
+
patience: 10
|
| 23 |
+
checkpoint_dir: ./data/checkpoints
|
| 24 |
+
|
| 25 |
+
data:
|
| 26 |
+
data_dir: ./data
|
| 27 |
+
train_years: [1999,2000]
|
| 28 |
+
val_years: [2001]
|
| 29 |
+
test_years: [2002]
|
| 30 |
+
time_step_hours: 6
|
| 31 |
+
channels:
|
| 32 |
+
- geopotential_50
|
| 33 |
+
- geopotential_100
|
| 34 |
+
- geopotential_150
|
| 35 |
+
- geopotential_200
|
| 36 |
+
- geopotential_250
|
| 37 |
+
- geopotential_300
|
| 38 |
+
- geopotential_400
|
| 39 |
+
- geopotential_500
|
| 40 |
+
- geopotential_600
|
| 41 |
+
- geopotential_700
|
| 42 |
+
- geopotential_850
|
| 43 |
+
- geopotential_925
|
| 44 |
+
- geopotential_1000
|
| 45 |
+
- temperature_50
|
| 46 |
+
- temperature_100
|
| 47 |
+
- temperature_150
|
| 48 |
+
- temperature_200
|
| 49 |
+
- temperature_250
|
| 50 |
+
- temperature_300
|
| 51 |
+
- temperature_400
|
| 52 |
+
- temperature_500
|
| 53 |
+
- temperature_600
|
| 54 |
+
- temperature_700
|
| 55 |
+
- temperature_850
|
| 56 |
+
- temperature_925
|
| 57 |
+
- temperature_1000
|
| 58 |
+
- u_component_of_wind_50
|
| 59 |
+
- u_component_of_wind_100
|
| 60 |
+
- u_component_of_wind_150
|
| 61 |
+
- u_component_of_wind_200
|
| 62 |
+
- u_component_of_wind_250
|
| 63 |
+
- u_component_of_wind_300
|
| 64 |
+
- u_component_of_wind_400
|
| 65 |
+
- u_component_of_wind_500
|
| 66 |
+
- u_component_of_wind_600
|
| 67 |
+
- u_component_of_wind_700
|
| 68 |
+
- u_component_of_wind_850
|
| 69 |
+
- u_component_of_wind_925
|
| 70 |
+
- u_component_of_wind_1000
|
| 71 |
+
- v_component_of_wind_50
|
| 72 |
+
- v_component_of_wind_100
|
| 73 |
+
- v_component_of_wind_150
|
| 74 |
+
- v_component_of_wind_200
|
| 75 |
+
- v_component_of_wind_250
|
| 76 |
+
- v_component_of_wind_300
|
| 77 |
+
- v_component_of_wind_400
|
| 78 |
+
- v_component_of_wind_500
|
| 79 |
+
- v_component_of_wind_600
|
| 80 |
+
- v_component_of_wind_700
|
| 81 |
+
- v_component_of_wind_850
|
| 82 |
+
- v_component_of_wind_925
|
| 83 |
+
- v_component_of_wind_1000
|
| 84 |
+
- specific_humidity_50
|
| 85 |
+
- specific_humidity_100
|
| 86 |
+
- specific_humidity_150
|
| 87 |
+
- specific_humidity_200
|
| 88 |
+
- specific_humidity_250
|
| 89 |
+
- specific_humidity_300
|
| 90 |
+
- specific_humidity_400
|
| 91 |
+
- specific_humidity_500
|
| 92 |
+
- specific_humidity_600
|
| 93 |
+
- specific_humidity_700
|
| 94 |
+
- specific_humidity_850
|
| 95 |
+
- specific_humidity_925
|
| 96 |
+
- specific_humidity_1000
|
| 97 |
+
- 2m_temperature
|
| 98 |
+
- total_precipitation
|
| 99 |
+
- mean_sea_level_pressure
|
| 100 |
+
- 10m_u_component_of_wind
|
| 101 |
+
- 10m_v_component_of_wind
|
| 102 |
+
- 100m_u_component_of_wind
|
| 103 |
+
- 100m_v_component_of_wind
|
| 104 |
+
- outgoing_longwave_radiation
|
| 105 |
+
- sea_surface_temperature
|
| 106 |
+
- significant_wave_height
|
| 107 |
+
- mean_wave_direction
|
| 108 |
+
- mean_wave_period
|
| 109 |
+
- soil_moisture_0_7cm
|
| 110 |
+
groups:
|
| 111 |
+
atmosphere: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64]
|
| 112 |
+
ocean: [73, 74, 75, 76]
|
| 113 |
+
land: [65, 66, 67, 68, 69, 70, 71, 72, 77]
|
| 114 |
+
normalization_epsilon: 1.0e-6
|
| 115 |
+
dataloader:
|
| 116 |
+
batch_size: 1
|
| 117 |
+
num_workers: 0
|
| 118 |
+
pin_memory: false
|
| 119 |
+
drop_last: false
|
| 120 |
+
|
| 121 |
+
fake_data:
|
| 122 |
+
# Small defaults keep the workflow runnable on a login node. Real ERA5 can use 721x1440.
|
| 123 |
+
# height: 32
|
| 124 |
+
height: 721
|
| 125 |
+
# width: 64
|
| 126 |
+
width: 1440
|
| 127 |
+
timesteps: 12
|
| 128 |
+
seed: 42
|
| 129 |
+
|
| 130 |
+
runtime:
|
| 131 |
+
# Fail fast instead of silently training on CPU when the DCU runtime is absent.
|
| 132 |
+
device: cuda
|
| 133 |
+
amp: false
|
config.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_name": "FengWu-W2S",
|
| 3 |
+
"model_type": "fengwu_w2s",
|
| 4 |
+
"architectures": [
|
| 5 |
+
"FengWuW2S"
|
| 6 |
+
],
|
| 7 |
+
"framework": "PyTorch",
|
| 8 |
+
"domain": "coupled-atmosphere-ocean-land",
|
| 9 |
+
"task": "seamless-weather-to-subseasonal-forecasting",
|
| 10 |
+
"implementation": {
|
| 11 |
+
"entry_point": "model/fengwu_w2s.py",
|
| 12 |
+
"scope": "compact coupled weather, ocean, and land model with stochastic perturbations for autoregressive rollout"
|
| 13 |
+
},
|
| 14 |
+
"architecture": {
|
| 15 |
+
"family": "coupled branch encoders with latent fusion and perturbation module",
|
| 16 |
+
"input_steps": 2,
|
| 17 |
+
"rollout_steps": 2,
|
| 18 |
+
"inference_steps": 4,
|
| 19 |
+
"input_channels": 78,
|
| 20 |
+
"atmosphere_channels": 65,
|
| 21 |
+
"ocean_channels": 4,
|
| 22 |
+
"land_channels": 9,
|
| 23 |
+
"hidden_channels": 32,
|
| 24 |
+
"latent_channels": 64,
|
| 25 |
+
"patch_size": 4,
|
| 26 |
+
"blocks": 2,
|
| 27 |
+
"perturbation_scale": 0.01
|
| 28 |
+
},
|
| 29 |
+
"data": {
|
| 30 |
+
"dataset": "ERA5 with coupled ocean and land fields",
|
| 31 |
+
"spatial_resolution_degrees": 0.25,
|
| 32 |
+
"time_step_hours": 6,
|
| 33 |
+
"protocol": "78-channel weather-to-subseasonal contract"
|
| 34 |
+
},
|
| 35 |
+
"configuration_sources": [
|
| 36 |
+
"conf/config.yaml",
|
| 37 |
+
"model/fengwu_w2s.py",
|
| 38 |
+
"scripts/data_loader.py",
|
| 39 |
+
"scripts/train.py"
|
| 40 |
+
]
|
| 41 |
+
}
|
configuration.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"framework": "PyTorch",
|
| 3 |
+
"task": "subseasonal_forecasting",
|
| 4 |
+
"model": "FengWu-W2S",
|
| 5 |
+
"input_format": "BTCHW",
|
| 6 |
+
"protocol": "independent_reproduction",
|
| 7 |
+
"default_config": "conf/config.yaml",
|
| 8 |
+
"train": "scripts/train.py",
|
| 9 |
+
"inference": "scripts/inference.py",
|
| 10 |
+
"evaluation": "scripts/result.py",
|
| 11 |
+
"visualization": "scripts/result.py",
|
| 12 |
+
"paper": "https://arxiv.org/abs/2411.10191",
|
| 13 |
+
"license": "Apache License 2.0"
|
| 14 |
+
}
|
model/fengwu_w2s.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Self-contained FengWu-W2S style coupled weather model.
|
| 2 |
+
|
| 3 |
+
The original FengWu-W2S extends FengWu with coupled ocean-atmosphere-land
|
| 4 |
+
states and perturbations for seamless subseasonal rollout. This module keeps
|
| 5 |
+
those interfaces explicit while using compact convolutional blocks so the
|
| 6 |
+
workflow can be smoke-tested on small synthetic grids.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import Dict, Iterable, Mapping, Optional, Sequence
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
from torch import Tensor, nn
|
| 15 |
+
from torch.nn import functional as F
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _group_count(channels: int, maximum: int = 8) -> int:
|
| 19 |
+
for groups in range(min(maximum, channels), 0, -1):
|
| 20 |
+
if channels % groups == 0:
|
| 21 |
+
return groups
|
| 22 |
+
return 1
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ResidualConvBlock(nn.Module):
|
| 26 |
+
"""Local periodic convolution block used by each FengWu branch."""
|
| 27 |
+
|
| 28 |
+
def __init__(self, channels: int, dilation: int = 1) -> None:
|
| 29 |
+
super().__init__()
|
| 30 |
+
padding = dilation
|
| 31 |
+
self.norm1 = nn.GroupNorm(_group_count(channels), channels)
|
| 32 |
+
self.conv1 = nn.Conv2d(
|
| 33 |
+
channels,
|
| 34 |
+
channels,
|
| 35 |
+
kernel_size=3,
|
| 36 |
+
padding=padding,
|
| 37 |
+
dilation=dilation,
|
| 38 |
+
padding_mode="circular",
|
| 39 |
+
)
|
| 40 |
+
self.norm2 = nn.GroupNorm(_group_count(channels), channels)
|
| 41 |
+
self.conv2 = nn.Conv2d(
|
| 42 |
+
channels, channels, kernel_size=3, padding=1, padding_mode="circular"
|
| 43 |
+
)
|
| 44 |
+
self.activation = nn.GELU()
|
| 45 |
+
|
| 46 |
+
def forward(self, x: Tensor) -> Tensor:
|
| 47 |
+
residual = x
|
| 48 |
+
x = self.conv1(self.activation(self.norm1(x)))
|
| 49 |
+
x = self.conv2(self.activation(self.norm2(x)))
|
| 50 |
+
return residual + x
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class BranchEncoder(nn.Module):
|
| 54 |
+
"""Patch embedding followed by compact Swin/FengWu-like local blocks."""
|
| 55 |
+
|
| 56 |
+
def __init__(
|
| 57 |
+
self,
|
| 58 |
+
in_channels: int,
|
| 59 |
+
hidden_channels: int,
|
| 60 |
+
latent_channels: int,
|
| 61 |
+
patch_size: int,
|
| 62 |
+
num_blocks: int,
|
| 63 |
+
) -> None:
|
| 64 |
+
super().__init__()
|
| 65 |
+
self.patch_size = max(1, int(patch_size))
|
| 66 |
+
self.stem = nn.Conv2d(
|
| 67 |
+
in_channels,
|
| 68 |
+
hidden_channels,
|
| 69 |
+
kernel_size=self.patch_size,
|
| 70 |
+
stride=self.patch_size,
|
| 71 |
+
)
|
| 72 |
+
blocks = []
|
| 73 |
+
for block_index in range(max(1, int(num_blocks))):
|
| 74 |
+
blocks.append(ResidualConvBlock(hidden_channels, dilation=1 + block_index % 2))
|
| 75 |
+
self.blocks = nn.Sequential(*blocks)
|
| 76 |
+
self.projection = nn.Conv2d(hidden_channels, latent_channels, kernel_size=1)
|
| 77 |
+
|
| 78 |
+
def forward(self, x: Tensor, output_size: Sequence[int]) -> Tensor:
|
| 79 |
+
# Padding here is implicit in interpolation, which also handles odd
|
| 80 |
+
# real-world grids such as 721x1440 with a compact patch embedding.
|
| 81 |
+
x = self.stem(x)
|
| 82 |
+
x = self.blocks(x)
|
| 83 |
+
x = self.projection(x)
|
| 84 |
+
return F.interpolate(x, size=tuple(output_size), mode="bilinear", align_corners=False)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class CoupledFusion(nn.Module):
|
| 88 |
+
"""Fuse atmosphere/ocean/land latents with a learned global gate."""
|
| 89 |
+
|
| 90 |
+
def __init__(self, latent_channels: int, branches: int = 3) -> None:
|
| 91 |
+
super().__init__()
|
| 92 |
+
total = latent_channels * branches
|
| 93 |
+
self.mix = nn.Sequential(
|
| 94 |
+
nn.Conv2d(total, latent_channels, kernel_size=1),
|
| 95 |
+
nn.GroupNorm(_group_count(latent_channels), latent_channels),
|
| 96 |
+
nn.GELU(),
|
| 97 |
+
ResidualConvBlock(latent_channels),
|
| 98 |
+
)
|
| 99 |
+
self.gate = nn.Sequential(
|
| 100 |
+
nn.AdaptiveAvgPool2d(1),
|
| 101 |
+
nn.Conv2d(latent_channels, latent_channels, kernel_size=1),
|
| 102 |
+
nn.Sigmoid(),
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
def forward(self, features: Iterable[Tensor]) -> Tensor:
|
| 106 |
+
fused = self.mix(torch.cat(tuple(features), dim=1))
|
| 107 |
+
return fused * (1.0 + self.gate(fused))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class BranchDecoder(nn.Module):
|
| 111 |
+
def __init__(self, latent_channels: int, out_channels: int, num_blocks: int) -> None:
|
| 112 |
+
super().__init__()
|
| 113 |
+
blocks = [ResidualConvBlock(latent_channels) for _ in range(max(1, int(num_blocks)))]
|
| 114 |
+
self.blocks = nn.Sequential(*blocks)
|
| 115 |
+
self.head = nn.Conv2d(latent_channels, out_channels, kernel_size=1)
|
| 116 |
+
|
| 117 |
+
def forward(self, x: Tensor) -> Tensor:
|
| 118 |
+
return self.head(self.blocks(x))
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class PerturbationModule(nn.Module):
|
| 122 |
+
"""Inject scale-controlled latent perturbations for ensemble members."""
|
| 123 |
+
|
| 124 |
+
def __init__(self, channels: int, scale: float = 0.01) -> None:
|
| 125 |
+
super().__init__()
|
| 126 |
+
self.scale = float(scale)
|
| 127 |
+
self.correlation = nn.Conv2d(channels, channels, kernel_size=1, bias=False)
|
| 128 |
+
self.mean_head = nn.Conv2d(channels, channels, kernel_size=1)
|
| 129 |
+
self.logvar_head = nn.Conv2d(channels, channels, kernel_size=1)
|
| 130 |
+
nn.init.eye_(self.correlation.weight.squeeze(-1).squeeze(-1))
|
| 131 |
+
|
| 132 |
+
def forward(self, latent: Tensor, enabled: bool, return_kl: bool = False):
|
| 133 |
+
if not enabled or self.scale == 0.0:
|
| 134 |
+
return (latent, latent.new_zeros(())) if return_kl else latent
|
| 135 |
+
noise = torch.randn_like(latent)
|
| 136 |
+
mean = self.mean_head(latent)
|
| 137 |
+
logvar = self.logvar_head(latent).clamp(-6.0, 2.0)
|
| 138 |
+
sample = mean + torch.exp(0.5 * logvar) * self.correlation(noise)
|
| 139 |
+
kl = 0.5 * torch.mean(torch.exp(logvar) + mean.square() - 1.0 - logvar)
|
| 140 |
+
perturbed = latent + self.scale * sample
|
| 141 |
+
return (perturbed, kl) if return_kl else perturbed
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
class FengWuW2S(nn.Module):
|
| 145 |
+
"""Coupled autoregressive FengWu-W2S model.
|
| 146 |
+
|
| 147 |
+
Inputs and outputs use ``[batch, channels, latitude, longitude]``. The
|
| 148 |
+
channel groups can be changed without changing the model interface.
|
| 149 |
+
"""
|
| 150 |
+
|
| 151 |
+
def __init__(
|
| 152 |
+
self,
|
| 153 |
+
in_channels: int,
|
| 154 |
+
group_indices: Mapping[str, Sequence[int]],
|
| 155 |
+
hidden_channels: int = 32,
|
| 156 |
+
latent_channels: int = 64,
|
| 157 |
+
patch_size: int = 4,
|
| 158 |
+
num_blocks: int = 2,
|
| 159 |
+
perturbation_scale: float = 0.01,
|
| 160 |
+
) -> None:
|
| 161 |
+
super().__init__()
|
| 162 |
+
required = {"atmosphere", "ocean", "land"}
|
| 163 |
+
missing = required.difference(group_indices)
|
| 164 |
+
if missing:
|
| 165 |
+
raise ValueError(f"group_indices missing required groups: {sorted(missing)}")
|
| 166 |
+
self.in_channels = int(in_channels)
|
| 167 |
+
self.group_indices = {name: tuple(int(i) for i in indices) for name, indices in group_indices.items()}
|
| 168 |
+
all_indices = [i for values in self.group_indices.values() for i in values]
|
| 169 |
+
if sorted(all_indices) != list(range(self.in_channels)):
|
| 170 |
+
raise ValueError("group_indices must partition all input channels")
|
| 171 |
+
|
| 172 |
+
self.encoders = nn.ModuleDict(
|
| 173 |
+
{
|
| 174 |
+
name: BranchEncoder(
|
| 175 |
+
len(indices), hidden_channels, latent_channels, patch_size, num_blocks
|
| 176 |
+
)
|
| 177 |
+
for name, indices in self.group_indices.items()
|
| 178 |
+
}
|
| 179 |
+
)
|
| 180 |
+
self.fusion = CoupledFusion(latent_channels, branches=3)
|
| 181 |
+
self.perturbation = PerturbationModule(latent_channels, perturbation_scale)
|
| 182 |
+
# A per-channel log scale makes the FengWu Gaussian probability loss
|
| 183 |
+
# explicit while keeping inference output equal to the predicted mean.
|
| 184 |
+
self.log_std = nn.Parameter(torch.zeros(self.in_channels, 1, 1))
|
| 185 |
+
self.decoders = nn.ModuleDict(
|
| 186 |
+
{
|
| 187 |
+
name: BranchDecoder(latent_channels, len(indices), num_blocks)
|
| 188 |
+
for name, indices in self.group_indices.items()
|
| 189 |
+
}
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
def _split(self, state: Tensor) -> Dict[str, Tensor]:
|
| 193 |
+
return {
|
| 194 |
+
name: state[:, indices, ...]
|
| 195 |
+
for name, indices in self.group_indices.items()
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
def forward(
|
| 199 |
+
self,
|
| 200 |
+
state: Tensor,
|
| 201 |
+
stochastic: Optional[bool] = None,
|
| 202 |
+
return_aux: bool = False,
|
| 203 |
+
):
|
| 204 |
+
if state.ndim != 4 or state.shape[1] != self.in_channels:
|
| 205 |
+
raise ValueError(
|
| 206 |
+
f"expected [B,{self.in_channels},H,W], got {tuple(state.shape)}"
|
| 207 |
+
)
|
| 208 |
+
branches = self._split(state)
|
| 209 |
+
spatial = state.shape[-2:]
|
| 210 |
+
features = [self.encoders[name](branches[name], spatial) for name in ("atmosphere", "ocean", "land")]
|
| 211 |
+
latent = self.fusion(features)
|
| 212 |
+
latent, kl = self.perturbation(
|
| 213 |
+
latent,
|
| 214 |
+
self.training if stochastic is None else stochastic,
|
| 215 |
+
return_kl=True,
|
| 216 |
+
)
|
| 217 |
+
delta = state.new_zeros(state.shape)
|
| 218 |
+
for name, indices in self.group_indices.items():
|
| 219 |
+
delta[:, indices, ...] = self.decoders[name](latent)
|
| 220 |
+
# Predicting an increment stabilizes long autoregressive rollouts.
|
| 221 |
+
result = state + delta
|
| 222 |
+
if return_aux:
|
| 223 |
+
return result, {"kl": kl, "log_std": self.log_std}
|
| 224 |
+
return result
|
| 225 |
+
|
| 226 |
+
@torch.no_grad()
|
| 227 |
+
def rollout(
|
| 228 |
+
self,
|
| 229 |
+
initial_state: Tensor,
|
| 230 |
+
steps: int,
|
| 231 |
+
stochastic: bool = False,
|
| 232 |
+
seed: Optional[int] = None,
|
| 233 |
+
) -> Tensor:
|
| 234 |
+
if initial_state.ndim == 5:
|
| 235 |
+
state = initial_state[:, -1]
|
| 236 |
+
elif initial_state.ndim == 4:
|
| 237 |
+
state = initial_state
|
| 238 |
+
else:
|
| 239 |
+
raise ValueError("initial_state must be [B,T,C,H,W] or [B,C,H,W]")
|
| 240 |
+
if seed is not None:
|
| 241 |
+
torch.manual_seed(int(seed))
|
| 242 |
+
predictions = []
|
| 243 |
+
was_training = self.training
|
| 244 |
+
self.eval()
|
| 245 |
+
for _ in range(max(0, int(steps))):
|
| 246 |
+
state = self.forward(state, stochastic=stochastic)
|
| 247 |
+
predictions.append(state)
|
| 248 |
+
if was_training:
|
| 249 |
+
self.train()
|
| 250 |
+
if not predictions:
|
| 251 |
+
return state.unsqueeze(1)[:, :0]
|
| 252 |
+
return torch.stack(predictions, dim=1)
|
scripts/data_loader.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ERA5-style HDF5 loader used by training and inference."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Dict, List, Sequence, Tuple
|
| 8 |
+
|
| 9 |
+
import h5py
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
from torch.utils.data import DataLoader, Dataset
|
| 13 |
+
from torch.utils.data.distributed import DistributedSampler
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _decode(value: object) -> str:
|
| 17 |
+
return value.decode() if isinstance(value, bytes) else str(value)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def resolve_data_dir(path: str | Path, project_root: Path | None = None) -> Path:
|
| 21 |
+
root = project_root or Path(__file__).resolve().parents[1]
|
| 22 |
+
candidate = Path(path).expanduser()
|
| 23 |
+
return candidate if candidate.is_absolute() else (root / candidate).resolve()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def read_metadata(data_dir: str | Path, channels: Sequence[str]) -> Dict[str, np.ndarray | int | List[str]]:
|
| 27 |
+
data_dir = Path(data_dir)
|
| 28 |
+
files = sorted((data_dir / "data").glob("*.h5"))
|
| 29 |
+
if not files:
|
| 30 |
+
raise FileNotFoundError(f"No yearly HDF5 files found under {data_dir / 'data'}")
|
| 31 |
+
with h5py.File(files[0], "r") as source:
|
| 32 |
+
fields = source["fields"]
|
| 33 |
+
variables = [_decode(item) for item in fields.attrs["variables"]]
|
| 34 |
+
time_step = int(fields.attrs.get("time_step", 6))
|
| 35 |
+
means = np.asarray(source["global_means"][:], dtype=np.float32)
|
| 36 |
+
stds = np.asarray(source["global_stds"][:], dtype=np.float32)
|
| 37 |
+
shape = tuple(int(item) for item in fields.shape)
|
| 38 |
+
missing = [name for name in channels if name not in variables]
|
| 39 |
+
if missing:
|
| 40 |
+
raise ValueError(f"Variables missing from synthetic/ERA5 data: {missing}")
|
| 41 |
+
indices = np.asarray([variables.index(name) for name in channels], dtype=np.int64)
|
| 42 |
+
return {
|
| 43 |
+
"variables": variables,
|
| 44 |
+
"indices": indices,
|
| 45 |
+
"time_step": time_step,
|
| 46 |
+
"means": means[:, indices, :, :],
|
| 47 |
+
"stds": np.maximum(stds[:, indices, :, :], 1.0e-6),
|
| 48 |
+
"shape": shape,
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class ERA5WindowDataset(Dataset):
|
| 53 |
+
def __init__(
|
| 54 |
+
self,
|
| 55 |
+
data_dir: str | Path,
|
| 56 |
+
years: Sequence[int],
|
| 57 |
+
channels: Sequence[str],
|
| 58 |
+
input_steps: int = 2,
|
| 59 |
+
rollout_steps: int = 1,
|
| 60 |
+
normalize: bool = True,
|
| 61 |
+
) -> None:
|
| 62 |
+
self.data_dir = Path(data_dir)
|
| 63 |
+
self.years = [int(year) for year in years]
|
| 64 |
+
self.channels = list(channels)
|
| 65 |
+
self.input_steps = max(1, int(input_steps))
|
| 66 |
+
self.rollout_steps = max(1, int(rollout_steps))
|
| 67 |
+
self.normalize = bool(normalize)
|
| 68 |
+
metadata = read_metadata(self.data_dir, self.channels)
|
| 69 |
+
self.channel_indices = metadata["indices"]
|
| 70 |
+
self.time_step = int(metadata["time_step"])
|
| 71 |
+
self.means = torch.from_numpy(metadata["means"])
|
| 72 |
+
self.stds = torch.from_numpy(metadata["stds"])
|
| 73 |
+
self.shape = metadata["shape"]
|
| 74 |
+
self.samples: List[Tuple[int, int]] = []
|
| 75 |
+
for year in self.years:
|
| 76 |
+
path = self.data_dir / "data" / f"{year}.h5"
|
| 77 |
+
if not path.exists():
|
| 78 |
+
raise FileNotFoundError(f"Missing year file: {path}")
|
| 79 |
+
with h5py.File(path, "r") as source:
|
| 80 |
+
timesteps = int(source["fields"].shape[0])
|
| 81 |
+
count = timesteps - self.input_steps - self.rollout_steps + 1
|
| 82 |
+
if count <= 0:
|
| 83 |
+
raise ValueError(
|
| 84 |
+
f"Year {year} has {timesteps} steps, but input={self.input_steps} "
|
| 85 |
+
f"and rollout={self.rollout_steps} require at least {self.input_steps + self.rollout_steps}"
|
| 86 |
+
)
|
| 87 |
+
self.samples.extend((year, index) for index in range(count))
|
| 88 |
+
|
| 89 |
+
def __len__(self) -> int:
|
| 90 |
+
return len(self.samples)
|
| 91 |
+
|
| 92 |
+
def _timestamp(self, year: int, index: int) -> str:
|
| 93 |
+
value = datetime(year, 1, 1) + timedelta(hours=index * self.time_step)
|
| 94 |
+
return value.strftime("%Y%m%d%H")
|
| 95 |
+
|
| 96 |
+
def __getitem__(self, item: int):
|
| 97 |
+
year, start = self.samples[item]
|
| 98 |
+
path = self.data_dir / "data" / f"{year}.h5"
|
| 99 |
+
with h5py.File(path, "r") as source:
|
| 100 |
+
fields = source["fields"]
|
| 101 |
+
input_data = np.asarray(
|
| 102 |
+
fields[start : start + self.input_steps, self.channel_indices, :, :], dtype=np.float32
|
| 103 |
+
)
|
| 104 |
+
target_data = np.asarray(
|
| 105 |
+
fields[
|
| 106 |
+
start + self.input_steps : start + self.input_steps + self.rollout_steps,
|
| 107 |
+
self.channel_indices,
|
| 108 |
+
:,
|
| 109 |
+
:,
|
| 110 |
+
],
|
| 111 |
+
dtype=np.float32,
|
| 112 |
+
)
|
| 113 |
+
input_tensor = torch.from_numpy(input_data)
|
| 114 |
+
target_tensor = torch.from_numpy(target_data)
|
| 115 |
+
if self.normalize:
|
| 116 |
+
input_tensor = (input_tensor - self.means) / self.stds
|
| 117 |
+
target_tensor = (target_tensor - self.means) / self.stds
|
| 118 |
+
timestamp = self._timestamp(year, start + self.input_steps)
|
| 119 |
+
return input_tensor, target_tensor, timestamp
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def make_dataloader(
|
| 123 |
+
data_dir: str | Path,
|
| 124 |
+
years: Sequence[int],
|
| 125 |
+
channels: Sequence[str],
|
| 126 |
+
input_steps: int,
|
| 127 |
+
rollout_steps: int,
|
| 128 |
+
batch_size: int,
|
| 129 |
+
num_workers: int = 0,
|
| 130 |
+
distributed: bool = False,
|
| 131 |
+
train: bool = False,
|
| 132 |
+
pin_memory: bool = False,
|
| 133 |
+
):
|
| 134 |
+
dataset = ERA5WindowDataset(
|
| 135 |
+
data_dir=data_dir,
|
| 136 |
+
years=years,
|
| 137 |
+
channels=channels,
|
| 138 |
+
input_steps=input_steps,
|
| 139 |
+
rollout_steps=rollout_steps,
|
| 140 |
+
)
|
| 141 |
+
sampler = DistributedSampler(dataset, shuffle=train) if distributed else None
|
| 142 |
+
loader = DataLoader(
|
| 143 |
+
dataset,
|
| 144 |
+
batch_size=max(1, int(batch_size)),
|
| 145 |
+
shuffle=train and sampler is None,
|
| 146 |
+
sampler=sampler,
|
| 147 |
+
num_workers=max(0, int(num_workers)),
|
| 148 |
+
pin_memory=bool(pin_memory),
|
| 149 |
+
drop_last=False,
|
| 150 |
+
)
|
| 151 |
+
return loader, sampler
|
| 152 |
+
|
scripts/fake_data.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Generate a compact deterministic ERA5-style dataset for workflow checks."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import json
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import h5py
|
| 11 |
+
import numpy as np
|
| 12 |
+
import yaml
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def parse_args() -> argparse.Namespace:
|
| 19 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 20 |
+
parser.add_argument("--config", default=str(PROJECT_ROOT / "conf/config.yaml"))
|
| 21 |
+
parser.add_argument("--output-dir", help="Override data.data_dir from the config")
|
| 22 |
+
parser.add_argument("--height", type=int)
|
| 23 |
+
parser.add_argument("--width", type=int)
|
| 24 |
+
parser.add_argument("--timesteps", type=int)
|
| 25 |
+
parser.add_argument("--seed", type=int)
|
| 26 |
+
return parser.parse_args()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _resolve(path: str, root: Path) -> Path:
|
| 30 |
+
candidate = Path(path).expanduser()
|
| 31 |
+
return candidate if candidate.is_absolute() else (root / candidate).resolve()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def generate_dataset(
|
| 35 |
+
output_dir: str | Path,
|
| 36 |
+
years: list[int],
|
| 37 |
+
channels: list[str],
|
| 38 |
+
height: int,
|
| 39 |
+
width: int,
|
| 40 |
+
timesteps: int,
|
| 41 |
+
time_step_hours: int = 6,
|
| 42 |
+
seed: int = 42,
|
| 43 |
+
) -> Path:
|
| 44 |
+
"""Write yearly HDF5 files and static fields without external data access."""
|
| 45 |
+
if min(height, width, timesteps) <= 0:
|
| 46 |
+
raise ValueError("height, width and timesteps must be positive")
|
| 47 |
+
output_dir = Path(output_dir)
|
| 48 |
+
data_dir = output_dir / "data"
|
| 49 |
+
data_dir.mkdir(parents=True, exist_ok=True)
|
| 50 |
+
rng = np.random.default_rng(seed)
|
| 51 |
+
channels = list(channels)
|
| 52 |
+
channel_count = len(channels)
|
| 53 |
+
channel_mean = np.linspace(0.0, 0.9, channel_count, dtype=np.float32)
|
| 54 |
+
channel_std = np.linspace(0.8, 1.2, channel_count, dtype=np.float32)
|
| 55 |
+
latitude = np.linspace(1.0, -1.0, height, dtype=np.float32)[None, :, None]
|
| 56 |
+
longitude = np.linspace(0.0, 2.0 * np.pi, width, endpoint=False, dtype=np.float32)[None, None, :]
|
| 57 |
+
base_pattern = 0.15 * latitude + 0.08 * np.sin(longitude)
|
| 58 |
+
|
| 59 |
+
for year_offset, year in enumerate(sorted(set(int(item) for item in years))):
|
| 60 |
+
path = data_dir / f"{year}.h5"
|
| 61 |
+
with h5py.File(path, "w") as output:
|
| 62 |
+
fields = output.create_dataset(
|
| 63 |
+
"fields",
|
| 64 |
+
shape=(timesteps, channel_count, height, width),
|
| 65 |
+
dtype="float32",
|
| 66 |
+
chunks=(1, channel_count, height, width),
|
| 67 |
+
compression="lzf",
|
| 68 |
+
)
|
| 69 |
+
fields.attrs["variables"] = np.asarray(channels, dtype="S")
|
| 70 |
+
fields.attrs["time_step"] = int(time_step_hours)
|
| 71 |
+
for timestep in range(timesteps):
|
| 72 |
+
values = np.empty((channel_count, height, width), dtype=np.float32)
|
| 73 |
+
for channel in range(channel_count):
|
| 74 |
+
phase = 0.05 * (timestep + year_offset) + 0.2 * channel
|
| 75 |
+
smooth = base_pattern + 0.03 * np.sin(phase)
|
| 76 |
+
noise = rng.normal(0.0, 0.01, size=(height, width)).astype(np.float32)
|
| 77 |
+
values[channel] = channel_mean[channel] + channel_std[channel] * (smooth + noise)
|
| 78 |
+
fields[timestep] = values
|
| 79 |
+
output.create_dataset("global_means", data=channel_mean.reshape(1, channel_count, 1, 1))
|
| 80 |
+
output.create_dataset("global_stds", data=channel_std.reshape(1, channel_count, 1, 1))
|
| 81 |
+
print(f"Generated {path} shape=({timesteps},{channel_count},{height},{width})")
|
| 82 |
+
|
| 83 |
+
static_dir = output_dir / "static"
|
| 84 |
+
static_dir.mkdir(parents=True, exist_ok=True)
|
| 85 |
+
latitude_2d = latitude.squeeze(0)
|
| 86 |
+
land_mask = np.broadcast_to((latitude_2d > 0).astype(np.float32), (height, width)).copy()
|
| 87 |
+
orography = (0.2 * np.cos(longitude) + 0.05 * latitude).astype(np.float32).squeeze(0)
|
| 88 |
+
sea_ice_mask = np.broadcast_to((latitude_2d < -0.55).astype(np.float32), (height, width)).copy()
|
| 89 |
+
np.save(static_dir / "land_mask.npy", land_mask)
|
| 90 |
+
np.save(static_dir / "orography.npy", orography)
|
| 91 |
+
np.save(static_dir / "sea_ice_mask.npy", sea_ice_mask)
|
| 92 |
+
metadata = {
|
| 93 |
+
"format": "ERA5-HDF5-window-v1",
|
| 94 |
+
"years": sorted(set(int(item) for item in years)),
|
| 95 |
+
"channels": channels,
|
| 96 |
+
"shape": [timesteps, channel_count, height, width],
|
| 97 |
+
"time_step_hours": int(time_step_hours),
|
| 98 |
+
"seed": int(seed),
|
| 99 |
+
}
|
| 100 |
+
(output_dir / "metadata.json").write_text(json.dumps(metadata, indent=2), encoding="utf-8")
|
| 101 |
+
print(f"Generated static fields and metadata under {output_dir}")
|
| 102 |
+
return output_dir
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def main() -> None:
|
| 106 |
+
args = parse_args()
|
| 107 |
+
with Path(args.config).open(encoding="utf-8") as source:
|
| 108 |
+
config = yaml.safe_load(source)
|
| 109 |
+
data_cfg = config["data"]
|
| 110 |
+
fake_cfg = config.get("fake_data", {})
|
| 111 |
+
data_dir = _resolve(args.output_dir or data_cfg["data_dir"], PROJECT_ROOT)
|
| 112 |
+
years = list(data_cfg["train_years"]) + list(data_cfg["val_years"]) + list(data_cfg["test_years"])
|
| 113 |
+
generate_dataset(
|
| 114 |
+
data_dir,
|
| 115 |
+
years=years,
|
| 116 |
+
channels=list(data_cfg["channels"]),
|
| 117 |
+
height=int(args.height or fake_cfg.get("height", 32)),
|
| 118 |
+
width=int(args.width or fake_cfg.get("width", 64)),
|
| 119 |
+
timesteps=int(args.timesteps or fake_cfg.get("timesteps", 12)),
|
| 120 |
+
time_step_hours=int(data_cfg.get("time_step_hours", 6)),
|
| 121 |
+
seed=int(args.seed if args.seed is not None else fake_cfg.get("seed", 42)),
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
if __name__ == "__main__":
|
| 126 |
+
main()
|
scripts/inference.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Run autoregressive FengWu-W2S inference and save forecast fields."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import json
|
| 8 |
+
import sys
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
| 14 |
+
import yaml
|
| 15 |
+
|
| 16 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 17 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 18 |
+
|
| 19 |
+
from model.fengwu_w2s import FengWuW2S
|
| 20 |
+
from scripts.data_loader import ERA5WindowDataset, read_metadata, resolve_data_dir
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def parse_args() -> argparse.Namespace:
|
| 24 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 25 |
+
parser.add_argument("--config", default=str(PROJECT_ROOT / "conf/config.yaml"))
|
| 26 |
+
parser.add_argument("--data-dir")
|
| 27 |
+
parser.add_argument("--checkpoint")
|
| 28 |
+
parser.add_argument("--output-dir")
|
| 29 |
+
parser.add_argument("--split", choices=("train", "val", "test"), default="test")
|
| 30 |
+
parser.add_argument("--steps", type=int)
|
| 31 |
+
parser.add_argument("--limit", type=int, help="Limit windows for a quick smoke test")
|
| 32 |
+
parser.add_argument("--stochastic", action="store_true")
|
| 33 |
+
parser.add_argument("--seed", type=int, default=0)
|
| 34 |
+
return parser.parse_args()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _resolve(path: str | Path, root: Path = PROJECT_ROOT) -> Path:
|
| 38 |
+
candidate = Path(path).expanduser()
|
| 39 |
+
return candidate if candidate.is_absolute() else (root / candidate).resolve()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _build_model(config: dict) -> FengWuW2S:
|
| 43 |
+
model_cfg = config["model"]
|
| 44 |
+
data_cfg = config["data"]
|
| 45 |
+
return FengWuW2S(
|
| 46 |
+
in_channels=len(data_cfg["channels"]),
|
| 47 |
+
group_indices=data_cfg["groups"],
|
| 48 |
+
hidden_channels=int(model_cfg.get("hidden_channels", 32)),
|
| 49 |
+
latent_channels=int(model_cfg.get("latent_channels", 64)),
|
| 50 |
+
patch_size=int(model_cfg.get("patch_size", 4)),
|
| 51 |
+
num_blocks=int(model_cfg.get("num_blocks", 2)),
|
| 52 |
+
perturbation_scale=float(model_cfg.get("perturbation_scale", 0.01)),
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def main() -> None:
|
| 57 |
+
args = parse_args()
|
| 58 |
+
with Path(args.config).open(encoding="utf-8") as source:
|
| 59 |
+
config = yaml.safe_load(source)
|
| 60 |
+
data_cfg = config["data"]
|
| 61 |
+
model_cfg = config["model"]
|
| 62 |
+
data_dir = resolve_data_dir(args.data_dir or data_cfg["data_dir"], PROJECT_ROOT)
|
| 63 |
+
checkpoint = _resolve(args.checkpoint or model_cfg.get("default_checkpoint", "./data/checkpoints/model_bak.pth"))
|
| 64 |
+
if not checkpoint.exists():
|
| 65 |
+
raise FileNotFoundError(f"Checkpoint not found: {checkpoint}; run scripts/train.py first")
|
| 66 |
+
split_years = {
|
| 67 |
+
"train": data_cfg["train_years"],
|
| 68 |
+
"val": data_cfg["val_years"],
|
| 69 |
+
"test": data_cfg["test_years"],
|
| 70 |
+
}[args.split]
|
| 71 |
+
steps = int(args.steps or model_cfg.get("inference_steps", 1))
|
| 72 |
+
dataset = ERA5WindowDataset(
|
| 73 |
+
data_dir=data_dir,
|
| 74 |
+
years=split_years,
|
| 75 |
+
channels=data_cfg["channels"],
|
| 76 |
+
input_steps=int(model_cfg.get("input_steps", 2)),
|
| 77 |
+
rollout_steps=max(1, steps),
|
| 78 |
+
)
|
| 79 |
+
metadata = read_metadata(data_dir, data_cfg["channels"])
|
| 80 |
+
means = torch.from_numpy(metadata["means"])
|
| 81 |
+
stds = torch.from_numpy(metadata["stds"])
|
| 82 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 83 |
+
model = _build_model(config).to(device)
|
| 84 |
+
state = torch.load(checkpoint, map_location=device, weights_only=False)
|
| 85 |
+
model.load_state_dict(state["model_state_dict"])
|
| 86 |
+
model.eval()
|
| 87 |
+
output_dir = _resolve(args.output_dir or "./result/output")
|
| 88 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 89 |
+
index = []
|
| 90 |
+
limit = len(dataset) if args.limit is None else min(len(dataset), max(0, int(args.limit)))
|
| 91 |
+
for sample_index in range(limit):
|
| 92 |
+
inputs, _, timestamp = dataset[sample_index]
|
| 93 |
+
initial = inputs.unsqueeze(0).to(device)
|
| 94 |
+
with torch.no_grad():
|
| 95 |
+
prediction = model.rollout(initial, steps=steps, stochastic=args.stochastic, seed=args.seed + sample_index)
|
| 96 |
+
prediction = prediction.squeeze(0).cpu() * stds + means
|
| 97 |
+
year = timestamp[:4]
|
| 98 |
+
year_dir = output_dir / year
|
| 99 |
+
year_dir.mkdir(parents=True, exist_ok=True)
|
| 100 |
+
start = datetime.strptime(timestamp, "%Y%m%d%H")
|
| 101 |
+
for lead, field in enumerate(prediction):
|
| 102 |
+
valid_time = start + timedelta(hours=lead * int(metadata["time_step"]))
|
| 103 |
+
valid_timestamp = valid_time.strftime("%Y%m%d%H")
|
| 104 |
+
path = year_dir / f"{timestamp}_lead{lead:03d}.npy"
|
| 105 |
+
np.save(path, field.numpy().astype(np.float32))
|
| 106 |
+
index.append({
|
| 107 |
+
"source_timestamp": timestamp,
|
| 108 |
+
"valid_timestamp": valid_timestamp,
|
| 109 |
+
"lead": lead,
|
| 110 |
+
# Keep the manifest relocatable when the project is copied to
|
| 111 |
+
# a compute node or downloaded from ModelScope.
|
| 112 |
+
"path": str(path.relative_to(output_dir)),
|
| 113 |
+
})
|
| 114 |
+
(output_dir / "index.json").write_text(json.dumps(index, indent=2), encoding="utf-8")
|
| 115 |
+
print(f"Saved {len(index)} forecast fields under {output_dir}")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
main()
|
scripts/result.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Evaluate forecasts and render meteorological diagnostic figures."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import json
|
| 8 |
+
import sys
|
| 9 |
+
from collections import defaultdict
|
| 10 |
+
from datetime import datetime, timedelta
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any, Dict, List, Mapping, Sequence, Tuple
|
| 13 |
+
|
| 14 |
+
import h5py
|
| 15 |
+
import numpy as np
|
| 16 |
+
import yaml
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
import matplotlib
|
| 20 |
+
|
| 21 |
+
matplotlib.use("Agg")
|
| 22 |
+
import matplotlib.pyplot as plt
|
| 23 |
+
from matplotlib.colors import Normalize, TwoSlopeNorm
|
| 24 |
+
except ImportError as exc: # pragma: no cover
|
| 25 |
+
raise RuntimeError(
|
| 26 |
+
"scripts/result.py requires matplotlib for scientific figures"
|
| 27 |
+
) from exc
|
| 28 |
+
|
| 29 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 30 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 31 |
+
|
| 32 |
+
from scripts.data_loader import read_metadata, resolve_data_dir
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def parse_args() -> argparse.Namespace:
|
| 36 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 37 |
+
parser.add_argument("--config", default=str(PROJECT_ROOT / "conf/config.yaml"))
|
| 38 |
+
parser.add_argument("--data-dir")
|
| 39 |
+
parser.add_argument("--output-dir", default=str(PROJECT_ROOT / "result/output"))
|
| 40 |
+
parser.add_argument("--plot-count", type=int, default=3, help="Initialization times to visualize")
|
| 41 |
+
parser.add_argument("--plot-leads", type=int, default=3, help="Lead times per initialization to visualize")
|
| 42 |
+
parser.add_argument("--dpi", type=int, default=160)
|
| 43 |
+
return parser.parse_args()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _pretty_name(name: str) -> str:
|
| 47 |
+
replacements = {
|
| 48 |
+
"geopotential_": "Geopotential ",
|
| 49 |
+
"temperature_": "Temperature ",
|
| 50 |
+
"specific_humidity_": "Specific humidity ",
|
| 51 |
+
"2m_temperature": "2 m temperature",
|
| 52 |
+
"total_precipitation": "Total precipitation",
|
| 53 |
+
"mean_sea_level_pressure": "Mean sea-level pressure",
|
| 54 |
+
"10m_u_component_of_wind": "10 m U wind",
|
| 55 |
+
"10m_v_component_of_wind": "10 m V wind",
|
| 56 |
+
"100m_u_component_of_wind": "100 m U wind",
|
| 57 |
+
"100m_v_component_of_wind": "100 m V wind",
|
| 58 |
+
"u_component_of_wind_": "U wind ",
|
| 59 |
+
"v_component_of_wind_": "V wind ",
|
| 60 |
+
"outgoing_longwave_radiation": "Outgoing longwave radiation",
|
| 61 |
+
"sea_surface_temperature": "Sea-surface temperature",
|
| 62 |
+
"significant_wave_height": "Significant wave height",
|
| 63 |
+
"mean_wave_direction": "Mean wave direction",
|
| 64 |
+
"mean_wave_period": "Mean wave period",
|
| 65 |
+
"soil_moisture_0_7cm": "Soil moisture 0-7 cm",
|
| 66 |
+
}
|
| 67 |
+
for prefix, replacement in replacements.items():
|
| 68 |
+
if name.startswith(prefix):
|
| 69 |
+
suffix = name[len(prefix) :]
|
| 70 |
+
return f"{replacement}{suffix}" if suffix.isdigit() else replacement
|
| 71 |
+
return name.replace("_", " ").title()
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _safe_percentile(values: np.ndarray, percentile: float, default: float = 1.0) -> float:
|
| 75 |
+
finite = np.asarray(values, dtype=np.float64)
|
| 76 |
+
finite = finite[np.isfinite(finite)]
|
| 77 |
+
if finite.size == 0:
|
| 78 |
+
return float(default)
|
| 79 |
+
result = float(np.percentile(finite, percentile))
|
| 80 |
+
return result if np.isfinite(result) else float(default)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _absolute_limits(values: np.ndarray) -> Tuple[float, float]:
|
| 84 |
+
lo, hi = _safe_percentile(values, 2.0, -1.0), _safe_percentile(values, 98.0, 1.0)
|
| 85 |
+
if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo:
|
| 86 |
+
center = float(np.nanmean(values)) if np.isfinite(values).any() else 0.0
|
| 87 |
+
span = max(abs(center) * 0.05, 1.0)
|
| 88 |
+
return center - span, center + span
|
| 89 |
+
return lo, hi
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _error_limit(values: np.ndarray) -> float:
|
| 93 |
+
limit = _safe_percentile(np.abs(values), 98.0, 1.0)
|
| 94 |
+
if not np.isfinite(limit) or limit <= 1.0e-8:
|
| 95 |
+
limit = max(_safe_percentile(np.abs(values), 100.0, 1.0), 1.0)
|
| 96 |
+
return float(limit)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _field(field: np.ndarray, lookup: Mapping[str, int], name: str) -> np.ndarray | None:
|
| 100 |
+
index = lookup.get(name)
|
| 101 |
+
return None if index is None else np.asarray(field[index], dtype=np.float32)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _diagnostics(channels: Sequence[str], prediction: np.ndarray, truth: np.ndarray) -> List[Dict[str, Any]]:
|
| 105 |
+
"""Create fields with meteorological meaning from a channel tensor."""
|
| 106 |
+
lookup = {name: index for index, name in enumerate(channels)}
|
| 107 |
+
diagnostics: List[Dict[str, Any]] = []
|
| 108 |
+
|
| 109 |
+
temperature_name = "2m_temperature" if "2m_temperature" in lookup else "temperature_500"
|
| 110 |
+
pred, true = _field(prediction, lookup, temperature_name), _field(truth, lookup, temperature_name)
|
| 111 |
+
if pred is not None and true is not None:
|
| 112 |
+
diagnostics.append({"key": "temperature", "title": _pretty_name(temperature_name), "unit": "data units", "prediction": pred, "truth": true, "cmap": "coolwarm"})
|
| 113 |
+
|
| 114 |
+
pred, true = _field(prediction, lookup, "geopotential_500"), _field(truth, lookup, "geopotential_500")
|
| 115 |
+
if pred is not None and true is not None:
|
| 116 |
+
diagnostics.append({"key": "geopotential", "title": "500 hPa geopotential", "unit": "data units", "prediction": pred, "truth": true, "cmap": "viridis"})
|
| 117 |
+
|
| 118 |
+
wind_pairs = (
|
| 119 |
+
("10m_u_component_of_wind", "10m_v_component_of_wind", "10 m wind speed"),
|
| 120 |
+
("u_component_of_wind_850", "v_component_of_wind_850", "850 hPa wind speed"),
|
| 121 |
+
)
|
| 122 |
+
for u_name, v_name, title in wind_pairs:
|
| 123 |
+
up, vp = _field(prediction, lookup, u_name), _field(prediction, lookup, v_name)
|
| 124 |
+
ut, vt = _field(truth, lookup, u_name), _field(truth, lookup, v_name)
|
| 125 |
+
if all(value is not None for value in (up, vp, ut, vt)):
|
| 126 |
+
diagnostics.append({"key": "wind_speed", "title": title, "unit": "data units", "prediction": np.hypot(up, vp), "truth": np.hypot(ut, vt), "cmap": "magma", "prediction_uv": (up, vp), "truth_uv": (ut, vt)})
|
| 127 |
+
break
|
| 128 |
+
|
| 129 |
+
pred, true = _field(prediction, lookup, "total_precipitation"), _field(truth, lookup, "total_precipitation")
|
| 130 |
+
if pred is not None and true is not None:
|
| 131 |
+
diagnostics.append({"key": "precipitation", "title": "Total precipitation", "unit": "data units", "prediction": pred, "truth": true, "cmap": "YlGnBu"})
|
| 132 |
+
|
| 133 |
+
if len(diagnostics) < 2:
|
| 134 |
+
used = {item["title"] for item in diagnostics}
|
| 135 |
+
for index, name in enumerate(channels):
|
| 136 |
+
if _pretty_name(name) in used:
|
| 137 |
+
continue
|
| 138 |
+
diagnostics.append({"key": f"channel_{index}", "title": _pretty_name(name), "unit": "data units", "prediction": prediction[index], "truth": truth[index], "cmap": "coolwarm"})
|
| 139 |
+
if len(diagnostics) >= 3:
|
| 140 |
+
break
|
| 141 |
+
return diagnostics
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _draw_grid(
|
| 145 |
+
ax: Any,
|
| 146 |
+
field: np.ndarray,
|
| 147 |
+
title: str,
|
| 148 |
+
cmap: str,
|
| 149 |
+
unit: str,
|
| 150 |
+
error: bool = False,
|
| 151 |
+
overlay: Tuple[np.ndarray, np.ndarray] | None = None,
|
| 152 |
+
limits: Tuple[float, float] | None = None,
|
| 153 |
+
) -> None:
|
| 154 |
+
"""Draw a regular global latitude/longitude panel without cartopy."""
|
| 155 |
+
height, width = field.shape
|
| 156 |
+
longitude = np.linspace(0.0, 360.0, width, endpoint=False)
|
| 157 |
+
latitude = np.linspace(-90.0, 90.0, height)
|
| 158 |
+
if error:
|
| 159 |
+
limit = _error_limit(field) if limits is None else max(abs(float(limits[0])), abs(float(limits[1])))
|
| 160 |
+
norm = TwoSlopeNorm(vmin=-limit, vcenter=0.0, vmax=limit)
|
| 161 |
+
image = ax.imshow(field, extent=(0, 360, -90, 90), origin="lower", aspect="auto", cmap="RdBu_r", norm=norm, interpolation="nearest")
|
| 162 |
+
else:
|
| 163 |
+
lo, hi = _absolute_limits(field) if limits is None else limits
|
| 164 |
+
image = ax.imshow(field, extent=(0, 360, -90, 90), origin="lower", aspect="auto", cmap=cmap, norm=Normalize(vmin=lo, vmax=hi), interpolation="nearest")
|
| 165 |
+
ax.set_title(title, fontsize=10, pad=5)
|
| 166 |
+
ax.set_xlim(0, 360)
|
| 167 |
+
ax.set_ylim(-90, 90)
|
| 168 |
+
ax.set_xticks((0, 60, 120, 180, 240, 300, 360))
|
| 169 |
+
ax.set_yticks((-60, -30, 0, 30, 60))
|
| 170 |
+
ax.set_xlabel("Longitude (deg)", fontsize=8)
|
| 171 |
+
ax.set_ylabel("Latitude (deg)", fontsize=8)
|
| 172 |
+
ax.tick_params(labelsize=8)
|
| 173 |
+
ax.grid(color="white", alpha=0.35, linewidth=0.45)
|
| 174 |
+
ax.axhline(0, color="black", alpha=0.28, linewidth=0.55)
|
| 175 |
+
ax.axvline(180, color="black", alpha=0.28, linewidth=0.55)
|
| 176 |
+
if overlay is not None and not error:
|
| 177 |
+
u, v = overlay
|
| 178 |
+
step_y, step_x = max(1, height // 12), max(1, width // 24)
|
| 179 |
+
speed = np.hypot(u, v)
|
| 180 |
+
scale = max(_safe_percentile(speed, 95, 1.0) * 12, 1.0)
|
| 181 |
+
quiver = ax.quiver(longitude[::step_x], latitude[::step_y], u[::step_y, ::step_x], v[::step_y, ::step_x], color="black", alpha=0.68, scale=scale, width=0.0022, headwidth=3.5, minlength=0.1)
|
| 182 |
+
ax.quiverkey(quiver, 0.86, -0.16, _safe_percentile(speed, 75, 1.0), "75th pct", labelpos="E", coordinates="axes", fontproperties={"size": 7})
|
| 183 |
+
colorbar = ax.figure.colorbar(image, ax=ax, fraction=0.045, pad=0.025)
|
| 184 |
+
colorbar.ax.tick_params(labelsize=7)
|
| 185 |
+
colorbar.set_label(("Error (" + unit + ")") if error else unit, fontsize=8)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _plot_overview(path: Path, source: str, record: Mapping[str, Any], channels: Sequence[str], prediction: np.ndarray, truth: np.ndarray, dpi: int) -> None:
|
| 189 |
+
diagnostics = _diagnostics(channels, prediction, truth)
|
| 190 |
+
lead = int(record["lead"])
|
| 191 |
+
time_step = int(record.get("time_step", 6))
|
| 192 |
+
lead_hours = (lead + 1) * time_step
|
| 193 |
+
fig, axes = plt.subplots(len(diagnostics), 3, figsize=(16.5, max(4.0 * len(diagnostics), 8.0)), squeeze=False, facecolor="#f5f7fa")
|
| 194 |
+
for row, diagnostic in enumerate(diagnostics):
|
| 195 |
+
pred, true = diagnostic["prediction"], diagnostic["truth"]
|
| 196 |
+
error = pred - true
|
| 197 |
+
limits = _absolute_limits(np.concatenate((pred.reshape(-1), true.reshape(-1))))
|
| 198 |
+
_draw_grid(axes[row, 0], pred, f"{diagnostic['title']} | forecast", diagnostic["cmap"], diagnostic["unit"], overlay=diagnostic.get("prediction_uv"), limits=limits)
|
| 199 |
+
_draw_grid(axes[row, 1], true, f"{diagnostic['title']} | verifying field", diagnostic["cmap"], diagnostic["unit"], overlay=diagnostic.get("truth_uv"), limits=limits)
|
| 200 |
+
_draw_grid(axes[row, 2], error, f"{diagnostic['title']} | forecast error", diagnostic["cmap"], diagnostic["unit"], error=True)
|
| 201 |
+
axes[row, 2].text(0.02, 0.03, f"RMSE {np.sqrt(np.nanmean(error * error)):.3g} bias {np.nanmean(error):+.3g}", transform=axes[row, 2].transAxes, fontsize=8, color="black", bbox={"facecolor": "white", "alpha": 0.82, "edgecolor": "none", "pad": 2.5})
|
| 202 |
+
valid = str(record["valid_timestamp"])
|
| 203 |
+
analysis_time = datetime.strptime(source, "%Y%m%d%H") - timedelta(hours=time_step)
|
| 204 |
+
analysis = analysis_time.strftime("%Y%m%d%H")
|
| 205 |
+
fig.suptitle(f"FengWu-W2S global forecast diagnostics\nInit {analysis[:8]} {analysis[8:]} UTC | Valid {valid[:8]} {valid[8:]} UTC | F{lead_hours:03d}", fontsize=15, fontweight="bold", y=0.995)
|
| 206 |
+
fig.subplots_adjust(top=0.93, left=0.04, right=0.98, bottom=0.045, hspace=0.56, wspace=0.20)
|
| 207 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 208 |
+
fig.savefig(path, dpi=dpi, facecolor=fig.get_facecolor())
|
| 209 |
+
plt.close(fig)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def _plot_lead_summary(path: Path, lead_metrics: Sequence[Mapping[str, Any]], dpi: int) -> None:
|
| 213 |
+
if not lead_metrics:
|
| 214 |
+
return
|
| 215 |
+
leads = np.asarray([item["lead_hours"] for item in lead_metrics], dtype=float)
|
| 216 |
+
nrmse = np.asarray([item["mean_normalized_rmse"] for item in lead_metrics], dtype=float)
|
| 217 |
+
acc = np.asarray([item["mean_acc"] for item in lead_metrics], dtype=float)
|
| 218 |
+
fig, axes = plt.subplots(1, 2, figsize=(14, 4.8), facecolor="#f5f7fa")
|
| 219 |
+
for ax in axes:
|
| 220 |
+
ax.set_facecolor("white")
|
| 221 |
+
ax.grid(alpha=0.25, linewidth=0.7)
|
| 222 |
+
ax.set_xlabel("Forecast lead (hours)")
|
| 223 |
+
axes[0].plot(leads, nrmse, color="#b43f3f", marker="o", linewidth=2.2, markersize=5)
|
| 224 |
+
axes[0].fill_between(leads, 0, nrmse, color="#e8a1a1", alpha=0.28)
|
| 225 |
+
axes[0].set_title("Normalized error growth", fontweight="bold")
|
| 226 |
+
axes[0].set_ylabel("Mean RMSE / channel standard deviation")
|
| 227 |
+
axes[0].set_ylim(bottom=0)
|
| 228 |
+
axes[1].plot(leads, acc, color="#20639b", marker="o", linewidth=2.2, markersize=5)
|
| 229 |
+
axes[1].axhline(0, color="#555", linewidth=0.8)
|
| 230 |
+
axes[1].set_title("Anomaly correlation by lead", fontweight="bold")
|
| 231 |
+
axes[1].set_ylabel("Mean ACC")
|
| 232 |
+
axes[1].set_ylim(-1, 1)
|
| 233 |
+
fig.suptitle("FengWu-W2S forecast skill across available leads", fontsize=14, fontweight="bold")
|
| 234 |
+
fig.tight_layout(rect=(0, 0, 1, 0.93))
|
| 235 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 236 |
+
fig.savefig(path, dpi=dpi, facecolor=fig.get_facecolor())
|
| 237 |
+
plt.close(fig)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _plot_channel_summary(path: Path, channels: Sequence[str], rmse: np.ndarray, normalized_rmse: np.ndarray, acc: np.ndarray, dpi: int) -> None:
|
| 241 |
+
count = min(12, len(channels))
|
| 242 |
+
ranking = np.argsort(normalized_rmse)[::-1][:count]
|
| 243 |
+
labels = [_pretty_name(channels[index]) for index in ranking][::-1]
|
| 244 |
+
values, acc_values = normalized_rmse[ranking][::-1], acc[ranking][::-1]
|
| 245 |
+
fig, axes = plt.subplots(1, 2, figsize=(15, max(5.5, count * 0.46)), facecolor="#f5f7fa")
|
| 246 |
+
for ax in axes:
|
| 247 |
+
ax.set_facecolor("white")
|
| 248 |
+
ax.grid(axis="x", alpha=0.25)
|
| 249 |
+
axes[0].barh(labels, values, color="#d46a6a", alpha=0.9)
|
| 250 |
+
axes[0].set_title("Largest normalized RMSE", fontweight="bold")
|
| 251 |
+
axes[0].set_xlabel("RMSE / channel standard deviation")
|
| 252 |
+
axes[1].barh(labels, acc_values, color="#4b88b8", alpha=0.9)
|
| 253 |
+
axes[1].set_title("ACC for the same channels", fontweight="bold")
|
| 254 |
+
axes[1].set_xlabel("Anomaly correlation coefficient")
|
| 255 |
+
axes[1].set_xlim(-1, 1)
|
| 256 |
+
fig.suptitle("Channel-level forecast diagnostics", fontsize=14, fontweight="bold")
|
| 257 |
+
fig.tight_layout(rect=(0, 0, 1, 0.94))
|
| 258 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 259 |
+
fig.savefig(path, dpi=dpi, facecolor=fig.get_facecolor())
|
| 260 |
+
plt.close(fig)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _plot_training_history(path: Path, train: np.ndarray, valid: np.ndarray, dpi: int) -> None:
|
| 264 |
+
if train.size == 0 and valid.size == 0:
|
| 265 |
+
return
|
| 266 |
+
fig, ax = plt.subplots(figsize=(9.5, 4.8), facecolor="#f5f7fa")
|
| 267 |
+
ax.set_facecolor("white")
|
| 268 |
+
if train.size:
|
| 269 |
+
ax.plot(np.arange(1, train.size + 1), train, marker="o", color="#2166ac", linewidth=2, label="Training")
|
| 270 |
+
if valid.size:
|
| 271 |
+
ax.plot(np.arange(1, valid.size + 1), valid, marker="o", color="#b2182b", linewidth=2, label="Validation")
|
| 272 |
+
ax.set_xlabel("Epoch")
|
| 273 |
+
ax.set_ylabel("Reported non-negative probability loss")
|
| 274 |
+
ax.set_title("FengWu-W2S optimization history", fontweight="bold")
|
| 275 |
+
ax.grid(alpha=0.28)
|
| 276 |
+
ax.legend(frameon=False)
|
| 277 |
+
fig.tight_layout()
|
| 278 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 279 |
+
fig.savefig(path, dpi=dpi, facecolor=fig.get_facecolor())
|
| 280 |
+
plt.close(fig)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _truth_for_timestamp(data_dir: Path, timestamp: str, indices: np.ndarray, time_step: int) -> np.ndarray:
|
| 284 |
+
dt = datetime.strptime(timestamp, "%Y%m%d%H")
|
| 285 |
+
index = int((dt - datetime(dt.year, 1, 1)).total_seconds() // (3600 * time_step))
|
| 286 |
+
path = data_dir / "data" / f"{dt.year}.h5"
|
| 287 |
+
with h5py.File(path, "r") as source:
|
| 288 |
+
fields = source["fields"]
|
| 289 |
+
if index < 0 or index >= fields.shape[0]:
|
| 290 |
+
raise IndexError(f"Timestamp {timestamp} maps to index {index}, outside {path}")
|
| 291 |
+
return np.asarray(fields[index, indices, :, :], dtype=np.float32)
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _load_prediction(output_dir: Path, record: Mapping[str, Any]) -> np.ndarray:
|
| 295 |
+
path = Path(record["path"])
|
| 296 |
+
if not path.is_absolute():
|
| 297 |
+
path = output_dir / path
|
| 298 |
+
if not path.exists():
|
| 299 |
+
raise FileNotFoundError(f"Forecast field listed in index.json does not exist: {path}")
|
| 300 |
+
field = np.load(path).astype(np.float32)
|
| 301 |
+
if field.ndim != 3:
|
| 302 |
+
raise ValueError(f"Forecast field must have [channel, lat, lon] shape, got {field.shape}")
|
| 303 |
+
return field
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def _selected_records(records: Sequence[Mapping[str, Any]], plot_count: int, plot_leads: int) -> List[Mapping[str, Any]]:
|
| 307 |
+
grouped: Dict[str, List[Mapping[str, Any]]] = defaultdict(list)
|
| 308 |
+
for record in records:
|
| 309 |
+
grouped[str(record["source_timestamp"])].append(record)
|
| 310 |
+
selected: List[Mapping[str, Any]] = []
|
| 311 |
+
for source in sorted(grouped)[: max(0, int(plot_count))]:
|
| 312 |
+
source_records = sorted(grouped[source], key=lambda item: int(item["lead"]))
|
| 313 |
+
number = max(1, min(int(plot_leads), len(source_records)))
|
| 314 |
+
positions = np.linspace(0, len(source_records) - 1, number, dtype=int)
|
| 315 |
+
selected.extend(source_records[int(position)] for position in np.unique(positions))
|
| 316 |
+
return selected
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def main() -> None:
|
| 320 |
+
args = parse_args()
|
| 321 |
+
with Path(args.config).open(encoding="utf-8") as source:
|
| 322 |
+
config = yaml.safe_load(source)
|
| 323 |
+
data_cfg = config["data"]
|
| 324 |
+
data_dir = resolve_data_dir(args.data_dir or data_cfg["data_dir"], PROJECT_ROOT)
|
| 325 |
+
output_dir = Path(args.output_dir).expanduser()
|
| 326 |
+
if not output_dir.is_absolute():
|
| 327 |
+
output_dir = (PROJECT_ROOT / output_dir).resolve()
|
| 328 |
+
index_path = output_dir / "index.json"
|
| 329 |
+
if not index_path.exists():
|
| 330 |
+
raise FileNotFoundError(f"Forecast index not found: {index_path}; run scripts/inference.py first")
|
| 331 |
+
records = json.loads(index_path.read_text(encoding="utf-8"))
|
| 332 |
+
if not isinstance(records, list):
|
| 333 |
+
raise ValueError("Forecast index must contain a list")
|
| 334 |
+
channels = list(data_cfg["channels"])
|
| 335 |
+
metadata = read_metadata(data_dir, channels)
|
| 336 |
+
indices, time_step = metadata["indices"], int(metadata["time_step"])
|
| 337 |
+
means = np.asarray(metadata["means"], dtype=np.float64).reshape(-1)
|
| 338 |
+
stds = np.asarray(metadata["stds"], dtype=np.float64).reshape(-1)
|
| 339 |
+
sum_squared = np.zeros(len(channels), dtype=np.float64)
|
| 340 |
+
sum_pred_anom_sq = np.zeros(len(channels), dtype=np.float64)
|
| 341 |
+
sum_truth_anom_sq = np.zeros(len(channels), dtype=np.float64)
|
| 342 |
+
sum_cross = np.zeros(len(channels), dtype=np.float64)
|
| 343 |
+
lead_sum_squared = defaultdict(lambda: np.zeros(len(channels), dtype=np.float64))
|
| 344 |
+
lead_sum_pred_anom_sq = defaultdict(lambda: np.zeros(len(channels), dtype=np.float64))
|
| 345 |
+
lead_sum_truth_anom_sq = defaultdict(lambda: np.zeros(len(channels), dtype=np.float64))
|
| 346 |
+
lead_sum_cross = defaultdict(lambda: np.zeros(len(channels), dtype=np.float64))
|
| 347 |
+
lead_counts: Dict[int, int] = defaultdict(int)
|
| 348 |
+
count = 0
|
| 349 |
+
for record in records:
|
| 350 |
+
prediction = _load_prediction(output_dir, record)
|
| 351 |
+
truth = _truth_for_timestamp(data_dir, str(record["valid_timestamp"]), indices, time_step)
|
| 352 |
+
if prediction.shape != truth.shape:
|
| 353 |
+
raise ValueError(f"Prediction/truth shape mismatch: {prediction.shape} vs {truth.shape}")
|
| 354 |
+
if not np.isfinite(prediction).all() or not np.isfinite(truth).all():
|
| 355 |
+
raise ValueError(f"Non-finite forecast or truth field for {record}")
|
| 356 |
+
error = prediction - truth
|
| 357 |
+
pred_anom, truth_anom = prediction - means[:, None, None], truth - means[:, None, None]
|
| 358 |
+
pixels = int(error.shape[1] * error.shape[2])
|
| 359 |
+
sum_squared += np.sum(error * error, axis=(1, 2))
|
| 360 |
+
sum_pred_anom_sq += np.sum(pred_anom * pred_anom, axis=(1, 2))
|
| 361 |
+
sum_truth_anom_sq += np.sum(truth_anom * truth_anom, axis=(1, 2))
|
| 362 |
+
sum_cross += np.sum(pred_anom * truth_anom, axis=(1, 2))
|
| 363 |
+
lead = int(record["lead"])
|
| 364 |
+
lead_sum_squared[lead] += np.sum(error * error, axis=(1, 2))
|
| 365 |
+
lead_sum_pred_anom_sq[lead] += np.sum(pred_anom * pred_anom, axis=(1, 2))
|
| 366 |
+
lead_sum_truth_anom_sq[lead] += np.sum(truth_anom * truth_anom, axis=(1, 2))
|
| 367 |
+
lead_sum_cross[lead] += np.sum(pred_anom * truth_anom, axis=(1, 2))
|
| 368 |
+
lead_counts[lead] += pixels
|
| 369 |
+
count += pixels
|
| 370 |
+
rmse = np.sqrt(sum_squared / max(1, count))
|
| 371 |
+
acc = sum_cross / (np.sqrt(sum_pred_anom_sq * sum_truth_anom_sq) + 1.0e-8)
|
| 372 |
+
normalized_rmse = rmse / np.maximum(stds, 1.0e-8)
|
| 373 |
+
lead_metrics: List[Dict[str, Any]] = []
|
| 374 |
+
pixels_per_field = int(metadata["shape"][-2] * metadata["shape"][-1])
|
| 375 |
+
for lead in sorted(lead_counts):
|
| 376 |
+
denominator = max(1, lead_counts[lead])
|
| 377 |
+
lead_rmse = np.sqrt(lead_sum_squared[lead] / denominator)
|
| 378 |
+
lead_acc = lead_sum_cross[lead] / (np.sqrt(lead_sum_pred_anom_sq[lead] * lead_sum_truth_anom_sq[lead]) + 1.0e-8)
|
| 379 |
+
lead_metrics.append({"lead": lead, "lead_hours": (lead + 1) * time_step, "mean_rmse": float(np.mean(lead_rmse)), "mean_normalized_rmse": float(np.mean(lead_rmse / np.maximum(stds, 1.0e-8))), "mean_acc": float(np.mean(lead_acc)), "records": int(lead_counts[lead] // max(1, pixels_per_field))})
|
| 380 |
+
result_root, plots_dir = output_dir.parent, output_dir.parent / "plots"
|
| 381 |
+
plots_dir.mkdir(parents=True, exist_ok=True)
|
| 382 |
+
for old_plot in plots_dir.glob("*.png"):
|
| 383 |
+
old_plot.unlink()
|
| 384 |
+
metrics = {"count": len(records), "channels": channels, "rmse": rmse.tolist(), "normalized_rmse": normalized_rmse.tolist(), "acc": acc.tolist(), "mean_rmse": float(np.mean(rmse)), "mean_normalized_rmse": float(np.mean(normalized_rmse)), "mean_acc": float(np.mean(acc)), "lead_metrics": lead_metrics, "visualization": {"domain": "meteorology", "coordinate_convention": "regular grid, longitude 0-360 degrees, latitude -90 to 90 degrees", "figures": ["plots/*_overview.png", "plots/lead_skill.png", "plots/channel_skill.png", "plots/training_history.png"]}}
|
| 385 |
+
(result_root / "metrics.json").write_text(json.dumps(metrics, indent=2), encoding="utf-8")
|
| 386 |
+
np.save(result_root / "rmse.npy", rmse.astype(np.float32))
|
| 387 |
+
np.save(result_root / "acc.npy", acc.astype(np.float32))
|
| 388 |
+
np.save(result_root / "normalized_rmse.npy", normalized_rmse.astype(np.float32))
|
| 389 |
+
for record in _selected_records(records, args.plot_count, args.plot_leads):
|
| 390 |
+
prediction = _load_prediction(output_dir, record)
|
| 391 |
+
truth = _truth_for_timestamp(data_dir, str(record["valid_timestamp"]), indices, time_step)
|
| 392 |
+
source, lead = str(record["source_timestamp"]), int(record["lead"])
|
| 393 |
+
_plot_overview(plots_dir / f"{source}_lead{lead:03d}_overview.png", source, {**record, "time_step": time_step}, channels, prediction, truth, max(80, int(args.dpi)))
|
| 394 |
+
_plot_lead_summary(plots_dir / "lead_skill.png", lead_metrics, max(80, int(args.dpi)))
|
| 395 |
+
_plot_channel_summary(plots_dir / "channel_skill.png", channels, rmse, normalized_rmse, acc, max(80, int(args.dpi)))
|
| 396 |
+
checkpoint_dir = PROJECT_ROOT / "data" / "checkpoints"
|
| 397 |
+
train_path, valid_path = checkpoint_dir / "trloss.npy", checkpoint_dir / "valoss.npy"
|
| 398 |
+
if train_path.exists() and valid_path.exists():
|
| 399 |
+
_plot_training_history(plots_dir / "training_history.png", np.load(train_path), np.load(valid_path), max(80, int(args.dpi)))
|
| 400 |
+
print(json.dumps(metrics, indent=2))
|
| 401 |
+
print(f"Saved scientific diagnostics under {plots_dir}")
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
if __name__ == "__main__":
|
| 405 |
+
main()
|
scripts/train.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Train FengWu-W2S on ERA5-style HDF5 windows (single or torchrun DDP)."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import json
|
| 8 |
+
import math
|
| 9 |
+
import os
|
| 10 |
+
import random
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any, Dict
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import torch
|
| 17 |
+
import yaml
|
| 18 |
+
from torch import nn
|
| 19 |
+
from torch.nn.parallel import DistributedDataParallel as DDP
|
| 20 |
+
|
| 21 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 22 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 23 |
+
|
| 24 |
+
from model.fengwu_w2s import FengWuW2S
|
| 25 |
+
from scripts.data_loader import make_dataloader, resolve_data_dir
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def parse_args() -> argparse.Namespace:
|
| 29 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 30 |
+
parser.add_argument("--config", default=str(PROJECT_ROOT / "conf/config.yaml"))
|
| 31 |
+
parser.add_argument("--data-dir")
|
| 32 |
+
parser.add_argument("--checkpoint")
|
| 33 |
+
parser.add_argument("--max-epoch", type=int)
|
| 34 |
+
parser.add_argument("--rollout-steps", type=int)
|
| 35 |
+
parser.add_argument("--batch-size", type=int)
|
| 36 |
+
parser.add_argument("--num-workers", type=int)
|
| 37 |
+
parser.add_argument("--device", choices=("auto", "cpu", "cuda"), help="Execution device; auto prefers DCU/GPU")
|
| 38 |
+
parser.add_argument("--finetune", action="store_true", help="Resume with the lower finetune learning rate")
|
| 39 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 40 |
+
return parser.parse_args()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _device(config: Dict[str, Any], local_rank: int, override: str | None = None) -> torch.device:
|
| 44 |
+
requested = str(override or config.get("runtime", {}).get("device", "auto")).lower()
|
| 45 |
+
if requested not in {"auto", "cpu", "cuda"}:
|
| 46 |
+
raise ValueError(f"unsupported device {requested!r}; expected auto, cpu, or cuda")
|
| 47 |
+
cuda_available = bool(torch.cuda.is_available())
|
| 48 |
+
cuda_count = int(torch.cuda.device_count()) if cuda_available else 0
|
| 49 |
+
if requested == "cpu":
|
| 50 |
+
return torch.device("cpu")
|
| 51 |
+
if not cuda_available or cuda_count < 1:
|
| 52 |
+
if requested == "cuda":
|
| 53 |
+
raise RuntimeError(
|
| 54 |
+
"CUDA/DCU was explicitly requested but torch.cuda is unavailable; "
|
| 55 |
+
"load the accelerator module before starting training"
|
| 56 |
+
)
|
| 57 |
+
return torch.device("cpu")
|
| 58 |
+
index = local_rank
|
| 59 |
+
if index >= cuda_count:
|
| 60 |
+
raise RuntimeError(f"local rank {index} exceeds visible accelerator count {cuda_count}")
|
| 61 |
+
torch.cuda.set_device(index)
|
| 62 |
+
return torch.device("cuda", index)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _distributed_context() -> tuple[bool, int, int]:
|
| 66 |
+
world_size = int(os.environ.get("WORLD_SIZE", "1"))
|
| 67 |
+
distributed = world_size > 1
|
| 68 |
+
rank = int(os.environ.get("RANK", "0"))
|
| 69 |
+
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
|
| 70 |
+
return distributed, rank, local_rank
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _model_from_config(config: Dict[str, Any]) -> FengWuW2S:
|
| 74 |
+
model_cfg = config["model"]
|
| 75 |
+
data_cfg = config["data"]
|
| 76 |
+
groups = {name: tuple(values) for name, values in data_cfg["groups"].items()}
|
| 77 |
+
return FengWuW2S(
|
| 78 |
+
in_channels=len(data_cfg["channels"]),
|
| 79 |
+
group_indices=groups,
|
| 80 |
+
hidden_channels=int(model_cfg.get("hidden_channels", 32)),
|
| 81 |
+
latent_channels=int(model_cfg.get("latent_channels", 64)),
|
| 82 |
+
patch_size=int(model_cfg.get("patch_size", 4)),
|
| 83 |
+
num_blocks=int(model_cfg.get("num_blocks", 2)),
|
| 84 |
+
perturbation_scale=float(model_cfg.get("perturbation_scale", 0.01)),
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _load_checkpoint(path: Path, model: nn.Module, optimizer: torch.optim.Optimizer | None, device: torch.device) -> Dict[str, Any]:
|
| 89 |
+
state = torch.load(path, map_location=device, weights_only=False)
|
| 90 |
+
model.load_state_dict(state["model_state_dict"])
|
| 91 |
+
if optimizer is not None and "optimizer_state_dict" in state:
|
| 92 |
+
optimizer.load_state_dict(state["optimizer_state_dict"])
|
| 93 |
+
return state
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _epoch(
|
| 97 |
+
model: nn.Module,
|
| 98 |
+
loader,
|
| 99 |
+
device: torch.device,
|
| 100 |
+
optimizer: torch.optim.Optimizer | None,
|
| 101 |
+
rollout_steps: int,
|
| 102 |
+
distributed: bool,
|
| 103 |
+
probability_loss: bool,
|
| 104 |
+
kl_weight: float,
|
| 105 |
+
perturbation_enabled: bool,
|
| 106 |
+
) -> float:
|
| 107 |
+
training = optimizer is not None
|
| 108 |
+
model.train(training)
|
| 109 |
+
total = 0.0
|
| 110 |
+
batches = 0
|
| 111 |
+
for inputs, targets, _ in loader:
|
| 112 |
+
with torch.set_grad_enabled(training):
|
| 113 |
+
state = inputs[:, -1].to(device=device, dtype=torch.float32, non_blocking=True)
|
| 114 |
+
targets = targets.to(device=device, dtype=torch.float32, non_blocking=True)
|
| 115 |
+
if optimizer is not None:
|
| 116 |
+
optimizer.zero_grad(set_to_none=True)
|
| 117 |
+
loss = state.new_zeros(())
|
| 118 |
+
steps = max(1, min(int(rollout_steps), targets.shape[1]))
|
| 119 |
+
for step in range(steps):
|
| 120 |
+
prediction, aux = model(
|
| 121 |
+
state,
|
| 122 |
+
stochastic=training and perturbation_enabled,
|
| 123 |
+
return_aux=True,
|
| 124 |
+
)
|
| 125 |
+
error = prediction - targets[:, step]
|
| 126 |
+
if probability_loss:
|
| 127 |
+
min_log_std = -6.0
|
| 128 |
+
log_std = aux["log_std"].clamp(min_log_std, 2.0)
|
| 129 |
+
# The offset is constant and therefore preserves Gaussian
|
| 130 |
+
# NLL gradients while making its logged value non-negative.
|
| 131 |
+
nll = (
|
| 132 |
+
0.5 * torch.exp(-2.0 * log_std) * error.square()
|
| 133 |
+
+ log_std
|
| 134 |
+
- min_log_std
|
| 135 |
+
)
|
| 136 |
+
loss = loss + torch.mean(nll)
|
| 137 |
+
if training and perturbation_enabled:
|
| 138 |
+
loss = loss + float(kl_weight) * aux["kl"]
|
| 139 |
+
else:
|
| 140 |
+
loss = loss + 0.8 * torch.mean(torch.abs(error)) + 0.2 * torch.mean(error.square())
|
| 141 |
+
state = prediction
|
| 142 |
+
loss = loss / steps
|
| 143 |
+
loss_value = float(loss.detach().cpu())
|
| 144 |
+
if not math.isfinite(loss_value) or loss_value < -1.0e-7:
|
| 145 |
+
raise RuntimeError(f"invalid loss {loss_value} on {device}")
|
| 146 |
+
if optimizer is not None:
|
| 147 |
+
loss.backward()
|
| 148 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
|
| 149 |
+
optimizer.step()
|
| 150 |
+
total += loss_value
|
| 151 |
+
batches += 1
|
| 152 |
+
if batches == 0:
|
| 153 |
+
return float("nan")
|
| 154 |
+
value = torch.tensor(total / batches, device=device)
|
| 155 |
+
if distributed:
|
| 156 |
+
torch.distributed.all_reduce(value, op=torch.distributed.ReduceOp.SUM)
|
| 157 |
+
value = value / torch.distributed.get_world_size()
|
| 158 |
+
return float(value.cpu())
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def main() -> None:
|
| 162 |
+
args = parse_args()
|
| 163 |
+
with Path(args.config).open(encoding="utf-8") as source:
|
| 164 |
+
config = yaml.safe_load(source)
|
| 165 |
+
random.seed(args.seed)
|
| 166 |
+
np.random.seed(args.seed)
|
| 167 |
+
torch.manual_seed(args.seed)
|
| 168 |
+
distributed, rank, local_rank = _distributed_context()
|
| 169 |
+
device = _device(config, local_rank, args.device)
|
| 170 |
+
if distributed:
|
| 171 |
+
backend = "nccl" if device.type == "cuda" else "gloo"
|
| 172 |
+
torch.distributed.init_process_group(backend=backend, init_method="env://")
|
| 173 |
+
model_cfg = config["model"]
|
| 174 |
+
data_cfg = config["data"]
|
| 175 |
+
data_dir = resolve_data_dir(args.data_dir or data_cfg["data_dir"], PROJECT_ROOT)
|
| 176 |
+
input_steps = int(model_cfg.get("input_steps", 2))
|
| 177 |
+
rollout_steps = int(args.rollout_steps or model_cfg.get("rollout_steps", 1))
|
| 178 |
+
batch_size = int(args.batch_size or data_cfg.get("dataloader", {}).get("batch_size", 1))
|
| 179 |
+
num_workers = int(args.num_workers if args.num_workers is not None else data_cfg.get("dataloader", {}).get("num_workers", 0))
|
| 180 |
+
train_loader, train_sampler = make_dataloader(
|
| 181 |
+
data_dir, data_cfg["train_years"], data_cfg["channels"], input_steps, rollout_steps,
|
| 182 |
+
batch_size, num_workers, distributed, True, data_cfg.get("dataloader", {}).get("pin_memory", False),
|
| 183 |
+
)
|
| 184 |
+
val_loader, val_sampler = make_dataloader(
|
| 185 |
+
data_dir, data_cfg["val_years"], data_cfg["channels"], input_steps, rollout_steps,
|
| 186 |
+
batch_size, num_workers, distributed, False, data_cfg.get("dataloader", {}).get("pin_memory", False),
|
| 187 |
+
)
|
| 188 |
+
model = _model_from_config(config).to(device)
|
| 189 |
+
if not args.finetune:
|
| 190 |
+
for parameter in model.perturbation.parameters():
|
| 191 |
+
parameter.requires_grad_(False)
|
| 192 |
+
parameter_device = next(model.parameters()).device
|
| 193 |
+
if parameter_device != device:
|
| 194 |
+
raise RuntimeError(f"model parameters are on {parameter_device}, expected {device}")
|
| 195 |
+
learning_rate = float(model_cfg.get("learning_rate", 2e-4))
|
| 196 |
+
if args.finetune:
|
| 197 |
+
learning_rate = float(model_cfg.get("finetune_learning_rate", learning_rate * 0.1))
|
| 198 |
+
optimizer = torch.optim.AdamW(
|
| 199 |
+
model.parameters(),
|
| 200 |
+
lr=learning_rate,
|
| 201 |
+
betas=(
|
| 202 |
+
float(model_cfg.get("adam_beta1", 0.9)),
|
| 203 |
+
float(model_cfg.get("adam_beta2", 0.999)),
|
| 204 |
+
),
|
| 205 |
+
weight_decay=float(model_cfg.get("weight_decay", 1e-5)),
|
| 206 |
+
)
|
| 207 |
+
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="min", factor=0.5, patience=3)
|
| 208 |
+
checkpoint_dir = resolve_data_dir(model_cfg.get("checkpoint_dir", "./data/checkpoints"), PROJECT_ROOT)
|
| 209 |
+
checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
| 210 |
+
checkpoint_path = Path(args.checkpoint) if args.checkpoint else checkpoint_dir / "model_bak.pth"
|
| 211 |
+
if not checkpoint_path.is_absolute():
|
| 212 |
+
checkpoint_path = (PROJECT_ROOT / checkpoint_path).resolve()
|
| 213 |
+
start_epoch = 0
|
| 214 |
+
best_valid = float("inf")
|
| 215 |
+
train_losses: list[float] = []
|
| 216 |
+
valid_losses: list[float] = []
|
| 217 |
+
if args.finetune:
|
| 218 |
+
if not checkpoint_path.exists():
|
| 219 |
+
raise FileNotFoundError(
|
| 220 |
+
f"Finetune checkpoint not found: {checkpoint_path}; run base training first"
|
| 221 |
+
)
|
| 222 |
+
state = _load_checkpoint(checkpoint_path, model, optimizer, device)
|
| 223 |
+
start_epoch = int(state.get("epoch", -1)) + 1
|
| 224 |
+
best_valid = float(state.get("best_valid_loss", best_valid))
|
| 225 |
+
# Loading AdamW state also restores its previous learning rate; apply
|
| 226 |
+
# the explicit finetune rate after restoration.
|
| 227 |
+
for group in optimizer.param_groups:
|
| 228 |
+
group["lr"] = learning_rate
|
| 229 |
+
if distributed:
|
| 230 |
+
model = DDP(model, device_ids=[local_rank] if device.type == "cuda" else None)
|
| 231 |
+
requested_epochs = int(args.max_epoch or (model_cfg.get("finetune_epoch") if args.finetune else model_cfg.get("max_epoch", 1)))
|
| 232 |
+
# ``--max-epoch`` is the number of epochs for this invocation. A
|
| 233 |
+
# finetune run therefore always performs the requested extra epochs after
|
| 234 |
+
# the checkpoint epoch rather than silently skipping when it is already 1.
|
| 235 |
+
max_epoch = start_epoch + requested_epochs if args.finetune else requested_epochs
|
| 236 |
+
patience = int(model_cfg.get("patience", 10))
|
| 237 |
+
probability_loss = bool(model_cfg.get("probability_loss", True))
|
| 238 |
+
kl_weight = float(model_cfg.get("kl_weight", 1.0e-4))
|
| 239 |
+
stale = 0
|
| 240 |
+
if rank == 0:
|
| 241 |
+
cuda_state = {
|
| 242 |
+
"available": bool(torch.cuda.is_available()),
|
| 243 |
+
"count": int(torch.cuda.device_count()) if torch.cuda.is_available() else 0,
|
| 244 |
+
"name": torch.cuda.get_device_name(device) if device.type == "cuda" else None,
|
| 245 |
+
"allocated_bytes": torch.cuda.memory_allocated(device) if device.type == "cuda" else 0,
|
| 246 |
+
}
|
| 247 |
+
print(
|
| 248 |
+
f"FengWu-W2S training on {device}; model_device={parameter_device}; "
|
| 249 |
+
f"cuda={cuda_state}; samples={len(train_loader.dataset)}; rollout={rollout_steps}",
|
| 250 |
+
flush=True,
|
| 251 |
+
)
|
| 252 |
+
print(f"Parameters: {sum(parameter.numel() for parameter in model.parameters()):,}", flush=True)
|
| 253 |
+
for epoch in range(start_epoch, max_epoch):
|
| 254 |
+
if distributed:
|
| 255 |
+
train_sampler.set_epoch(epoch)
|
| 256 |
+
val_sampler.set_epoch(epoch)
|
| 257 |
+
train_loss = _epoch(
|
| 258 |
+
model, train_loader, device, optimizer, rollout_steps, distributed,
|
| 259 |
+
probability_loss, kl_weight, args.finetune,
|
| 260 |
+
)
|
| 261 |
+
valid_loss = _epoch(
|
| 262 |
+
model, val_loader, device, None, rollout_steps, distributed,
|
| 263 |
+
probability_loss, kl_weight, False,
|
| 264 |
+
)
|
| 265 |
+
scheduler.step(valid_loss)
|
| 266 |
+
train_losses.append(train_loss)
|
| 267 |
+
valid_losses.append(valid_loss)
|
| 268 |
+
improved = valid_loss < best_valid
|
| 269 |
+
if improved:
|
| 270 |
+
best_valid = valid_loss
|
| 271 |
+
stale = 0
|
| 272 |
+
else:
|
| 273 |
+
stale += 1
|
| 274 |
+
if rank == 0:
|
| 275 |
+
model_to_save = model.module if hasattr(model, "module") else model
|
| 276 |
+
state = {
|
| 277 |
+
"model_state_dict": model_to_save.state_dict(),
|
| 278 |
+
"optimizer_state_dict": optimizer.state_dict(),
|
| 279 |
+
"scheduler_state_dict": scheduler.state_dict(),
|
| 280 |
+
"epoch": epoch,
|
| 281 |
+
"best_valid_loss": best_valid,
|
| 282 |
+
"channels": data_cfg["channels"],
|
| 283 |
+
"group_indices": data_cfg["groups"],
|
| 284 |
+
"config": config,
|
| 285 |
+
}
|
| 286 |
+
if improved or epoch == max_epoch - 1:
|
| 287 |
+
torch.save(state, checkpoint_dir / "model_bak.pth")
|
| 288 |
+
np.save(checkpoint_dir / "trloss.npy", np.asarray(train_losses, dtype=np.float32))
|
| 289 |
+
np.save(checkpoint_dir / "valoss.npy", np.asarray(valid_losses, dtype=np.float32))
|
| 290 |
+
print(
|
| 291 |
+
f"Epoch {epoch + 1}/{max_epoch}: train={train_loss:.6f} valid={valid_loss:.6f}",
|
| 292 |
+
flush=True,
|
| 293 |
+
)
|
| 294 |
+
if stale > patience:
|
| 295 |
+
break
|
| 296 |
+
if distributed:
|
| 297 |
+
torch.distributed.barrier()
|
| 298 |
+
torch.distributed.destroy_process_group()
|
| 299 |
+
if rank == 0:
|
| 300 |
+
(checkpoint_dir / "training_summary.json").write_text(
|
| 301 |
+
json.dumps({"best_valid_loss": best_valid, "epochs": len(train_losses), "device": str(device)}, indent=2),
|
| 302 |
+
encoding="utf-8",
|
| 303 |
+
)
|
| 304 |
+
print(f"Saved checkpoint to {checkpoint_dir / 'model_bak.pth'}")
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
if __name__ == "__main__":
|
| 308 |
+
main()
|
weight/.gitkeep
ADDED
|
File without changes
|