Upload folder using huggingface_hub
Browse files- README.md +159 -0
- conf/config.yaml +141 -0
- config.json +97 -0
- configuration.json +1 -0
- model/era5_adapter.py +254 -0
- model/oneforecast.py +553 -0
- scripts/fake_data.py +102 -0
- scripts/finetune.py +131 -0
- scripts/inference.py +148 -0
- scripts/result.py +52 -0
- scripts/train.py +284 -0
- weight/.gitkeep +0 -0
README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
datasets:
|
| 3 |
+
- OneScience/ERA5
|
| 4 |
+
frameworks:
|
| 5 |
+
- PyTorch
|
| 6 |
+
language:
|
| 7 |
+
- en
|
| 8 |
+
- zh
|
| 9 |
+
license: mit
|
| 10 |
+
tags:
|
| 11 |
+
- OneScience
|
| 12 |
+
- Earth Science
|
| 13 |
+
- Weather Forecast
|
| 14 |
+
- Global Forecast
|
| 15 |
+
- ERA5
|
| 16 |
+
- OneForecast
|
| 17 |
+
tasks: []
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
<p align="center">
|
| 21 |
+
<strong>
|
| 22 |
+
<span style="font-size: 30px;">OneForecast</span>
|
| 23 |
+
</strong>
|
| 24 |
+
</p>
|
| 25 |
+
|
| 26 |
+
# Model Introduction
|
| 27 |
+
|
| 28 |
+
OneForecast was developed by the team of Prof. Xiaomeng Huang in the Department of Earth System Science at Tsinghua University, in collaboration with multiple institutions. The paper has been accepted by ICML 2025.
|
| 29 |
+
|
| 30 |
+
Paper: OneForecast: A Universal Framework for Global and Regional Weather Forecasting
|
| 31 |
+
|
| 32 |
+
https://arxiv.org/abs/2502.00338
|
| 33 |
+
|
| 34 |
+
# Model Description
|
| 35 |
+
|
| 36 |
+
OneForecast is a universal framework for nested global-regional weather forecasting based on graph neural networks (GNNs). Its core goal is to address the challenges of existing AI weather models in balancing low-resolution global forecasts with high-resolution regional forecasts, as well as issues such as over-smoothing in extreme event forecasting.
|
| 37 |
+
|
| 38 |
+
# Use Cases
|
| 39 |
+
|
| 40 |
+
| Scenario | Description |
|
| 41 |
+
| :---: | :--- |
|
| 42 |
+
| Global Weather Forecast Training | Train the single-step OneForecast model using ERA5 HDF5 data. |
|
| 43 |
+
| Local Quick Validation | Use synthetic data to verify the data protocol, model construction, training, inference, and result visualization. |
|
| 44 |
+
| Multi-GPU Training | Data-parallel training on multiple GPUs/DCUs via PyTorch DDP and `torchrun`. |
|
| 45 |
+
| ModelScope / OneCode Execution | Download as a standalone model package, install the OneScience dependencies, and run. |
|
| 46 |
+
|
| 47 |
+
# Usage Guide
|
| 48 |
+
|
| 49 |
+
## 1. OneCode Usage
|
| 50 |
+
|
| 51 |
+
Experience intelligent one-click AI4S programming through the OneCode online environment:
|
| 52 |
+
|
| 53 |
+
[Click to Experience Intelligent One-Click AI4S Programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
|
| 54 |
+
|
| 55 |
+
## 2. Manual Installation and Usage
|
| 56 |
+
|
| 57 |
+
**Hardware Requirements**
|
| 58 |
+
|
| 59 |
+
- A GPU or DCU is recommended.
|
| 60 |
+
- CPU can be used for import and small-scale connectivity verification; full training and inference will be slow.
|
| 61 |
+
- DCU users must install DTK in advance. DTK 25.04.2 or above, or the OneScience recommended version matching your cluster, is recommended.
|
| 62 |
+
|
| 63 |
+
### Download the Model Package
|
| 64 |
+
|
| 65 |
+
```bash
|
| 66 |
+
hf download OneScience-Group/OneForecast --local-dir ./OneForecast
|
| 67 |
+
cd OneForecast
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
### Install the Runtime Environment
|
| 71 |
+
|
| 72 |
+
**DCU Environment**
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
# Please activate DTK and CONDA first
|
| 76 |
+
conda create -n onescience311 python=3.11 -y
|
| 77 |
+
conda activate onescience311
|
| 78 |
+
# uv installation is supported
|
| 79 |
+
pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
**GPU Environment**
|
| 83 |
+
|
| 84 |
+
```bash
|
| 85 |
+
# Please activate CONDA first
|
| 86 |
+
conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
|
| 87 |
+
conda activate onescience311
|
| 88 |
+
# uv installation is supported
|
| 89 |
+
pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
### Training Data Introduction
|
| 93 |
+
|
| 94 |
+
The OneScience community provides ERA5 data for training (due to file size limits, the current repository contains a slice of the full dataset). Users can download it with the command below and confirm that the data path in `conf/config.yaml` is set correctly:
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
hf download --repo-type dataset OneScience-Group/ERA5 --local-dir ./data
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
### Generate Synthetic Data
|
| 101 |
+
|
| 102 |
+
Synthetic data is only used to verify the data protocol and program flow; it does not represent scientific forecast quality:
|
| 103 |
+
|
| 104 |
+
```bash
|
| 105 |
+
python scripts/fake_data.py
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
### Training
|
| 109 |
+
|
| 110 |
+
Single GPU:
|
| 111 |
+
|
| 112 |
+
```bash
|
| 113 |
+
python scripts/train.py
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
Training starts from random initialization by default and saves the model to `data/checkpoint/model_bak.tar`.
|
| 117 |
+
|
| 118 |
+
Multi-GPU:
|
| 119 |
+
|
| 120 |
+
```bash
|
| 121 |
+
torchrun --nproc-per-node=4 scripts/train.py
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
### Fine-tuning
|
| 125 |
+
|
| 126 |
+
Fine-tuning starts from the training checkpoint `data/checkpoint/model_bak.tar` by default and saves the result to `data/checkpoint/model_finetuned.tar`:
|
| 127 |
+
|
| 128 |
+
```bash
|
| 129 |
+
python scripts/finetune.py
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
### Training Weights
|
| 133 |
+
|
| 134 |
+
This repository provides weights trained on ERA5 reanalysis data in the `weights/` folder. The weight files will be uploaded soon and are expected to be available in the near future.
|
| 135 |
+
|
| 136 |
+
### Inference
|
| 137 |
+
|
| 138 |
+
Inference loads the training checkpoint `data/checkpoint/model_bak.tar` by default, uses the test-year data, and writes predictions to `outputs/predictions/`:
|
| 139 |
+
|
| 140 |
+
```bash
|
| 141 |
+
python scripts/inference.py --config conf/config.yaml
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
### Result Visualization
|
| 145 |
+
|
| 146 |
+
```bash
|
| 147 |
+
python scripts/result.py
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
# OneScience Official Information
|
| 151 |
+
|
| 152 |
+
| Platform | OneScience Main Repository | Skills Repository |
|
| 153 |
+
| --- | --- | --- |
|
| 154 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 155 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 156 |
+
|
| 157 |
+
# Citation & License
|
| 158 |
+
|
| 159 |
+
- This repository is a reproduction of the original OneForecast paper.
|
conf/config.yaml
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
model:
|
| 2 |
+
name: OneForecast
|
| 3 |
+
input_channels: 69
|
| 4 |
+
output_channels: 69
|
| 5 |
+
# Native ERA5 721x1440 is sampled every sixth point to 121x240, then cropped to 120x240.
|
| 6 |
+
grid_height: 120
|
| 7 |
+
grid_width: 240
|
| 8 |
+
dt_hours: 6
|
| 9 |
+
mesh_level: 5
|
| 10 |
+
processor_layers: 16
|
| 11 |
+
hidden_layers: 1
|
| 12 |
+
hidden_dim: 512
|
| 13 |
+
num_heads_edge: 4
|
| 14 |
+
num_heads_node: 4
|
| 15 |
+
weight_init: scratch
|
| 16 |
+
official_checkpoint_path: /root/private_data/workspaces/yangzt01/OneForecast/best_ckpt.tar
|
| 17 |
+
|
| 18 |
+
runtime:
|
| 19 |
+
seed: 42
|
| 20 |
+
device: dcu
|
| 21 |
+
output_dir: ./outputs
|
| 22 |
+
distributed_backend: nccl
|
| 23 |
+
|
| 24 |
+
training:
|
| 25 |
+
start_epoch: 0
|
| 26 |
+
max_epoch: 5
|
| 27 |
+
learning_rate: 0.00025
|
| 28 |
+
weight_decay: 0.0
|
| 29 |
+
checkpoint_dir: ./data/checkpoint
|
| 30 |
+
model_name: model_bak
|
| 31 |
+
save_every_epoch: 1
|
| 32 |
+
max_batches: 1
|
| 33 |
+
|
| 34 |
+
finetuning:
|
| 35 |
+
steps: 2
|
| 36 |
+
max_epoch: 2
|
| 37 |
+
learning_rate: 0.00025
|
| 38 |
+
max_batches: 1
|
| 39 |
+
model_source: trained
|
| 40 |
+
trained_model_path: ./data/checkpoint/model_bak.tar
|
| 41 |
+
official_checkpoint_path: /root/private_data/workspaces/yangzt01/OneForecast/best_ckpt.tar
|
| 42 |
+
output_path: ./data/checkpoint/model_finetuned.tar
|
| 43 |
+
|
| 44 |
+
datapipe:
|
| 45 |
+
name: ERA5
|
| 46 |
+
task: global_weather_forecasting
|
| 47 |
+
dataset_dir: ./data
|
| 48 |
+
train_years: [2000]
|
| 49 |
+
valid_years: [2001]
|
| 50 |
+
test_years: [2002]
|
| 51 |
+
input_steps: 1
|
| 52 |
+
output_steps: 1
|
| 53 |
+
normalize: true
|
| 54 |
+
batch_size: 1
|
| 55 |
+
num_workers: 0
|
| 56 |
+
# Runtime distribution is enabled automatically when launched with torchrun.
|
| 57 |
+
distributed: false
|
| 58 |
+
|
| 59 |
+
# This exact order matches the official OneForecast global dataset.
|
| 60 |
+
variables:
|
| 61 |
+
- Z50
|
| 62 |
+
- Z100
|
| 63 |
+
- Z150
|
| 64 |
+
- Z200
|
| 65 |
+
- Z250
|
| 66 |
+
- Z300
|
| 67 |
+
- Z400
|
| 68 |
+
- Z500
|
| 69 |
+
- Z600
|
| 70 |
+
- Z700
|
| 71 |
+
- Z850
|
| 72 |
+
- Z925
|
| 73 |
+
- Z1000
|
| 74 |
+
- Q50
|
| 75 |
+
- Q100
|
| 76 |
+
- Q150
|
| 77 |
+
- Q200
|
| 78 |
+
- Q250
|
| 79 |
+
- Q300
|
| 80 |
+
- Q400
|
| 81 |
+
- Q500
|
| 82 |
+
- Q600
|
| 83 |
+
- Q700
|
| 84 |
+
- Q850
|
| 85 |
+
- Q925
|
| 86 |
+
- Q1000
|
| 87 |
+
- T50
|
| 88 |
+
- T100
|
| 89 |
+
- T150
|
| 90 |
+
- T200
|
| 91 |
+
- T250
|
| 92 |
+
- T300
|
| 93 |
+
- T400
|
| 94 |
+
- T500
|
| 95 |
+
- T600
|
| 96 |
+
- T700
|
| 97 |
+
- T850
|
| 98 |
+
- T925
|
| 99 |
+
- T1000
|
| 100 |
+
- U50
|
| 101 |
+
- U100
|
| 102 |
+
- U150
|
| 103 |
+
- U200
|
| 104 |
+
- U250
|
| 105 |
+
- U300
|
| 106 |
+
- U400
|
| 107 |
+
- U500
|
| 108 |
+
- U600
|
| 109 |
+
- U700
|
| 110 |
+
- U850
|
| 111 |
+
- U925
|
| 112 |
+
- U1000
|
| 113 |
+
- V50
|
| 114 |
+
- V100
|
| 115 |
+
- V150
|
| 116 |
+
- V200
|
| 117 |
+
- V250
|
| 118 |
+
- V300
|
| 119 |
+
- V400
|
| 120 |
+
- V500
|
| 121 |
+
- V600
|
| 122 |
+
- V700
|
| 123 |
+
- V850
|
| 124 |
+
- V925
|
| 125 |
+
- V1000
|
| 126 |
+
- U10M
|
| 127 |
+
- V10M
|
| 128 |
+
- T2M
|
| 129 |
+
- MSLP
|
| 130 |
+
|
| 131 |
+
inference:
|
| 132 |
+
model_source: trained
|
| 133 |
+
trained_model_path: ./data/checkpoint/model_bak.tar
|
| 134 |
+
official_checkpoint_path: /root/private_data/workspaces/yangzt01/OneForecast/best_ckpt.tar
|
| 135 |
+
output_dir: ./outputs/predictions
|
| 136 |
+
max_batches: 1
|
| 137 |
+
|
| 138 |
+
visualization:
|
| 139 |
+
input_dir: ./outputs/predictions
|
| 140 |
+
output_dir: ./outputs/visualizations
|
| 141 |
+
channels: [0, 18, 36, 54, 67, 68]
|
config.json
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_name": "OneForecast",
|
| 3 |
+
"model_type": "oneforecast",
|
| 4 |
+
"architectures": [
|
| 5 |
+
"OneForecast"
|
| 6 |
+
],
|
| 7 |
+
"framework": "PyTorch",
|
| 8 |
+
"domain": "atmosphere",
|
| 9 |
+
"task": "global-regional-weather-forecasting",
|
| 10 |
+
"implementation": {
|
| 11 |
+
"entry_point": "model/oneforecast.py",
|
| 12 |
+
"scope": "portable encoder-processor-decoder mesh graph neural network with PyTorch index-tensor graph kernels usable on CPU, CUDA, and DCU; official checkpoint compatible"
|
| 13 |
+
},
|
| 14 |
+
"architecture": {
|
| 15 |
+
"family": "encoder-processor-decoder graph neural network on a refined icosahedral mesh",
|
| 16 |
+
"input_grid_shape": [
|
| 17 |
+
120,
|
| 18 |
+
240
|
| 19 |
+
],
|
| 20 |
+
"grid_sampling": "ERA5 0.25-degree 721x1440 sampled every sixth point, then cropped to 120x240",
|
| 21 |
+
"input_channels": 69,
|
| 22 |
+
"output_channels": 69,
|
| 23 |
+
"mesh_level": 5,
|
| 24 |
+
"mesh_local_refinement_regions": [
|
| 25 |
+
{
|
| 26 |
+
"description": "South and East Asia",
|
| 27 |
+
"lat_range": [
|
| 28 |
+
0.0,
|
| 29 |
+
30.0
|
| 30 |
+
],
|
| 31 |
+
"lon_range": [
|
| 32 |
+
105.0,
|
| 33 |
+
160.0
|
| 34 |
+
]
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"description": "Central North America",
|
| 38 |
+
"lat_range": [
|
| 39 |
+
10.0,
|
| 40 |
+
30.0
|
| 41 |
+
],
|
| 42 |
+
"lon_range": [
|
| 43 |
+
-95.0,
|
| 44 |
+
-35.0
|
| 45 |
+
]
|
| 46 |
+
}
|
| 47 |
+
],
|
| 48 |
+
"hidden_dim": 512,
|
| 49 |
+
"processor_layers": 16,
|
| 50 |
+
"hidden_layers": 1,
|
| 51 |
+
"num_heads_edge": 4,
|
| 52 |
+
"num_heads_node": 4,
|
| 53 |
+
"aggregation": "sum",
|
| 54 |
+
"activation": "SiLU",
|
| 55 |
+
"normalization": "LayerNorm"
|
| 56 |
+
},
|
| 57 |
+
"data": {
|
| 58 |
+
"dataset": "ERA5",
|
| 59 |
+
"grid_spatial_resolution_degrees": 1.5,
|
| 60 |
+
"time_step_hours": 6,
|
| 61 |
+
"input_steps": 1,
|
| 62 |
+
"output_steps": 1,
|
| 63 |
+
"surface_variables": [
|
| 64 |
+
"10m_u_component_of_wind",
|
| 65 |
+
"10m_v_component_of_wind",
|
| 66 |
+
"2m_temperature",
|
| 67 |
+
"mean_sea_level_pressure"
|
| 68 |
+
],
|
| 69 |
+
"atmospheric_variables": [
|
| 70 |
+
"geopotential",
|
| 71 |
+
"specific_humidity",
|
| 72 |
+
"temperature",
|
| 73 |
+
"u_component_of_wind",
|
| 74 |
+
"v_component_of_wind"
|
| 75 |
+
],
|
| 76 |
+
"pressure_levels_hpa": [
|
| 77 |
+
50,
|
| 78 |
+
100,
|
| 79 |
+
150,
|
| 80 |
+
200,
|
| 81 |
+
250,
|
| 82 |
+
300,
|
| 83 |
+
400,
|
| 84 |
+
500,
|
| 85 |
+
600,
|
| 86 |
+
700,
|
| 87 |
+
850,
|
| 88 |
+
925,
|
| 89 |
+
1000
|
| 90 |
+
]
|
| 91 |
+
},
|
| 92 |
+
"configuration_sources": [
|
| 93 |
+
"conf/config.yaml",
|
| 94 |
+
"model/oneforecast.py",
|
| 95 |
+
"model/era5_adapter.py"
|
| 96 |
+
]
|
| 97 |
+
}
|
configuration.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"framework":"PyTorch","task":"global_weather_forecasting","hardware":"DCU"}
|
model/era5_adapter.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OneScience ERA5 adapter for the official OneForecast 69-channel contract."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import tempfile
|
| 7 |
+
from typing import Any, Iterable
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
SOURCE_GRID = (721, 1440)
|
| 12 |
+
ONEFORECAST_FILE_GRID = (121, 240)
|
| 13 |
+
SPATIAL_STRIDE = 6
|
| 14 |
+
|
| 15 |
+
OFFICIAL_VARIABLES = tuple(
|
| 16 |
+
[f"Z{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
|
| 17 |
+
+ [f"Q{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
|
| 18 |
+
+ [f"T{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
|
| 19 |
+
+ [f"U{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
|
| 20 |
+
+ [f"V{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
|
| 21 |
+
+ ["U10M", "V10M", "T2M", "MSLP"]
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
VARIABLE_ALIASES = {
|
| 25 |
+
**{f"Z{x}": f"geopotential_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
|
| 26 |
+
**{f"Q{x}": f"specific_humidity_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
|
| 27 |
+
**{f"T{x}": f"temperature_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
|
| 28 |
+
**{f"U{x}": f"u_component_of_wind_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
|
| 29 |
+
**{f"V{x}": f"v_component_of_wind_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
|
| 30 |
+
"U10M": "10m_u_component_of_wind",
|
| 31 |
+
"V10M": "10m_v_component_of_wind",
|
| 32 |
+
"T2M": "2m_temperature",
|
| 33 |
+
"MSLP": "mean_sea_level_pressure",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _decode_variables(values: Iterable[Any]) -> list[str]:
|
| 38 |
+
return [value.decode() if isinstance(value, bytes) else str(value) for value in values]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class OneForecastERA5Adapter:
|
| 42 |
+
"""Validate files and construct OneScience's ERA5 DataLoader."""
|
| 43 |
+
|
| 44 |
+
def __init__(self, dataset_dir: str | Path, years: Iterable[int], batch_size: int = 1,
|
| 45 |
+
input_steps: int = 1, output_steps: int = 1, normalize: bool = True,
|
| 46 |
+
num_workers: int = 0, distributed: bool = False) -> None:
|
| 47 |
+
self.dataset_dir = Path(dataset_dir).expanduser().resolve()
|
| 48 |
+
self.years = [int(year) for year in years]
|
| 49 |
+
self.batch_size = batch_size
|
| 50 |
+
self.input_steps = input_steps
|
| 51 |
+
self.output_steps = output_steps
|
| 52 |
+
self.normalize = normalize
|
| 53 |
+
self.num_workers = num_workers
|
| 54 |
+
self.distributed = distributed
|
| 55 |
+
self.source_variables: list[str] = []
|
| 56 |
+
self.channel_indices: list[int] = []
|
| 57 |
+
self.global_means: np.ndarray | None = None
|
| 58 |
+
self.global_stds: np.ndarray | None = None
|
| 59 |
+
self.time_step_hours: int | None = None
|
| 60 |
+
self.source_grid: tuple[int, int] | None = None
|
| 61 |
+
self._external_stats: tuple[Path, Path] | None = None
|
| 62 |
+
self._layout_dir: tempfile.TemporaryDirectory[str] | None = None
|
| 63 |
+
self._validate_files()
|
| 64 |
+
|
| 65 |
+
def _year_path(self, year: int) -> Path:
|
| 66 |
+
for path in (self.dataset_dir / "data" / f"{year}.h5", self.dataset_dir / f"{year}.h5"):
|
| 67 |
+
if path.is_file():
|
| 68 |
+
return path
|
| 69 |
+
raise FileNotFoundError(f"ERA5 file for year {year} was not found below {self.dataset_dir}")
|
| 70 |
+
|
| 71 |
+
def _validate_files(self) -> None:
|
| 72 |
+
try:
|
| 73 |
+
import h5py
|
| 74 |
+
except ImportError as exc:
|
| 75 |
+
raise RuntimeError("h5py is required to validate ERA5 HDF5 files") from exc
|
| 76 |
+
if not self.years:
|
| 77 |
+
raise ValueError("At least one ERA5 year is required")
|
| 78 |
+
reference_variables: list[str] | None = None
|
| 79 |
+
reference_indices: list[int] | None = None
|
| 80 |
+
for year in self.years:
|
| 81 |
+
path = self._year_path(year)
|
| 82 |
+
with h5py.File(path, "r") as handle:
|
| 83 |
+
if "fields" not in handle:
|
| 84 |
+
raise ValueError(f"{path} does not contain a fields dataset")
|
| 85 |
+
fields = handle["fields"]
|
| 86 |
+
if len(fields.shape) != 4:
|
| 87 |
+
raise ValueError(f"{path}: fields must have shape [T, C, H, W], got {fields.shape}")
|
| 88 |
+
variables = _decode_variables(fields.attrs.get("variables", []))
|
| 89 |
+
source_variables = [
|
| 90 |
+
name if name in variables else VARIABLE_ALIASES[name]
|
| 91 |
+
for name in OFFICIAL_VARIABLES
|
| 92 |
+
if name in variables or VARIABLE_ALIASES[name] in variables
|
| 93 |
+
]
|
| 94 |
+
missing = [
|
| 95 |
+
name for name in OFFICIAL_VARIABLES
|
| 96 |
+
if name not in variables and VARIABLE_ALIASES[name] not in variables
|
| 97 |
+
]
|
| 98 |
+
if missing:
|
| 99 |
+
raise ValueError(f"{path}: missing official variables: {missing}")
|
| 100 |
+
indices = [variables.index(name) for name in source_variables]
|
| 101 |
+
if reference_variables is not None and variables != reference_variables:
|
| 102 |
+
raise ValueError(f"{path}: complete variable metadata differs between yearly files")
|
| 103 |
+
if reference_indices is not None and indices != reference_indices:
|
| 104 |
+
raise ValueError(f"{path}: official channel indices differ between yearly files")
|
| 105 |
+
reference_variables, reference_indices = variables, indices
|
| 106 |
+
self.source_variables = source_variables
|
| 107 |
+
self.channel_indices = indices
|
| 108 |
+
if fields.shape[1] != len(variables):
|
| 109 |
+
raise ValueError(f"{path}: variables metadata does not match channel dimension")
|
| 110 |
+
if fields.shape[1] != 69 or tuple(fields.shape[2:]) not in (SOURCE_GRID, ONEFORECAST_FILE_GRID):
|
| 111 |
+
raise ValueError(
|
| 112 |
+
f"{path}: expected fields [T, 69, 721, 1440] or [T, 69, 121, 240], got {fields.shape}"
|
| 113 |
+
)
|
| 114 |
+
grid = tuple(fields.shape[2:])
|
| 115 |
+
if self.source_grid is not None and grid != self.source_grid:
|
| 116 |
+
raise ValueError(f"{path}: spatial grid differs between yearly files")
|
| 117 |
+
self.source_grid = grid
|
| 118 |
+
if fields.shape[0] < self.input_steps + self.output_steps:
|
| 119 |
+
raise ValueError(f"{path}: not enough time steps for configured window")
|
| 120 |
+
if "time_step" not in fields.attrs:
|
| 121 |
+
raise ValueError(f"{path}: fields.attrs['time_step'] is required by ERA5Datapipe")
|
| 122 |
+
time_step = int(fields.attrs["time_step"])
|
| 123 |
+
if time_step != 6 or (self.time_step_hours is not None and time_step != self.time_step_hours):
|
| 124 |
+
raise ValueError(f"{path}: expected a consistent 6-hour time_step, got {time_step}")
|
| 125 |
+
self.time_step_hours = time_step
|
| 126 |
+
if "global_means" in handle and "global_stds" in handle:
|
| 127 |
+
means = np.asarray(handle["global_means"])
|
| 128 |
+
stds = np.asarray(handle["global_stds"])
|
| 129 |
+
else:
|
| 130 |
+
candidates = (
|
| 131 |
+
(self.dataset_dir / "stats" / "global_means.npy",
|
| 132 |
+
self.dataset_dir / "stats" / "global_stds.npy"),
|
| 133 |
+
(self.dataset_dir / "mean.npy", self.dataset_dir / "std.npy"),
|
| 134 |
+
(self.dataset_dir.parent / "mean.npy", self.dataset_dir.parent / "std.npy"),
|
| 135 |
+
)
|
| 136 |
+
stats_paths = next(((mean, std) for mean, std in candidates
|
| 137 |
+
if mean.is_file() and std.is_file()), None)
|
| 138 |
+
if stats_paths is None:
|
| 139 |
+
raise ValueError(f"{path}: embedded or external ERA5 statistics are required")
|
| 140 |
+
self._external_stats = stats_paths
|
| 141 |
+
means, stds = (np.load(item) for item in stats_paths)
|
| 142 |
+
expected_shape = (1, len(variables), 1, 1)
|
| 143 |
+
if means.shape != expected_shape or stds.shape != expected_shape:
|
| 144 |
+
raise ValueError(f"{path}: statistics must have shape {expected_shape}")
|
| 145 |
+
if not np.isfinite(means).all() or not np.isfinite(stds).all() or not (stds > 0).all():
|
| 146 |
+
raise ValueError(f"{path}: statistics must be finite and standard deviations positive")
|
| 147 |
+
if self.global_means is not None and not np.array_equal(means, self.global_means):
|
| 148 |
+
raise ValueError(f"{path}: global_means differ between yearly files")
|
| 149 |
+
if self.global_stds is not None and not np.array_equal(stds, self.global_stds):
|
| 150 |
+
raise ValueError(f"{path}: global_stds differ between yearly files")
|
| 151 |
+
self.global_means, self.global_stds = means, stds
|
| 152 |
+
|
| 153 |
+
def _onescience_dataset_dir(self) -> Path:
|
| 154 |
+
if self._layout_dir is not None:
|
| 155 |
+
return Path(self._layout_dir.name)
|
| 156 |
+
self._layout_dir = tempfile.TemporaryDirectory(prefix="oneforecast_era5_")
|
| 157 |
+
root = Path(self._layout_dir.name)
|
| 158 |
+
data_dir = root / "data"
|
| 159 |
+
data_dir.mkdir()
|
| 160 |
+
for year in self.years:
|
| 161 |
+
source_path = self._year_path(year)
|
| 162 |
+
target_path = data_dir / f"{year}.h5"
|
| 163 |
+
if self.source_grid == SOURCE_GRID:
|
| 164 |
+
import h5py
|
| 165 |
+
|
| 166 |
+
with h5py.File(source_path, "r") as source_handle:
|
| 167 |
+
source_fields = source_handle["fields"]
|
| 168 |
+
layout = h5py.VirtualLayout(
|
| 169 |
+
shape=(source_fields.shape[0], source_fields.shape[1], *ONEFORECAST_FILE_GRID),
|
| 170 |
+
dtype=source_fields.dtype,
|
| 171 |
+
)
|
| 172 |
+
virtual_source = h5py.VirtualSource(str(source_path), "fields", shape=source_fields.shape)
|
| 173 |
+
layout[:] = virtual_source[:, :, ::SPATIAL_STRIDE, ::SPATIAL_STRIDE]
|
| 174 |
+
with h5py.File(target_path, "w", libver="latest") as target_handle:
|
| 175 |
+
fields = target_handle.create_virtual_dataset("fields", layout)
|
| 176 |
+
for name, value in source_fields.attrs.items():
|
| 177 |
+
fields.attrs[name] = value
|
| 178 |
+
else:
|
| 179 |
+
target_path.symlink_to(source_path)
|
| 180 |
+
if self._external_stats is not None:
|
| 181 |
+
stats_dir = root / "stats"
|
| 182 |
+
stats_dir.mkdir()
|
| 183 |
+
(stats_dir / "global_means.npy").symlink_to(self._external_stats[0])
|
| 184 |
+
(stats_dir / "global_stds.npy").symlink_to(self._external_stats[1])
|
| 185 |
+
|
| 186 |
+
return root
|
| 187 |
+
|
| 188 |
+
def get_dataloader(self, mode: str):
|
| 189 |
+
"""Delegate loading to OneScience, then align native ERA5 to OneForecast's grid."""
|
| 190 |
+
try:
|
| 191 |
+
from onescience.datapipes.climate.era5 import ERA5Datapipe
|
| 192 |
+
except ImportError as exc:
|
| 193 |
+
raise RuntimeError("OneScience ERA5Datapipe is required for data loading") from exc
|
| 194 |
+
datapipe = ERA5Datapipe(
|
| 195 |
+
dataset_dir=str(self._onescience_dataset_dir()), used_years=self.years,
|
| 196 |
+
used_variables=self.source_variables, distributed=self.distributed,
|
| 197 |
+
input_steps=self.input_steps, output_steps=self.output_steps,
|
| 198 |
+
normalize=self.normalize, batch_size=self.batch_size, num_workers=self.num_workers,
|
| 199 |
+
)
|
| 200 |
+
loader, sampler = datapipe.get_dataloader(mode=mode)
|
| 201 |
+
return _SpatiallyAdaptedLoader(loader, self.source_grid), sampler
|
| 202 |
+
|
| 203 |
+
def inspect(self) -> dict[str, Any]:
|
| 204 |
+
try:
|
| 205 |
+
import h5py
|
| 206 |
+
except ImportError as exc:
|
| 207 |
+
raise RuntimeError("h5py is required to inspect ERA5 HDF5 files") from exc
|
| 208 |
+
path = self._year_path(self.years[0])
|
| 209 |
+
with h5py.File(path, "r") as handle:
|
| 210 |
+
fields = handle["fields"]
|
| 211 |
+
variables = _decode_variables(fields.attrs["variables"])
|
| 212 |
+
indices = [variables.index(name) for name in self.source_variables]
|
| 213 |
+
return {"path": str(path), "fields_shape": list(fields.shape),
|
| 214 |
+
"source_grid": list(fields.shape[2:]),
|
| 215 |
+
"oneforecast_file_grid": list(ONEFORECAST_FILE_GRID),
|
| 216 |
+
"oneforecast_model_grid": [120, 240],
|
| 217 |
+
"spatial_transform": "identity" if tuple(fields.shape[2:]) == ONEFORECAST_FILE_GRID else "stride_6",
|
| 218 |
+
"time_step_hours": int(fields.attrs["time_step"]),
|
| 219 |
+
"variable_count": len(variables), "official_channel_indices": indices,
|
| 220 |
+
"source_variables": self.source_variables,
|
| 221 |
+
"statistics_shape": list(self.global_means.shape),
|
| 222 |
+
"statistics_shared_across_years": True,
|
| 223 |
+
"official_variables_match": len(indices) == len(OFFICIAL_VARIABLES)}
|
| 224 |
+
|
| 225 |
+
def selected_statistics(self) -> tuple[np.ndarray, np.ndarray]:
|
| 226 |
+
"""Return normalization statistics in the model's 69-channel order."""
|
| 227 |
+
if self.global_means is None or self.global_stds is None:
|
| 228 |
+
raise RuntimeError("ERA5 statistics have not been validated")
|
| 229 |
+
return self.global_means[:, self.channel_indices], self.global_stds[:, self.channel_indices]
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def _adapt_spatial(value: Any, source_grid: tuple[int, int] | None) -> Any:
|
| 233 |
+
if not hasattr(value, "shape") or len(value.shape) < 2:
|
| 234 |
+
return value
|
| 235 |
+
if tuple(value.shape[-2:]) == ONEFORECAST_FILE_GRID:
|
| 236 |
+
return value
|
| 237 |
+
if tuple(value.shape[-2:]) != SOURCE_GRID or source_grid != SOURCE_GRID:
|
| 238 |
+
return value
|
| 239 |
+
return value[..., ::SPATIAL_STRIDE, ::SPATIAL_STRIDE]
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
class _SpatiallyAdaptedLoader:
|
| 243 |
+
"""Preserve the DataLoader interface while adapting fields after ERA5Datapipe."""
|
| 244 |
+
|
| 245 |
+
def __init__(self, loader: Any, source_grid: tuple[int, int] | None) -> None:
|
| 246 |
+
self.loader = loader
|
| 247 |
+
self.source_grid = source_grid
|
| 248 |
+
|
| 249 |
+
def __len__(self) -> int:
|
| 250 |
+
return len(self.loader)
|
| 251 |
+
|
| 252 |
+
def __iter__(self):
|
| 253 |
+
for batch in self.loader:
|
| 254 |
+
yield tuple(_adapt_spatial(value, self.source_grid) for value in batch)
|
model/oneforecast.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Portable OneForecast model and official checkpoint compatibility helpers.
|
| 2 |
+
|
| 3 |
+
The parameter hierarchy mirrors the official model. Graph operations use
|
| 4 |
+
PyTorch index tensors instead of CUDA-only CuGraph kernels, making the model
|
| 5 |
+
usable on CPU, CUDA, and DCU PyTorch builds.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
import sys
|
| 13 |
+
import types
|
| 14 |
+
from typing import Any, NamedTuple
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import torch
|
| 18 |
+
from torch import Tensor, nn
|
| 19 |
+
from torch.nn import functional as F
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class GraphData:
|
| 23 |
+
"""Minimal homogeneous or bipartite graph used by the portable kernels."""
|
| 24 |
+
|
| 25 |
+
def __init__(self, src: Tensor, dst: Tensor, num_src: int, num_dst: int) -> None:
|
| 26 |
+
self.src = src.to(torch.long)
|
| 27 |
+
self.dst = dst.to(torch.long)
|
| 28 |
+
self.num_src = num_src
|
| 29 |
+
self.num_dst = num_dst
|
| 30 |
+
|
| 31 |
+
def to(self, device: torch.device | str) -> "GraphData":
|
| 32 |
+
self.src = self.src.to(device)
|
| 33 |
+
self.dst = self.dst.to(device)
|
| 34 |
+
return self
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _aggregate(values: Tensor, dst: Tensor, num_dst: int, reduction: str) -> Tensor:
|
| 38 |
+
output = values.new_zeros((num_dst,) + values.shape[1:])
|
| 39 |
+
index = dst.view((-1,) + (1,) * (values.ndim - 1)).expand_as(values)
|
| 40 |
+
output.scatter_add_(0, index, values)
|
| 41 |
+
if reduction == "mean":
|
| 42 |
+
counts = values.new_zeros(num_dst)
|
| 43 |
+
counts.scatter_add_(0, dst, torch.ones_like(dst, dtype=values.dtype))
|
| 44 |
+
output = output / counts.clamp_min(1).view((-1,) + (1,) * (values.ndim - 1))
|
| 45 |
+
elif reduction != "sum":
|
| 46 |
+
raise ValueError(f"Unsupported aggregation: {reduction}")
|
| 47 |
+
return output
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _edge_softmax(logits: Tensor, dst: Tensor, num_dst: int) -> Tensor:
|
| 51 |
+
index = dst[:, None].expand_as(logits)
|
| 52 |
+
maxima = logits.new_full((num_dst, logits.shape[1]), -torch.inf)
|
| 53 |
+
maxima.scatter_reduce_(0, index, logits, reduce="amax", include_self=True)
|
| 54 |
+
exp = torch.exp(logits - maxima[dst])
|
| 55 |
+
denominator = logits.new_zeros((num_dst, logits.shape[1]))
|
| 56 |
+
denominator.scatter_add_(0, index, exp)
|
| 57 |
+
return exp / denominator[dst].clamp_min(torch.finfo(exp.dtype).tiny)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class MeshGraphMLP(nn.Module):
|
| 61 |
+
def __init__(self, input_dim: int, output_dim: int = 512, hidden_dim: int = 512,
|
| 62 |
+
hidden_layers: int | None = 1, activation_fn: nn.Module | None = None,
|
| 63 |
+
norm_type: str | None = "LayerNorm", recompute_activation: bool = False) -> None:
|
| 64 |
+
super().__init__()
|
| 65 |
+
del recompute_activation
|
| 66 |
+
activation_fn = activation_fn or nn.SiLU()
|
| 67 |
+
if hidden_layers is None:
|
| 68 |
+
self.model = nn.Identity()
|
| 69 |
+
return
|
| 70 |
+
layers: list[nn.Module] = [nn.Linear(input_dim, hidden_dim), activation_fn]
|
| 71 |
+
for _ in range(hidden_layers - 1):
|
| 72 |
+
layers.extend([nn.Linear(hidden_dim, hidden_dim), nn.SiLU()])
|
| 73 |
+
layers.append(nn.Linear(hidden_dim, output_dim))
|
| 74 |
+
if norm_type is not None:
|
| 75 |
+
if norm_type != "LayerNorm":
|
| 76 |
+
raise ValueError("The portable model supports LayerNorm only")
|
| 77 |
+
layers.append(nn.LayerNorm(output_dim))
|
| 78 |
+
self.model = nn.Sequential(*layers)
|
| 79 |
+
|
| 80 |
+
def forward(self, x: Tensor) -> Tensor:
|
| 81 |
+
return self.model(x)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class MeshGraphEdgeMLPSum(nn.Module):
|
| 85 |
+
"""Concat-trick edge MLP with the official parameter names and initialization."""
|
| 86 |
+
|
| 87 |
+
def __init__(self, efeat_dim: int, src_dim: int, dst_dim: int,
|
| 88 |
+
output_dim: int = 512, hidden_dim: int = 512,
|
| 89 |
+
hidden_layers: int = 1, activation_fn: nn.Module | None = None,
|
| 90 |
+
norm_type: str | None = "LayerNorm", recompute_activation: bool = False) -> None:
|
| 91 |
+
super().__init__()
|
| 92 |
+
del recompute_activation
|
| 93 |
+
activation_fn = activation_fn or nn.SiLU()
|
| 94 |
+
initial = nn.Linear(efeat_dim + src_dim + dst_dim, hidden_dim)
|
| 95 |
+
weights = torch.split(initial.weight, [efeat_dim, src_dim, dst_dim], dim=1)
|
| 96 |
+
self.lin_efeat = nn.Parameter(weights[0])
|
| 97 |
+
self.lin_src = nn.Parameter(weights[1])
|
| 98 |
+
self.lin_dst = nn.Parameter(weights[2])
|
| 99 |
+
self.bias = initial.bias
|
| 100 |
+
layers: list[nn.Module] = [activation_fn]
|
| 101 |
+
for _ in range(hidden_layers - 1):
|
| 102 |
+
layers.extend([nn.Linear(hidden_dim, hidden_dim), nn.SiLU()])
|
| 103 |
+
layers.append(nn.Linear(hidden_dim, output_dim))
|
| 104 |
+
if norm_type is not None:
|
| 105 |
+
if norm_type != "LayerNorm":
|
| 106 |
+
raise ValueError("The portable model supports LayerNorm only")
|
| 107 |
+
layers.append(nn.LayerNorm(output_dim))
|
| 108 |
+
self.model = nn.Sequential(*layers)
|
| 109 |
+
|
| 110 |
+
def forward(self, efeat: Tensor, nfeat: Tensor | tuple[Tensor, Tensor],
|
| 111 |
+
graph: GraphData) -> Tensor:
|
| 112 |
+
src_feat, dst_feat = (nfeat, nfeat) if isinstance(nfeat, Tensor) else nfeat
|
| 113 |
+
hidden = F.linear(efeat, self.lin_efeat)
|
| 114 |
+
hidden = hidden + F.linear(src_feat[graph.src], self.lin_src)
|
| 115 |
+
hidden = hidden + F.linear(dst_feat[graph.dst], self.lin_dst, self.bias)
|
| 116 |
+
return self.model(hidden)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class OneForecastEncoderEmbedder(nn.Module):
|
| 120 |
+
def __init__(self, input_dim_grid_nodes: int = 69, input_dim_mesh_nodes: int = 3,
|
| 121 |
+
input_dim_edges: int = 4, output_dim: int = 512,
|
| 122 |
+
hidden_dim: int = 512, hidden_layers: int = 1) -> None:
|
| 123 |
+
super().__init__()
|
| 124 |
+
kwargs = dict(output_dim=output_dim, hidden_dim=hidden_dim, hidden_layers=hidden_layers)
|
| 125 |
+
self.grid_node_mlp = MeshGraphMLP(input_dim_grid_nodes, **kwargs)
|
| 126 |
+
self.mesh_node_mlp = MeshGraphMLP(input_dim_mesh_nodes, **kwargs)
|
| 127 |
+
self.mesh_edge_mlp = MeshGraphMLP(input_dim_edges, **kwargs)
|
| 128 |
+
self.grid2mesh_edge_mlp = MeshGraphMLP(input_dim_edges, **kwargs)
|
| 129 |
+
|
| 130 |
+
def forward(self, grid: Tensor, mesh: Tensor, g2m: Tensor,
|
| 131 |
+
mesh_edges: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor]:
|
| 132 |
+
return (self.grid_node_mlp(grid), self.mesh_node_mlp(mesh),
|
| 133 |
+
self.grid2mesh_edge_mlp(g2m), self.mesh_edge_mlp(mesh_edges))
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class OneForecastDecoderEmbedder(nn.Module):
|
| 137 |
+
def __init__(self, input_dim_edges: int = 4, output_dim: int = 512,
|
| 138 |
+
hidden_dim: int = 512, hidden_layers: int = 1) -> None:
|
| 139 |
+
super().__init__()
|
| 140 |
+
self.mesh2grid_edge_mlp = MeshGraphMLP(
|
| 141 |
+
input_dim_edges, output_dim, hidden_dim, hidden_layers)
|
| 142 |
+
|
| 143 |
+
def forward(self, edges: Tensor) -> Tensor:
|
| 144 |
+
return self.mesh2grid_edge_mlp(edges)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class MeshGraphEncoder(nn.Module):
|
| 148 |
+
def __init__(self, hidden_dim: int = 512, hidden_layers: int = 1,
|
| 149 |
+
aggregation: str = "sum") -> None:
|
| 150 |
+
super().__init__()
|
| 151 |
+
self.aggregation = aggregation
|
| 152 |
+
self.edge_mlp = MeshGraphEdgeMLPSum(hidden_dim, hidden_dim, hidden_dim,
|
| 153 |
+
hidden_dim, hidden_dim, hidden_layers)
|
| 154 |
+
self.src_node_mlp = MeshGraphMLP(hidden_dim, hidden_dim, hidden_dim, hidden_layers)
|
| 155 |
+
self.dst_node_mlp = MeshGraphMLP(hidden_dim * 2, hidden_dim, hidden_dim, hidden_layers)
|
| 156 |
+
|
| 157 |
+
def forward(self, edges: Tensor, grid: Tensor, mesh: Tensor,
|
| 158 |
+
graph: GraphData) -> tuple[Tensor, Tensor]:
|
| 159 |
+
edges = self.edge_mlp(edges, (grid, mesh), graph)
|
| 160 |
+
aggregated = _aggregate(edges, graph.dst, graph.num_dst, self.aggregation)
|
| 161 |
+
return grid + self.src_node_mlp(grid), mesh + self.dst_node_mlp(torch.cat((aggregated, mesh), -1))
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
class MeshGraphDecoder(nn.Module):
|
| 165 |
+
def __init__(self, hidden_dim: int = 512, hidden_layers: int = 1,
|
| 166 |
+
aggregation: str = "sum") -> None:
|
| 167 |
+
super().__init__()
|
| 168 |
+
self.aggregation = aggregation
|
| 169 |
+
self.edge_mlp = MeshGraphEdgeMLPSum(hidden_dim, hidden_dim, hidden_dim,
|
| 170 |
+
hidden_dim, hidden_dim, hidden_layers)
|
| 171 |
+
self.node_mlp = MeshGraphMLP(hidden_dim * 2, hidden_dim, hidden_dim, hidden_layers)
|
| 172 |
+
|
| 173 |
+
def forward(self, edges: Tensor, grid: Tensor, mesh: Tensor, graph: GraphData) -> Tensor:
|
| 174 |
+
edges = self.edge_mlp(edges, (mesh, grid), graph)
|
| 175 |
+
aggregated = _aggregate(edges, graph.dst, graph.num_dst, self.aggregation)
|
| 176 |
+
return grid + self.node_mlp(torch.cat((aggregated, grid), -1))
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
class MeshEdgeBlockMultiHeadGated(nn.Module):
|
| 180 |
+
def __init__(self, hidden_dim: int = 512, hidden_layers: int = 1,
|
| 181 |
+
num_heads: int = 4) -> None:
|
| 182 |
+
super().__init__()
|
| 183 |
+
self.num_heads = num_heads
|
| 184 |
+
self.edge_mlp = MeshGraphEdgeMLPSum(hidden_dim, hidden_dim, hidden_dim,
|
| 185 |
+
hidden_dim, hidden_dim, hidden_layers)
|
| 186 |
+
gating_hidden = max(16, hidden_dim // 8)
|
| 187 |
+
self.gate_net = nn.Sequential(nn.Linear(hidden_dim * 3, gating_hidden), nn.SiLU(),
|
| 188 |
+
nn.Linear(gating_hidden, 3 * num_heads), nn.Sigmoid())
|
| 189 |
+
|
| 190 |
+
def forward(self, edges: Tensor, nodes: Tensor, graph: GraphData) -> tuple[Tensor, Tensor]:
|
| 191 |
+
raw = torch.cat((edges, nodes[graph.src], nodes[graph.dst]), -1)
|
| 192 |
+
gates = self.gate_net(raw).view(-1, self.num_heads, 3).mean(1)
|
| 193 |
+
updated = self.edge_mlp(edges, nodes, graph)
|
| 194 |
+
return edges + updated * gates.mean(-1, keepdim=True), nodes
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
class MeshNodeBlockMultiHeadAttn(nn.Module):
|
| 198 |
+
def __init__(self, hidden_dim: int = 512, hidden_layers: int = 1,
|
| 199 |
+
aggregation: str = "sum", num_heads: int = 4) -> None:
|
| 200 |
+
super().__init__()
|
| 201 |
+
self.num_heads = num_heads
|
| 202 |
+
self.aggregation = aggregation
|
| 203 |
+
self.node_mlp = MeshGraphMLP(hidden_dim * (num_heads + 1), hidden_dim,
|
| 204 |
+
hidden_dim, hidden_layers)
|
| 205 |
+
attention_hidden = max(16, hidden_dim // 8)
|
| 206 |
+
self.attn_net = nn.Sequential(nn.Linear(hidden_dim, attention_hidden), nn.SiLU(),
|
| 207 |
+
nn.Linear(attention_hidden, num_heads))
|
| 208 |
+
|
| 209 |
+
def forward(self, edges: Tensor, nodes: Tensor, graph: GraphData) -> tuple[Tensor, Tensor]:
|
| 210 |
+
scores = _edge_softmax(self.attn_net(edges), graph.dst, graph.num_dst)
|
| 211 |
+
messages = edges[:, None, :].expand(-1, self.num_heads, -1) * scores[:, :, None]
|
| 212 |
+
aggregated = _aggregate(messages, graph.dst, graph.num_dst, self.aggregation).flatten(1)
|
| 213 |
+
return edges, nodes + self.node_mlp(torch.cat((aggregated, nodes), -1))
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
class OneForecastProcessor(nn.Module):
|
| 217 |
+
def __init__(self, processor_layers: int, hidden_dim: int = 512,
|
| 218 |
+
hidden_layers: int = 1, aggregation: str = "sum",
|
| 219 |
+
num_heads_edge: int = 4, num_heads_node: int = 4) -> None:
|
| 220 |
+
super().__init__()
|
| 221 |
+
layers: list[nn.Module] = []
|
| 222 |
+
for _ in range(processor_layers):
|
| 223 |
+
layers.append(MeshEdgeBlockMultiHeadGated(hidden_dim, hidden_layers, num_heads_edge))
|
| 224 |
+
layers.append(MeshNodeBlockMultiHeadAttn(hidden_dim, hidden_layers, aggregation, num_heads_node))
|
| 225 |
+
self.processor_layers = nn.ModuleList(layers)
|
| 226 |
+
|
| 227 |
+
def forward(self, edges: Tensor, nodes: Tensor, graph: GraphData) -> tuple[Tensor, Tensor]:
|
| 228 |
+
for layer in self.processor_layers:
|
| 229 |
+
edges, nodes = layer(edges, nodes, graph)
|
| 230 |
+
return edges, nodes
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
class TriangularMesh(NamedTuple):
|
| 234 |
+
vertices: np.ndarray
|
| 235 |
+
faces: np.ndarray
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _icosahedron() -> TriangularMesh:
|
| 239 |
+
from scipy.spatial.transform import Rotation
|
| 240 |
+
|
| 241 |
+
phi = (1 + np.sqrt(5)) / 2
|
| 242 |
+
vertices = []
|
| 243 |
+
for first in (1.0, -1.0):
|
| 244 |
+
for second in (phi, -phi):
|
| 245 |
+
vertices.extend(((first, second, 0.0), (0.0, first, second), (second, 0.0, first)))
|
| 246 |
+
vertices = np.asarray(vertices, dtype=np.float32) / np.linalg.norm([1.0, phi])
|
| 247 |
+
faces = np.asarray(((0,1,2),(0,6,1),(8,0,2),(8,4,0),(3,8,2),(3,2,7),(7,2,1),
|
| 248 |
+
(0,4,6),(4,11,6),(6,11,5),(1,5,7),(4,10,11),(4,8,10),(10,8,3),
|
| 249 |
+
(10,3,9),(11,10,9),(11,9,5),(5,9,7),(9,3,7),(1,6,5)), dtype=np.int32)
|
| 250 |
+
angle = (np.pi - 2 * np.arcsin(phi / np.sqrt(3))) / 2
|
| 251 |
+
vertices = vertices @ Rotation.from_euler("y", angle).as_matrix()
|
| 252 |
+
return TriangularMesh(vertices.astype(np.float32), faces)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _split_mesh(mesh: TriangularMesh) -> TriangularMesh:
|
| 256 |
+
vertices = list(mesh.vertices)
|
| 257 |
+
children: dict[tuple[int, int], int] = {}
|
| 258 |
+
faces = []
|
| 259 |
+
for a, b, c in mesh.faces:
|
| 260 |
+
mids = []
|
| 261 |
+
for pair in ((a, b), (b, c), (c, a)):
|
| 262 |
+
key = tuple(sorted(map(int, pair)))
|
| 263 |
+
if key not in children:
|
| 264 |
+
position = mesh.vertices[list(pair)].mean(0)
|
| 265 |
+
position /= np.linalg.norm(position)
|
| 266 |
+
children[key] = len(vertices)
|
| 267 |
+
vertices.append(position)
|
| 268 |
+
mids.append(children[key])
|
| 269 |
+
ab, bc, ca = mids
|
| 270 |
+
faces.extend(((a, ab, ca), (ab, b, bc), (ca, bc, c), (ab, bc, ca)))
|
| 271 |
+
return TriangularMesh(np.asarray(vertices, dtype=np.float32), np.asarray(faces, dtype=np.int32))
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def _mesh_hierarchy(level: int) -> list[TriangularMesh]:
|
| 275 |
+
meshes = [_icosahedron()]
|
| 276 |
+
for _ in range(level):
|
| 277 |
+
meshes.append(_split_mesh(meshes[-1]))
|
| 278 |
+
return meshes
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def _faces_to_edges(faces: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
| 282 |
+
return (np.concatenate((faces[:, 0], faces[:, 1], faces[:, 2])),
|
| 283 |
+
np.concatenate((faces[:, 1], faces[:, 2], faces[:, 0])))
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _latlon_to_xyz(latlon: Tensor) -> Tensor:
|
| 287 |
+
values = torch.deg2rad(latlon)
|
| 288 |
+
lat, lon = values[:, 0], values[:, 1]
|
| 289 |
+
return torch.stack((torch.cos(lat) * torch.cos(lon), torch.cos(lat) * torch.sin(lon), torch.sin(lat)), 1)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def _node_features(xyz: Tensor) -> Tensor:
|
| 293 |
+
# The official implementation applies trigonometric functions to the
|
| 294 |
+
# degree-valued xyz2latlon output; retain that behavior for parity.
|
| 295 |
+
lat = torch.rad2deg(torch.asin(xyz[:, 2]))
|
| 296 |
+
lon = torch.rad2deg(torch.atan2(xyz[:, 1], xyz[:, 0]))
|
| 297 |
+
return torch.stack((torch.cos(lat), torch.sin(lon), torch.cos(lon)), -1)
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def _edge_features(src_pos: Tensor, dst_pos: Tensor, src: Tensor, dst: Tensor) -> Tensor:
|
| 301 |
+
source, target = src_pos[src], dst_pos[dst]
|
| 302 |
+
lat = torch.asin(target[:, 2])
|
| 303 |
+
lon = torch.atan2(target[:, 1], target[:, 0])
|
| 304 |
+
cos_lon, sin_lon = torch.cos(-lon), torch.sin(-lon)
|
| 305 |
+
source = torch.stack((cos_lon * source[:, 0] - sin_lon * source[:, 1],
|
| 306 |
+
sin_lon * source[:, 0] + cos_lon * source[:, 1], source[:, 2]), -1)
|
| 307 |
+
target = torch.stack((cos_lon * target[:, 0] - sin_lon * target[:, 1],
|
| 308 |
+
sin_lon * target[:, 0] + cos_lon * target[:, 1], target[:, 2]), -1)
|
| 309 |
+
cos_lat, sin_lat = torch.cos(lat), torch.sin(lat)
|
| 310 |
+
source = torch.stack((cos_lat * source[:, 0] + sin_lat * source[:, 2], source[:, 1],
|
| 311 |
+
-sin_lat * source[:, 0] + cos_lat * source[:, 2]), -1)
|
| 312 |
+
target = torch.stack((cos_lat * target[:, 0] + sin_lat * target[:, 2], target[:, 1],
|
| 313 |
+
-sin_lat * target[:, 0] + cos_lat * target[:, 2]), -1)
|
| 314 |
+
displacement = source - target
|
| 315 |
+
norm = torch.linalg.norm(displacement, dim=-1, keepdim=True)
|
| 316 |
+
maximum = norm.max()
|
| 317 |
+
return torch.cat((displacement / maximum, norm / maximum), -1)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def _local_refine(mesh: TriangularMesh, lat_min: float, lat_max: float,
|
| 321 |
+
lon_min: float, lon_max: float) -> TriangularMesh:
|
| 322 |
+
centroids = mesh.vertices[mesh.faces].mean(axis=1)
|
| 323 |
+
# Match the official xyz2latlon call, which assumes radius=1 for centroids.
|
| 324 |
+
latitudes = np.rad2deg(np.arcsin(centroids[:, 2]))
|
| 325 |
+
longitudes = np.rad2deg(np.arctan2(centroids[:, 1], centroids[:, 0]))
|
| 326 |
+
selected = ((latitudes >= lat_min) & (latitudes <= lat_max)
|
| 327 |
+
& (longitudes >= lon_min) & (longitudes <= lon_max))
|
| 328 |
+
refined = _split_mesh(TriangularMesh(mesh.vertices, mesh.faces[selected]))
|
| 329 |
+
combined_vertices = np.concatenate((refined.vertices, mesh.vertices), axis=0)
|
| 330 |
+
combined_faces = np.concatenate((refined.faces, mesh.faces[~selected] + len(refined.vertices)), axis=0)
|
| 331 |
+
rounded = np.round(combined_vertices, decimals=6)
|
| 332 |
+
unique: dict[tuple[float, float, float], int] = {}
|
| 333 |
+
remap = np.empty(len(rounded), dtype=np.int64)
|
| 334 |
+
vertices = []
|
| 335 |
+
for index, coordinates in enumerate(rounded):
|
| 336 |
+
key = tuple(coordinates.tolist())
|
| 337 |
+
if key not in unique:
|
| 338 |
+
unique[key] = len(vertices)
|
| 339 |
+
vertices.append(combined_vertices[index])
|
| 340 |
+
remap[index] = unique[key]
|
| 341 |
+
return TriangularMesh(np.asarray(vertices, dtype=np.float32), remap[combined_faces].astype(np.int32))
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def _build_graphs(height: int, width: int, mesh_level: int) -> tuple[GraphData, GraphData, GraphData, Tensor, Tensor, Tensor, Tensor]:
|
| 345 |
+
from scipy.spatial import cKDTree
|
| 346 |
+
|
| 347 |
+
latitudes = torch.linspace(-90, 90, height + 1)[:-1]
|
| 348 |
+
longitudes = torch.linspace(-180, 180, width + 1)[1:]
|
| 349 |
+
latlon = torch.stack(torch.meshgrid(latitudes, longitudes, indexing="ij"), -1).reshape(-1, 2)
|
| 350 |
+
grid_xyz = _latlon_to_xyz(latlon)
|
| 351 |
+
hierarchy = _mesh_hierarchy(mesh_level)
|
| 352 |
+
finest = hierarchy[-1]
|
| 353 |
+
refined = _local_refine(finest, 0.0, 30.0, 105.0, 160.0)
|
| 354 |
+
refined = _local_refine(refined, 10.0, 30.0, -95.0, -35.0)
|
| 355 |
+
mesh_vertices = refined.vertices
|
| 356 |
+
mesh_faces = np.concatenate([mesh.faces for mesh in hierarchy] + [refined.faces], axis=0)
|
| 357 |
+
mesh_src, mesh_dst = _faces_to_edges(mesh_faces)
|
| 358 |
+
mesh_src = np.concatenate((mesh_src, mesh_dst))
|
| 359 |
+
mesh_dst = np.concatenate((mesh_dst, mesh_src[:len(mesh_dst)]))
|
| 360 |
+
pairs = np.unique(np.stack((mesh_src, mesh_dst), 1), axis=0)
|
| 361 |
+
mesh_src_t = torch.from_numpy(pairs[:, 0])
|
| 362 |
+
mesh_dst_t = torch.from_numpy(pairs[:, 1])
|
| 363 |
+
mesh_xyz = torch.from_numpy(mesh_vertices)
|
| 364 |
+
mesh_graph = GraphData(mesh_src_t, mesh_dst_t, len(mesh_vertices), len(mesh_vertices))
|
| 365 |
+
|
| 366 |
+
finest_src, finest_dst = _faces_to_edges(finest.faces)
|
| 367 |
+
max_edge = np.linalg.norm(finest.vertices[finest_src] - finest.vertices[finest_dst], axis=1).max()
|
| 368 |
+
distances, neighbors = cKDTree(mesh_vertices).query(grid_xyz.numpy(), k=4)
|
| 369 |
+
valid = distances <= 0.6 * max_edge
|
| 370 |
+
g2m_src, neighbor_slot = np.nonzero(valid)
|
| 371 |
+
g2m_dst = neighbors[g2m_src, neighbor_slot]
|
| 372 |
+
g2m_graph = GraphData(torch.from_numpy(g2m_src), torch.from_numpy(g2m_dst), len(grid_xyz), len(mesh_vertices))
|
| 373 |
+
|
| 374 |
+
centroids = mesh_vertices[mesh_faces].mean(axis=1)
|
| 375 |
+
face_indices = cKDTree(centroids).query(grid_xyz.numpy(), k=1)[1]
|
| 376 |
+
m2g_src = mesh_faces[face_indices].reshape(-1)
|
| 377 |
+
m2g_dst = np.repeat(np.arange(len(grid_xyz)), 3)
|
| 378 |
+
m2g_graph = GraphData(torch.from_numpy(m2g_src), torch.from_numpy(m2g_dst), len(mesh_vertices), len(grid_xyz))
|
| 379 |
+
mesh_nodes = _node_features(mesh_xyz)
|
| 380 |
+
mesh_edges = _edge_features(mesh_xyz, mesh_xyz, mesh_graph.src, mesh_graph.dst)
|
| 381 |
+
g2m_edges = _edge_features(grid_xyz, mesh_xyz, g2m_graph.src, g2m_graph.dst)
|
| 382 |
+
m2g_edges = _edge_features(mesh_xyz, grid_xyz, m2g_graph.src, m2g_graph.dst)
|
| 383 |
+
return mesh_graph, g2m_graph, m2g_graph, mesh_nodes, mesh_edges, g2m_edges, m2g_edges
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
class OneForecast(nn.Module):
|
| 387 |
+
"""Official OneForecast message-passing architecture with portable graph kernels."""
|
| 388 |
+
|
| 389 |
+
def __init__(self, input_res: tuple[int, int] = (120, 240), input_dim_grid_nodes: int = 69,
|
| 390 |
+
output_dim_grid_nodes: int = 69, mesh_level: int = 5,
|
| 391 |
+
processor_layers: int = 16, hidden_layers: int = 1,
|
| 392 |
+
hidden_dim: int = 512, aggregation: str = "sum",
|
| 393 |
+
num_heads_edge: int = 4, num_heads_node: int = 4,
|
| 394 |
+
build_graph: bool = True) -> None:
|
| 395 |
+
super().__init__()
|
| 396 |
+
if processor_layers <= 2:
|
| 397 |
+
raise ValueError("Expected at least 3 processor layers")
|
| 398 |
+
self.register_buffer("device_buffer", torch.empty(0))
|
| 399 |
+
self.input_res = tuple(input_res)
|
| 400 |
+
self.input_dim_grid_nodes = input_dim_grid_nodes
|
| 401 |
+
self.output_dim_grid_nodes = output_dim_grid_nodes
|
| 402 |
+
self.mesh_level = mesh_level
|
| 403 |
+
self.encoder_embedder = OneForecastEncoderEmbedder(
|
| 404 |
+
input_dim_grid_nodes, 3, 4, hidden_dim, hidden_dim, hidden_layers)
|
| 405 |
+
self.decoder_embedder = OneForecastDecoderEmbedder(4, hidden_dim, hidden_dim, hidden_layers)
|
| 406 |
+
self.encoder = MeshGraphEncoder(hidden_dim, hidden_layers, aggregation)
|
| 407 |
+
self.processor_encoder = OneForecastProcessor(
|
| 408 |
+
1, hidden_dim, hidden_layers, aggregation, num_heads_edge, num_heads_node)
|
| 409 |
+
self.processor = OneForecastProcessor(
|
| 410 |
+
processor_layers - 2, hidden_dim, hidden_layers, aggregation, num_heads_edge, num_heads_node)
|
| 411 |
+
self.processor_decoder = OneForecastProcessor(
|
| 412 |
+
1, hidden_dim, hidden_layers, aggregation, num_heads_edge, num_heads_node)
|
| 413 |
+
self.decoder = MeshGraphDecoder(hidden_dim, hidden_layers, aggregation)
|
| 414 |
+
self.finale = MeshGraphMLP(hidden_dim, output_dim_grid_nodes, hidden_dim, hidden_layers, norm_type=None)
|
| 415 |
+
self._graph_ready = False
|
| 416 |
+
if build_graph:
|
| 417 |
+
self.build_graph()
|
| 418 |
+
|
| 419 |
+
def build_graph(self) -> None:
|
| 420 |
+
values = _build_graphs(*self.input_res, self.mesh_level)
|
| 421 |
+
self.mesh_graph, self.g2m_graph, self.m2g_graph = values[:3]
|
| 422 |
+
for name, value in zip(("mesh_ndata", "mesh_edata", "g2m_edata", "m2g_edata"), values[3:]):
|
| 423 |
+
self.register_buffer(name, value, persistent=False)
|
| 424 |
+
self._graph_ready = True
|
| 425 |
+
|
| 426 |
+
def forward(self, grid_nfeat: Tensor) -> Tensor:
|
| 427 |
+
if not self._graph_ready:
|
| 428 |
+
raise RuntimeError("Graph construction was disabled for this model instance")
|
| 429 |
+
if grid_nfeat.shape != (1, self.input_dim_grid_nodes, *self.input_res):
|
| 430 |
+
raise ValueError(f"Expected input shape (1, {self.input_dim_grid_nodes}, {self.input_res[0]}, {self.input_res[1]}), got {tuple(grid_nfeat.shape)}")
|
| 431 |
+
grid = grid_nfeat[0].reshape(self.input_dim_grid_nodes, -1).T
|
| 432 |
+
grid, mesh, g2m, mesh_edges = self.encoder_embedder(
|
| 433 |
+
grid, self.mesh_ndata, self.g2m_edata, self.mesh_edata)
|
| 434 |
+
grid, mesh = self.encoder(g2m, grid, mesh, self.g2m_graph)
|
| 435 |
+
mesh_edges, mesh = self.processor_encoder(mesh_edges, mesh, self.mesh_graph)
|
| 436 |
+
mesh_edges, mesh = self.processor(mesh_edges, mesh, self.mesh_graph)
|
| 437 |
+
_, mesh = self.processor_decoder(mesh_edges, mesh, self.mesh_graph)
|
| 438 |
+
grid = self.decoder(self.decoder_embedder(self.m2g_edata), grid, mesh, self.m2g_graph)
|
| 439 |
+
output = self.finale(grid).T.reshape(self.output_dim_grid_nodes, *self.input_res)
|
| 440 |
+
return output.unsqueeze(0)
|
| 441 |
+
|
| 442 |
+
def to(self, *args: Any, **kwargs: Any) -> "OneForecast":
|
| 443 |
+
super().to(*args, **kwargs)
|
| 444 |
+
if self._graph_ready:
|
| 445 |
+
device = self.device_buffer.device
|
| 446 |
+
self.mesh_graph.to(device)
|
| 447 |
+
self.g2m_graph.to(device)
|
| 448 |
+
self.m2g_graph.to(device)
|
| 449 |
+
return self
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
@dataclass(frozen=True)
|
| 453 |
+
class CheckpointReport:
|
| 454 |
+
checkpoint_path: str
|
| 455 |
+
checkpoint_keys: int
|
| 456 |
+
model_keys: int
|
| 457 |
+
missing_keys: tuple[str, ...]
|
| 458 |
+
unexpected_keys: tuple[str, ...]
|
| 459 |
+
shape_mismatches: tuple[str, ...]
|
| 460 |
+
|
| 461 |
+
@property
|
| 462 |
+
def compatible(self) -> bool:
|
| 463 |
+
return not (self.missing_keys or self.unexpected_keys or self.shape_mismatches)
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def _install_scalarfloat_safe_global() -> type[float]:
|
| 467 |
+
"""Allow the known ruamel ScalarFloat metadata type without importing ruamel."""
|
| 468 |
+
module_name = "ruamel.yaml.scalarfloat"
|
| 469 |
+
module = sys.modules.get(module_name)
|
| 470 |
+
if module is not None and hasattr(module, "ScalarFloat"):
|
| 471 |
+
scalar_float = module.ScalarFloat
|
| 472 |
+
else:
|
| 473 |
+
ruamel = sys.modules.setdefault("ruamel", types.ModuleType("ruamel"))
|
| 474 |
+
yaml_module = sys.modules.setdefault("ruamel.yaml", types.ModuleType("ruamel.yaml"))
|
| 475 |
+
module = types.ModuleType(module_name)
|
| 476 |
+
scalar_float = type("ScalarFloat", (float,), {})
|
| 477 |
+
scalar_float.__module__ = module_name
|
| 478 |
+
module.ScalarFloat = scalar_float
|
| 479 |
+
yaml_module.scalarfloat = module
|
| 480 |
+
ruamel.yaml = yaml_module
|
| 481 |
+
sys.modules[module_name] = module
|
| 482 |
+
torch.serialization.add_safe_globals([scalar_float])
|
| 483 |
+
return scalar_float
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def read_official_checkpoint(path: str | Path) -> tuple[dict[str, Tensor], dict[str, Any]]:
|
| 487 |
+
path = Path(path).expanduser().resolve()
|
| 488 |
+
_install_scalarfloat_safe_global()
|
| 489 |
+
checkpoint = torch.load(path, map_location="cpu", weights_only=True, mmap=True)
|
| 490 |
+
if not isinstance(checkpoint, dict) or "model_state" not in checkpoint:
|
| 491 |
+
raise ValueError(f"{path} does not contain an official model_state")
|
| 492 |
+
state = checkpoint["model_state"]
|
| 493 |
+
if not isinstance(state, dict):
|
| 494 |
+
raise TypeError("checkpoint model_state must be a mapping")
|
| 495 |
+
cleaned = {key.removeprefix("module."): value for key, value in state.items()}
|
| 496 |
+
metadata = {key: value for key, value in checkpoint.items() if key not in {"model_state", "optimizer_state_dict"}}
|
| 497 |
+
return cleaned, metadata
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
def _compare_checkpoint_state(model: nn.Module, checkpoint_state: dict[str, Tensor],
|
| 501 |
+
path: str | Path) -> CheckpointReport:
|
| 502 |
+
model_state = model.state_dict()
|
| 503 |
+
missing = tuple(sorted(set(model_state) - set(checkpoint_state)))
|
| 504 |
+
unexpected = tuple(sorted(set(checkpoint_state) - set(model_state)))
|
| 505 |
+
mismatches = tuple(sorted(
|
| 506 |
+
f"{key}: checkpoint={tuple(checkpoint_state[key].shape)} model={tuple(model_state[key].shape)}"
|
| 507 |
+
for key in set(model_state) & set(checkpoint_state)
|
| 508 |
+
if model_state[key].shape != checkpoint_state[key].shape
|
| 509 |
+
))
|
| 510 |
+
return CheckpointReport(str(Path(path).expanduser().resolve()), len(checkpoint_state),
|
| 511 |
+
len(model_state), missing, unexpected, mismatches)
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
def check_checkpoint_compatibility(model: nn.Module, path: str | Path) -> CheckpointReport:
|
| 515 |
+
checkpoint_state, _ = read_official_checkpoint(path)
|
| 516 |
+
return _compare_checkpoint_state(model, checkpoint_state, path)
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
def load_official_checkpoint(model: nn.Module, path: str | Path, strict: bool = True) -> CheckpointReport:
|
| 520 |
+
state, _ = read_official_checkpoint(path)
|
| 521 |
+
report = _compare_checkpoint_state(model, state, path)
|
| 522 |
+
if strict and not report.compatible:
|
| 523 |
+
raise RuntimeError(f"Official checkpoint is incompatible: {report}")
|
| 524 |
+
compatible = {key: value for key, value in state.items()
|
| 525 |
+
if key in model.state_dict() and value.shape == model.state_dict()[key].shape}
|
| 526 |
+
model.load_state_dict(compatible, strict=strict)
|
| 527 |
+
return report
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
def build_model(config: dict[str, Any], build_graph: bool = True) -> OneForecast:
|
| 531 |
+
settings = config["model"]
|
| 532 |
+
model = OneForecast(
|
| 533 |
+
input_res=(settings["grid_height"], settings["grid_width"]),
|
| 534 |
+
input_dim_grid_nodes=settings["input_channels"],
|
| 535 |
+
output_dim_grid_nodes=settings["output_channels"],
|
| 536 |
+
mesh_level=settings.get("mesh_level", 5),
|
| 537 |
+
processor_layers=settings.get("processor_layers", 16),
|
| 538 |
+
hidden_layers=settings.get("hidden_layers", 1),
|
| 539 |
+
hidden_dim=settings.get("hidden_dim", 512),
|
| 540 |
+
num_heads_edge=settings.get("num_heads_edge", 4),
|
| 541 |
+
num_heads_node=settings.get("num_heads_node", 4),
|
| 542 |
+
build_graph=build_graph,
|
| 543 |
+
)
|
| 544 |
+
initialization = settings.get("weight_init", "scratch")
|
| 545 |
+
if initialization == "official":
|
| 546 |
+
load_official_checkpoint(model, settings["checkpoint_path"])
|
| 547 |
+
elif initialization != "scratch":
|
| 548 |
+
raise ValueError("model.weight_init must be 'scratch' or 'official'")
|
| 549 |
+
return model
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
__all__ = ["CheckpointReport", "OneForecast", "build_model", "check_checkpoint_compatibility",
|
| 553 |
+
"load_official_checkpoint", "read_official_checkpoint"]
|
scripts/fake_data.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate metadata-compatible ERA5 HDF5 fixtures at the native 0.25 degree grid."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import h5py
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
OFFICIAL_VARIABLES = (
|
| 13 |
+
[f"Z{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
|
| 14 |
+
+ [f"Q{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
|
| 15 |
+
+ [f"T{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
|
| 16 |
+
+ [f"U{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
|
| 17 |
+
+ [f"V{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
|
| 18 |
+
+ ["U10M", "V10M", "T2M", "MSLP"]
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
VARIABLE_ALIASES = {
|
| 22 |
+
**{f"Z{x}": f"geopotential_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
|
| 23 |
+
**{f"Q{x}": f"specific_humidity_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
|
| 24 |
+
**{f"T{x}": f"temperature_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
|
| 25 |
+
**{f"U{x}": f"u_component_of_wind_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
|
| 26 |
+
**{f"V{x}": f"v_component_of_wind_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
|
| 27 |
+
"U10M": "10m_u_component_of_wind",
|
| 28 |
+
"V10M": "10m_v_component_of_wind",
|
| 29 |
+
"T2M": "2m_temperature",
|
| 30 |
+
"MSLP": "mean_sea_level_pressure",
|
| 31 |
+
}
|
| 32 |
+
REAL_VARIABLES = tuple(VARIABLE_ALIASES[name] for name in OFFICIAL_VARIABLES)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _synthetic_field(time_index: int, channel: int, height: int, width: int) -> np.ndarray:
|
| 36 |
+
lat = np.linspace(1.0, -1.0, height, dtype=np.float32)[:, None]
|
| 37 |
+
lon = np.linspace(0.0, 2.0 * np.pi, width, endpoint=False, dtype=np.float32)[None, :]
|
| 38 |
+
phase = np.float32(channel * 0.17)
|
| 39 |
+
field = np.sin(lon + phase) + 0.4 * np.cos(np.float32(time_index / 3.0) + phase) + 0.2 * lat
|
| 40 |
+
return np.asarray(field, dtype=np.float32)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def generate_fake_h5(output_dir: Path, years: list[int], stats_years: set[int], time_steps: int,
|
| 44 |
+
height: int, width: int, seed: int) -> None:
|
| 45 |
+
data_dir = output_dir / "data"
|
| 46 |
+
data_dir.mkdir(parents=True, exist_ok=True)
|
| 47 |
+
sums = np.zeros(len(OFFICIAL_VARIABLES), dtype=np.float64)
|
| 48 |
+
squared_sums = np.zeros_like(sums)
|
| 49 |
+
value_count = 0
|
| 50 |
+
|
| 51 |
+
for offset, year in enumerate(years):
|
| 52 |
+
path = data_dir / f"{year}.h5"
|
| 53 |
+
with h5py.File(path, "w") as handle:
|
| 54 |
+
dataset = handle.create_dataset(
|
| 55 |
+
"fields", shape=(time_steps, len(OFFICIAL_VARIABLES), height, width),
|
| 56 |
+
dtype="float32", chunks=(1, 1, height, width),
|
| 57 |
+
)
|
| 58 |
+
dataset.attrs["variables"] = list(REAL_VARIABLES)
|
| 59 |
+
dataset.attrs["time_step"] = 6
|
| 60 |
+
for time_index in range(time_steps):
|
| 61 |
+
for channel in range(len(OFFICIAL_VARIABLES)):
|
| 62 |
+
field = _synthetic_field(time_index + offset, channel, height, width)
|
| 63 |
+
dataset[time_index, channel] = field
|
| 64 |
+
if year in stats_years:
|
| 65 |
+
sums[channel] += field.sum(dtype=np.float64)
|
| 66 |
+
squared_sums[channel] += np.square(field, dtype=np.float64).sum()
|
| 67 |
+
if year in stats_years:
|
| 68 |
+
value_count += time_steps * height * width
|
| 69 |
+
print(f"{path}: fields={(time_steps, len(OFFICIAL_VARIABLES), height, width)}, variables={len(OFFICIAL_VARIABLES)}")
|
| 70 |
+
|
| 71 |
+
means = (sums / value_count).reshape(1, -1, 1, 1)
|
| 72 |
+
variances = squared_sums / value_count - np.square(means.reshape(-1))
|
| 73 |
+
stds = np.sqrt(np.maximum(variances, 1e-12)).reshape(1, -1, 1, 1)
|
| 74 |
+
stats_dir = output_dir / "stats"
|
| 75 |
+
stats_dir.mkdir(parents=True, exist_ok=True)
|
| 76 |
+
np.save(stats_dir / "global_means.npy", means)
|
| 77 |
+
np.save(stats_dir / "global_stds.npy", stds)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def main() -> None:
|
| 81 |
+
parser = argparse.ArgumentParser()
|
| 82 |
+
parser.add_argument("--output-dir", type=Path, default=Path("./data"))
|
| 83 |
+
parser.add_argument("--years", nargs="+", type=int, default=[2000, 2001, 2002])
|
| 84 |
+
parser.add_argument("--stats-years", nargs="+", type=int, default=None)
|
| 85 |
+
parser.add_argument("--time-steps", type=int, default=3)
|
| 86 |
+
parser.add_argument("--height", type=int, default=721)
|
| 87 |
+
parser.add_argument("--width", type=int, default=1440)
|
| 88 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 89 |
+
args = parser.parse_args()
|
| 90 |
+
if len(args.years) < 1 or min(args.time_steps, args.height, args.width) < 1:
|
| 91 |
+
parser.error("years, time-steps, height, and width must be positive")
|
| 92 |
+
if (args.height, args.width) != (721, 1440):
|
| 93 |
+
parser.error("ERA5 fixtures must use the native 0.25 degree grid 721x1440")
|
| 94 |
+
stats_years = set(args.stats_years or args.years[:1])
|
| 95 |
+
if not stats_years.issubset(args.years):
|
| 96 |
+
parser.error("stats-years must be included in years")
|
| 97 |
+
generate_fake_h5(args.output_dir, args.years, stats_years, args.time_steps,
|
| 98 |
+
args.height, args.width, args.seed)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
if __name__ == "__main__":
|
| 102 |
+
main()
|
scripts/finetune.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-step autoregressive fine-tuning for OneForecast."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import sys
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
import torch.distributed as dist
|
| 11 |
+
from torch.nn.parallel import DistributedDataParallel
|
| 12 |
+
from torch.utils.checkpoint import checkpoint
|
| 13 |
+
import yaml
|
| 14 |
+
|
| 15 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 16 |
+
|
| 17 |
+
from model.era5_adapter import OFFICIAL_VARIABLES, OneForecastERA5Adapter
|
| 18 |
+
from model.oneforecast import build_model, read_official_checkpoint
|
| 19 |
+
from scripts.train import _LossScaleFunction, _reduce_metrics, _relative_channel_l2, _set_seed, _setup_distributed
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _resolve_path(value: str | Path, config_path: Path) -> Path:
|
| 23 |
+
path = Path(value).expanduser()
|
| 24 |
+
return path if path.is_absolute() else (config_path.parent.parent / path).resolve()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _prepare_batch(batch: tuple, steps: int) -> tuple[torch.Tensor, torch.Tensor]:
|
| 28 |
+
inputs, targets = batch[0], batch[1]
|
| 29 |
+
if inputs.ndim != 4 or targets.ndim != 5:
|
| 30 |
+
raise ValueError(f"Expected [B,C,H,W] inputs and [B,S,C,H,W] targets, got {inputs.shape} and {targets.shape}")
|
| 31 |
+
if targets.shape[1] != steps:
|
| 32 |
+
raise ValueError(f"Expected {steps} target steps, got {targets.shape[1]}")
|
| 33 |
+
if inputs.shape[-2] == 121:
|
| 34 |
+
inputs = inputs[..., :120, :]
|
| 35 |
+
if targets.shape[-2] == 121:
|
| 36 |
+
targets = targets[..., :120, :]
|
| 37 |
+
if inputs.shape[-2:] != (120, 240) or targets.shape[-2:] != (120, 240):
|
| 38 |
+
raise ValueError(f"Expected official model grid 120x240, got {inputs.shape} and {targets.shape}")
|
| 39 |
+
return torch.nan_to_num(inputs.float()), torch.nan_to_num(targets.float())
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def main() -> None:
|
| 43 |
+
parser = argparse.ArgumentParser()
|
| 44 |
+
parser.add_argument("--config", type=Path, default=Path("conf/config.yaml"))
|
| 45 |
+
parser.add_argument("--model-source", choices=("trained", "official"), default=None)
|
| 46 |
+
parser.add_argument("--max-epochs", type=int, default=None)
|
| 47 |
+
parser.add_argument("--max-batches", type=int, default=None)
|
| 48 |
+
args = parser.parse_args()
|
| 49 |
+
config_path = args.config.resolve()
|
| 50 |
+
with config_path.open("r", encoding="utf-8") as handle:
|
| 51 |
+
config = yaml.safe_load(handle)
|
| 52 |
+
if tuple(config["datapipe"]["variables"]) != OFFICIAL_VARIABLES:
|
| 53 |
+
raise ValueError("datapipe.variables must exactly match the official 69-channel order")
|
| 54 |
+
|
| 55 |
+
settings = config["datapipe"]
|
| 56 |
+
finetune = config["finetuning"]
|
| 57 |
+
if args.max_epochs is not None:
|
| 58 |
+
finetune["max_epoch"] = args.max_epochs
|
| 59 |
+
if args.max_batches is not None:
|
| 60 |
+
finetune["max_batches"] = args.max_batches
|
| 61 |
+
steps = int(finetune["steps"])
|
| 62 |
+
if steps < 2:
|
| 63 |
+
raise ValueError("finetuning.steps must be at least 2")
|
| 64 |
+
source = args.model_source or finetune.get("model_source", "trained")
|
| 65 |
+
checkpoint_path = _resolve_path(
|
| 66 |
+
finetune["trained_model_path"] if source == "trained" else finetune["official_checkpoint_path"],
|
| 67 |
+
config_path,
|
| 68 |
+
)
|
| 69 |
+
output_path = _resolve_path(finetune["output_path"], config_path)
|
| 70 |
+
dataset_dir = _resolve_path(settings["dataset_dir"], config_path)
|
| 71 |
+
|
| 72 |
+
device, rank, world_size, distributed = _setup_distributed(
|
| 73 |
+
config["runtime"].get("device", "cpu"), config["runtime"].get("distributed_backend", "nccl")
|
| 74 |
+
)
|
| 75 |
+
_set_seed(int(config["runtime"].get("seed", 42)))
|
| 76 |
+
config["model"]["weight_init"] = "scratch"
|
| 77 |
+
model = build_model(config).to(device)
|
| 78 |
+
state, _ = read_official_checkpoint(checkpoint_path)
|
| 79 |
+
model.load_state_dict(state)
|
| 80 |
+
if distributed:
|
| 81 |
+
ddp_devices = {"device_ids": [device.index], "output_device": device.index} if device.type == "cuda" else {}
|
| 82 |
+
model = DistributedDataParallel(model, broadcast_buffers=False, **ddp_devices)
|
| 83 |
+
optimizer = torch.optim.Adam(model.parameters(), lr=float(finetune["learning_rate"]))
|
| 84 |
+
adapter = OneForecastERA5Adapter(
|
| 85 |
+
dataset_dir, settings["train_years"], batch_size=settings["batch_size"],
|
| 86 |
+
input_steps=1, output_steps=steps, normalize=settings["normalize"],
|
| 87 |
+
num_workers=settings["num_workers"], distributed=distributed,
|
| 88 |
+
)
|
| 89 |
+
loader, sampler = adapter.get_dataloader("train")
|
| 90 |
+
max_batches = int(finetune.get("max_batches", -1))
|
| 91 |
+
|
| 92 |
+
for epoch in range(int(finetune["max_epoch"])):
|
| 93 |
+
if sampler is not None:
|
| 94 |
+
sampler.set_epoch(epoch)
|
| 95 |
+
model.train()
|
| 96 |
+
epoch_loss = 0.0
|
| 97 |
+
batches = 0
|
| 98 |
+
for batch in loader:
|
| 99 |
+
inputs, targets = _prepare_batch(batch, steps)
|
| 100 |
+
current = inputs.to(device)
|
| 101 |
+
targets = targets.to(device)
|
| 102 |
+
optimizer.zero_grad(set_to_none=True)
|
| 103 |
+
loss = torch.zeros((), device=device)
|
| 104 |
+
for step in range(steps):
|
| 105 |
+
current = checkpoint(model, current, use_reentrant=False)
|
| 106 |
+
scaled = _LossScaleFunction.apply(current, 1e-5)
|
| 107 |
+
step_loss, _ = _relative_channel_l2(scaled, targets[:, step])
|
| 108 |
+
loss = loss + step_loss
|
| 109 |
+
loss.backward()
|
| 110 |
+
optimizer.step()
|
| 111 |
+
epoch_loss += float(loss.detach())
|
| 112 |
+
batches += 1
|
| 113 |
+
if max_batches >= 0 and batches >= max_batches:
|
| 114 |
+
break
|
| 115 |
+
mean_loss = _reduce_metrics(epoch_loss, batches, device, distributed)
|
| 116 |
+
if rank == 0:
|
| 117 |
+
print({"epoch": epoch + 1, "steps": steps, "loss": mean_loss,
|
| 118 |
+
"batches_per_rank": batches, "world_size": world_size})
|
| 119 |
+
|
| 120 |
+
if rank == 0:
|
| 121 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 122 |
+
state = model.module.state_dict() if distributed else model.state_dict()
|
| 123 |
+
torch.save({"model_state": state, "epoch": int(finetune["max_epoch"]),
|
| 124 |
+
"finetune_steps": steps, "world_size": world_size}, output_path)
|
| 125 |
+
print({"checkpoint": str(output_path)})
|
| 126 |
+
if distributed:
|
| 127 |
+
dist.destroy_process_group()
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
if __name__ == "__main__":
|
| 131 |
+
main()
|
scripts/inference.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OneForecast inference entry point with the shared ERA5 adapter."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import sys
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import torch
|
| 11 |
+
import yaml
|
| 12 |
+
|
| 13 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 14 |
+
|
| 15 |
+
from model.era5_adapter import OFFICIAL_VARIABLES, OneForecastERA5Adapter
|
| 16 |
+
from model.oneforecast import build_model, check_checkpoint_compatibility, read_official_checkpoint
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _resolve_path(value: str | Path, config_path: Path) -> Path:
|
| 20 |
+
path = Path(value).expanduser()
|
| 21 |
+
return path if path.is_absolute() else (config_path.parent.parent / path).resolve()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _load_config(path: Path) -> dict:
|
| 25 |
+
with path.open("r", encoding="utf-8") as handle:
|
| 26 |
+
config = yaml.safe_load(handle)
|
| 27 |
+
config["datapipe"]["dataset_dir"] = str(_resolve_path(config["datapipe"]["dataset_dir"], path))
|
| 28 |
+
config["model"]["official_checkpoint_path"] = str(
|
| 29 |
+
_resolve_path(config["model"]["official_checkpoint_path"], path)
|
| 30 |
+
)
|
| 31 |
+
config["model"]["checkpoint_path"] = config["model"]["official_checkpoint_path"]
|
| 32 |
+
config["inference"]["trained_model_path"] = str(
|
| 33 |
+
_resolve_path(config["inference"]["trained_model_path"], path)
|
| 34 |
+
)
|
| 35 |
+
config["inference"]["official_checkpoint_path"] = str(
|
| 36 |
+
_resolve_path(config["inference"]["official_checkpoint_path"], path)
|
| 37 |
+
)
|
| 38 |
+
config["inference"]["output_dir"] = str(_resolve_path(config["inference"]["output_dir"], path))
|
| 39 |
+
return config
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _resolve_device(name: str) -> torch.device:
|
| 43 |
+
"""Map the logical DCU name to the backend exposed by this PyTorch build."""
|
| 44 |
+
requested = str(name).lower()
|
| 45 |
+
if requested == "dcu":
|
| 46 |
+
if torch.cuda.is_available():
|
| 47 |
+
return torch.device("cuda")
|
| 48 |
+
privateuse = torch._C._get_privateuse1_backend_name()
|
| 49 |
+
if privateuse != "privateuseone":
|
| 50 |
+
return torch.device(privateuse)
|
| 51 |
+
raise RuntimeError("runtime.device=dcu, but this PyTorch build exposes no usable accelerator")
|
| 52 |
+
if requested == "auto":
|
| 53 |
+
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 54 |
+
device = torch.device(requested)
|
| 55 |
+
if device.type == "cuda" and not torch.cuda.is_available():
|
| 56 |
+
raise RuntimeError("runtime.device=cuda, but torch.cuda.is_available() is False")
|
| 57 |
+
return device
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def main() -> None:
|
| 61 |
+
parser = argparse.ArgumentParser()
|
| 62 |
+
parser.add_argument("--config", type=Path, default=Path("conf/config.yaml"))
|
| 63 |
+
parser.add_argument("--check-data", action="store_true")
|
| 64 |
+
parser.add_argument("--check-model", action="store_true")
|
| 65 |
+
parser.add_argument("--check-checkpoint", action="store_true")
|
| 66 |
+
parser.add_argument("--model-source", choices=("trained", "official"), default=None)
|
| 67 |
+
args = parser.parse_args()
|
| 68 |
+
config = _load_config(args.config.resolve())
|
| 69 |
+
if tuple(config["datapipe"]["variables"]) != OFFICIAL_VARIABLES:
|
| 70 |
+
raise ValueError("datapipe.variables must exactly match the official 69-channel order")
|
| 71 |
+
if args.model_source is not None:
|
| 72 |
+
config["inference"]["model_source"] = args.model_source
|
| 73 |
+
if args.check_data:
|
| 74 |
+
settings = config["datapipe"]
|
| 75 |
+
adapter = OneForecastERA5Adapter(
|
| 76 |
+
settings["dataset_dir"], settings["test_years"], batch_size=1,
|
| 77 |
+
input_steps=settings["input_steps"], output_steps=settings["output_steps"],
|
| 78 |
+
normalize=settings["normalize"], num_workers=settings["num_workers"],
|
| 79 |
+
)
|
| 80 |
+
print(adapter.inspect())
|
| 81 |
+
return
|
| 82 |
+
if args.check_model:
|
| 83 |
+
configured_init = config["model"].get("weight_init", "scratch")
|
| 84 |
+
config["model"]["weight_init"] = "scratch"
|
| 85 |
+
with __import__("torch").device("meta"):
|
| 86 |
+
model = build_model(config, build_graph=False)
|
| 87 |
+
print({"model": type(model).__name__, "parameters": sum(p.numel() for p in model.parameters()),
|
| 88 |
+
"configured_weight_init": configured_init})
|
| 89 |
+
return
|
| 90 |
+
if args.check_checkpoint:
|
| 91 |
+
with __import__("torch").device("meta"):
|
| 92 |
+
model = build_model(config, build_graph=False)
|
| 93 |
+
report = check_checkpoint_compatibility(
|
| 94 |
+
model, config["model"]["official_checkpoint_path"]
|
| 95 |
+
)
|
| 96 |
+
print(report)
|
| 97 |
+
if not report.compatible:
|
| 98 |
+
raise SystemExit(1)
|
| 99 |
+
return
|
| 100 |
+
settings = config["datapipe"]
|
| 101 |
+
if settings["input_steps"] != 1 or settings["output_steps"] != 1:
|
| 102 |
+
raise SystemExit("OneForecast inference currently requires input_steps=1 and output_steps=1")
|
| 103 |
+
device = _resolve_device(config["runtime"].get("device", "cpu"))
|
| 104 |
+
config["model"]["weight_init"] = "scratch"
|
| 105 |
+
model = build_model(config).to(device)
|
| 106 |
+
source = config["inference"].get("model_source", "trained")
|
| 107 |
+
checkpoint_path = config["inference"][
|
| 108 |
+
"trained_model_path" if source == "trained" else "official_checkpoint_path"
|
| 109 |
+
]
|
| 110 |
+
state, _ = read_official_checkpoint(checkpoint_path)
|
| 111 |
+
model.load_state_dict(state)
|
| 112 |
+
model.eval()
|
| 113 |
+
adapter = OneForecastERA5Adapter(
|
| 114 |
+
_resolve_path(settings["dataset_dir"], args.config), settings["test_years"],
|
| 115 |
+
batch_size=1, input_steps=1, output_steps=1,
|
| 116 |
+
normalize=settings["normalize"], num_workers=settings["num_workers"],
|
| 117 |
+
)
|
| 118 |
+
loader, _ = adapter.get_dataloader("test")
|
| 119 |
+
output_dir = Path(config["inference"]["output_dir"])
|
| 120 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 121 |
+
max_batches = int(config["inference"].get("max_batches", -1))
|
| 122 |
+
processed = 0
|
| 123 |
+
with torch.no_grad():
|
| 124 |
+
for index, batch in enumerate(loader):
|
| 125 |
+
inputs, targets = batch[0].float().to(device), batch[1].float().to(device)
|
| 126 |
+
if inputs.ndim == 5 or targets.ndim == 5:
|
| 127 |
+
raise ValueError("OneForecast currently supports input_steps=1 and output_steps=1 only")
|
| 128 |
+
if inputs.ndim != 4:
|
| 129 |
+
raise ValueError(f"Expected batched input with four dimensions, got {inputs.shape}")
|
| 130 |
+
if inputs.shape[-2] == 121:
|
| 131 |
+
inputs = inputs[..., :120, :]
|
| 132 |
+
if targets.shape[-2] == 121:
|
| 133 |
+
targets = targets[..., :120, :]
|
| 134 |
+
if inputs.shape[-2:] != (120, 240) or targets.shape[-2:] != (120, 240):
|
| 135 |
+
raise ValueError(f"Expected official model grid 120x240, got {inputs.shape} and {targets.shape}")
|
| 136 |
+
prediction = model(torch.nan_to_num(inputs))
|
| 137 |
+
if settings["normalize"]:
|
| 138 |
+
means, stds = adapter.selected_statistics()
|
| 139 |
+
prediction = prediction.cpu() * torch.from_numpy(stds).float() + torch.from_numpy(means).float()
|
| 140 |
+
np.save(output_dir / f"prediction_{index:05d}.npy", prediction.cpu().numpy())
|
| 141 |
+
processed += 1
|
| 142 |
+
if max_batches >= 0 and index + 1 >= max_batches:
|
| 143 |
+
break
|
| 144 |
+
print({"output_dir": str(output_dir), "batches": processed})
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
if __name__ == "__main__":
|
| 148 |
+
main()
|
scripts/result.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Create quick field images from OneForecast prediction files."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import argparse
|
| 7 |
+
import numpy as np
|
| 8 |
+
import yaml
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def main() -> None:
|
| 12 |
+
parser = argparse.ArgumentParser()
|
| 13 |
+
parser.add_argument("--config", type=Path, default=Path("conf/config.yaml"))
|
| 14 |
+
args = parser.parse_args()
|
| 15 |
+
with args.config.open("r", encoding="utf-8") as handle:
|
| 16 |
+
config = yaml.safe_load(handle)
|
| 17 |
+
root = args.config.resolve().parent.parent
|
| 18 |
+
input_dir = Path(config["visualization"]["input_dir"])
|
| 19 |
+
output_dir = Path(config["visualization"]["output_dir"])
|
| 20 |
+
if not input_dir.is_absolute():
|
| 21 |
+
input_dir = root / input_dir
|
| 22 |
+
if not output_dir.is_absolute():
|
| 23 |
+
output_dir = root / output_dir
|
| 24 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 25 |
+
files = sorted(input_dir.glob("prediction_*.npy"))
|
| 26 |
+
if not files:
|
| 27 |
+
raise SystemExit(f"No prediction files found in {input_dir}")
|
| 28 |
+
import matplotlib.pyplot as plt
|
| 29 |
+
|
| 30 |
+
channels = config["visualization"].get("channels", [0])
|
| 31 |
+
for source in files:
|
| 32 |
+
prediction = np.load(source)
|
| 33 |
+
if prediction.shape != (1, 69, 120, 240):
|
| 34 |
+
raise ValueError(f"Expected official prediction shape [1, 69, 120, 240], got {prediction.shape}")
|
| 35 |
+
field = prediction[0]
|
| 36 |
+
for channel in channels:
|
| 37 |
+
if channel < 0 or channel >= field.shape[0]:
|
| 38 |
+
raise ValueError(f"Channel {channel} is outside prediction shape {field.shape}")
|
| 39 |
+
figure, axis = plt.subplots(figsize=(8, 3.5))
|
| 40 |
+
image = axis.imshow(field[channel], cmap="coolwarm", aspect="auto")
|
| 41 |
+
axis.set_title(f"{source.stem}, channel {channel}")
|
| 42 |
+
axis.set_xlabel("longitude index")
|
| 43 |
+
axis.set_ylabel("latitude index")
|
| 44 |
+
figure.colorbar(image, ax=axis, shrink=0.8)
|
| 45 |
+
figure.tight_layout()
|
| 46 |
+
figure.savefig(output_dir / f"{source.stem}_ch{channel}.png", dpi=160)
|
| 47 |
+
plt.close(figure)
|
| 48 |
+
print({"input_dir": str(input_dir), "output_dir": str(output_dir), "files": len(files)})
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
main()
|
scripts/train.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OneForecast training entry point with integrated data checking."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
import sys
|
| 9 |
+
import random
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import torch
|
| 13 |
+
import torch.distributed as dist
|
| 14 |
+
from torch.nn import functional as F
|
| 15 |
+
from torch.autograd import Function
|
| 16 |
+
from torch.nn.parallel import DistributedDataParallel
|
| 17 |
+
|
| 18 |
+
import yaml
|
| 19 |
+
|
| 20 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 21 |
+
|
| 22 |
+
from model.era5_adapter import OFFICIAL_VARIABLES, OneForecastERA5Adapter
|
| 23 |
+
from model.oneforecast import build_model, check_checkpoint_compatibility
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _resolve_path(value: str | Path, config_path: Path) -> Path:
|
| 27 |
+
path = Path(value).expanduser()
|
| 28 |
+
return path if path.is_absolute() else (config_path.parent.parent / path).resolve()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _load_config(path: Path) -> dict:
|
| 32 |
+
with path.open("r", encoding="utf-8") as handle:
|
| 33 |
+
config = yaml.safe_load(handle)
|
| 34 |
+
config["datapipe"]["dataset_dir"] = str(_resolve_path(config["datapipe"]["dataset_dir"], path))
|
| 35 |
+
config["model"]["official_checkpoint_path"] = str(
|
| 36 |
+
_resolve_path(config["model"]["official_checkpoint_path"], path)
|
| 37 |
+
)
|
| 38 |
+
config["model"]["checkpoint_path"] = config["model"]["official_checkpoint_path"]
|
| 39 |
+
config["training"]["checkpoint_dir"] = str(_resolve_path(config["training"]["checkpoint_dir"], path))
|
| 40 |
+
return config
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _set_seed(seed: int) -> None:
|
| 44 |
+
random.seed(seed)
|
| 45 |
+
np.random.seed(seed)
|
| 46 |
+
torch.manual_seed(seed)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _resolve_device(name: str) -> torch.device:
|
| 50 |
+
"""Map the logical DCU name to the backend exposed by this PyTorch build."""
|
| 51 |
+
requested = str(name).lower()
|
| 52 |
+
if requested == "dcu":
|
| 53 |
+
if torch.cuda.is_available():
|
| 54 |
+
return torch.device("cuda")
|
| 55 |
+
privateuse = torch._C._get_privateuse1_backend_name()
|
| 56 |
+
if privateuse != "privateuseone":
|
| 57 |
+
return torch.device(privateuse)
|
| 58 |
+
raise RuntimeError("runtime.device=dcu, but this PyTorch build exposes no usable accelerator")
|
| 59 |
+
if requested == "auto":
|
| 60 |
+
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 61 |
+
device = torch.device(requested)
|
| 62 |
+
if device.type == "cuda" and not torch.cuda.is_available():
|
| 63 |
+
raise RuntimeError("runtime.device=cuda, but torch.cuda.is_available() is False")
|
| 64 |
+
return device
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _setup_distributed(device_name: str, backend: str = "nccl") -> tuple[torch.device, int, int, bool]:
|
| 68 |
+
world_size = int(os.environ.get("WORLD_SIZE", "1"))
|
| 69 |
+
distributed = world_size > 1
|
| 70 |
+
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
|
| 71 |
+
if distributed:
|
| 72 |
+
device = _resolve_device(device_name)
|
| 73 |
+
if device.type == "cuda":
|
| 74 |
+
torch.cuda.set_device(local_rank)
|
| 75 |
+
device = torch.device("cuda", local_rank)
|
| 76 |
+
dist.init_process_group(backend=backend, init_method="env://")
|
| 77 |
+
return device, dist.get_rank(), world_size, True
|
| 78 |
+
return _resolve_device(device_name), 0, 1, False
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _reduce_metrics(total: float, count: int, device: torch.device, distributed: bool) -> float:
|
| 82 |
+
metrics = torch.tensor([total, count], dtype=torch.float64, device=device)
|
| 83 |
+
if distributed:
|
| 84 |
+
dist.all_reduce(metrics, op=dist.ReduceOp.SUM)
|
| 85 |
+
return float(metrics[0] / metrics[1].clamp_min(1))
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _loader_batch(batch: tuple) -> tuple[torch.Tensor, torch.Tensor]:
|
| 89 |
+
inputs, targets = batch[0], batch[1]
|
| 90 |
+
if inputs.ndim == 5 or targets.ndim == 5:
|
| 91 |
+
raise ValueError("OneForecast currently supports input_steps=1 and output_steps=1 only")
|
| 92 |
+
if inputs.ndim != 4 or targets.ndim != 4:
|
| 93 |
+
raise ValueError(f"Expected batched fields with four dimensions, got {inputs.shape} and {targets.shape}")
|
| 94 |
+
if inputs.shape[-2] == 121:
|
| 95 |
+
inputs = inputs[..., :120, :]
|
| 96 |
+
if targets.shape[-2] == 121:
|
| 97 |
+
targets = targets[..., :120, :]
|
| 98 |
+
if inputs.shape[-2:] != (120, 240) or targets.shape[-2:] != (120, 240):
|
| 99 |
+
raise ValueError(f"Expected official model grid 120x240, got {inputs.shape} and {targets.shape}")
|
| 100 |
+
return torch.nan_to_num(inputs.float()), torch.nan_to_num(targets.float())
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class _LossScaleFunction(Function):
|
| 104 |
+
@staticmethod
|
| 105 |
+
def forward(ctx, values: torch.Tensor, eps: float) -> torch.Tensor:
|
| 106 |
+
ctx.eps = eps
|
| 107 |
+
return values
|
| 108 |
+
|
| 109 |
+
@staticmethod
|
| 110 |
+
def backward(ctx, gradients: torch.Tensor) -> tuple[torch.Tensor, None]:
|
| 111 |
+
channels = gradients.shape[1]
|
| 112 |
+
weights = 1.0 / gradients.norm(p=2, dim=(-1, -2), keepdim=True).clamp_min(ctx.eps)
|
| 113 |
+
weights = weights / weights.sum(dim=1, keepdim=True).clamp_min(ctx.eps)
|
| 114 |
+
return channels * weights * gradients, None
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _relative_channel_l2(prediction: torch.Tensor, target: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
| 118 |
+
difference = (prediction - target).flatten(2).norm(p=2, dim=2)
|
| 119 |
+
target_norm = target.flatten(2).norm(p=2, dim=2).clamp_min(1e-10)
|
| 120 |
+
channel_loss = (difference / target_norm).mean(dim=0)
|
| 121 |
+
return channel_loss.mean(), channel_loss
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def check_data(config: dict) -> dict:
|
| 125 |
+
settings = config["datapipe"]
|
| 126 |
+
adapter = OneForecastERA5Adapter(
|
| 127 |
+
settings["dataset_dir"], settings["train_years"],
|
| 128 |
+
batch_size=settings["batch_size"], input_steps=settings["input_steps"],
|
| 129 |
+
output_steps=settings["output_steps"], normalize=settings["normalize"],
|
| 130 |
+
num_workers=settings["num_workers"],
|
| 131 |
+
)
|
| 132 |
+
report = adapter.inspect()
|
| 133 |
+
print(report)
|
| 134 |
+
return report
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def main() -> None:
|
| 138 |
+
parser = argparse.ArgumentParser()
|
| 139 |
+
parser.add_argument("--config", type=Path, default=Path("conf/config.yaml"))
|
| 140 |
+
parser.add_argument("--check-data", action="store_true")
|
| 141 |
+
parser.add_argument("--check-model", action="store_true")
|
| 142 |
+
parser.add_argument("--check-checkpoint", action="store_true")
|
| 143 |
+
parser.add_argument("--check-distributed", action="store_true")
|
| 144 |
+
parser.add_argument("--device", default=None)
|
| 145 |
+
parser.add_argument("--distributed-backend", default=None)
|
| 146 |
+
parser.add_argument("--max-epochs", type=int, default=None)
|
| 147 |
+
parser.add_argument("--max-batches", type=int, default=None)
|
| 148 |
+
parser.add_argument("--weight-init", choices=("scratch", "official"), default=None)
|
| 149 |
+
args = parser.parse_args()
|
| 150 |
+
config = _load_config(args.config.resolve())
|
| 151 |
+
if args.device is not None:
|
| 152 |
+
config["runtime"]["device"] = args.device
|
| 153 |
+
if args.distributed_backend is not None:
|
| 154 |
+
config["runtime"]["distributed_backend"] = args.distributed_backend
|
| 155 |
+
if args.max_epochs is not None:
|
| 156 |
+
config["training"]["max_epoch"] = args.max_epochs
|
| 157 |
+
if args.max_batches is not None:
|
| 158 |
+
config["training"]["max_batches"] = args.max_batches
|
| 159 |
+
if tuple(config["datapipe"]["variables"]) != OFFICIAL_VARIABLES:
|
| 160 |
+
raise ValueError("datapipe.variables must exactly match the official 69-channel order")
|
| 161 |
+
if args.weight_init is not None:
|
| 162 |
+
config["model"]["weight_init"] = args.weight_init
|
| 163 |
+
if config["model"].get("weight_init") == "official":
|
| 164 |
+
config["model"]["checkpoint_path"] = config["model"]["official_checkpoint_path"]
|
| 165 |
+
if args.check_data:
|
| 166 |
+
check_data(config)
|
| 167 |
+
return
|
| 168 |
+
if args.check_model:
|
| 169 |
+
configured_init = config["model"].get("weight_init", "scratch")
|
| 170 |
+
config["model"]["weight_init"] = "scratch"
|
| 171 |
+
with __import__("torch").device("meta"):
|
| 172 |
+
model = build_model(config, build_graph=False)
|
| 173 |
+
print({"model": type(model).__name__, "parameters": sum(p.numel() for p in model.parameters()),
|
| 174 |
+
"configured_weight_init": configured_init})
|
| 175 |
+
return
|
| 176 |
+
if args.check_checkpoint:
|
| 177 |
+
with __import__("torch").device("meta"):
|
| 178 |
+
model = build_model(config, build_graph=False)
|
| 179 |
+
report = check_checkpoint_compatibility(
|
| 180 |
+
model, config["model"]["official_checkpoint_path"]
|
| 181 |
+
)
|
| 182 |
+
print(report)
|
| 183 |
+
if not report.compatible:
|
| 184 |
+
raise SystemExit(1)
|
| 185 |
+
return
|
| 186 |
+
if args.check_distributed:
|
| 187 |
+
device, rank, world_size, distributed = _setup_distributed(
|
| 188 |
+
config["runtime"].get("device", "cpu"), config["runtime"].get("distributed_backend", "nccl")
|
| 189 |
+
)
|
| 190 |
+
settings = config["datapipe"]
|
| 191 |
+
adapter = OneForecastERA5Adapter(
|
| 192 |
+
settings["dataset_dir"], settings["train_years"], batch_size=settings["batch_size"],
|
| 193 |
+
input_steps=settings["input_steps"], output_steps=settings["output_steps"],
|
| 194 |
+
normalize=settings["normalize"], num_workers=0, distributed=distributed,
|
| 195 |
+
)
|
| 196 |
+
loader, sampler = adapter.get_dataloader("train")
|
| 197 |
+
sample_indices = list(iter(sampler)) if sampler is not None else list(range(len(loader.dataset)))
|
| 198 |
+
print({"rank": rank, "world_size": world_size, "distributed": distributed,
|
| 199 |
+
"backend": dist.get_backend() if distributed else None, "device": str(device),
|
| 200 |
+
"sampler": type(sampler).__name__ if sampler is not None else None,
|
| 201 |
+
"sample_indices": sample_indices})
|
| 202 |
+
if distributed:
|
| 203 |
+
dist.barrier()
|
| 204 |
+
dist.destroy_process_group()
|
| 205 |
+
return
|
| 206 |
+
settings = config["datapipe"]
|
| 207 |
+
if settings["input_steps"] != 1 or settings["output_steps"] != 1:
|
| 208 |
+
raise SystemExit("OneForecast training currently requires input_steps=1 and output_steps=1")
|
| 209 |
+
device, rank, world_size, distributed = _setup_distributed(
|
| 210 |
+
config["runtime"].get("device", "cpu"), config["runtime"].get("distributed_backend", "nccl")
|
| 211 |
+
)
|
| 212 |
+
_set_seed(int(config["runtime"].get("seed", 42)))
|
| 213 |
+
model = build_model(config).to(device)
|
| 214 |
+
if distributed:
|
| 215 |
+
ddp_devices = {"device_ids": [device.index], "output_device": device.index} if device.type == "cuda" else {}
|
| 216 |
+
model = DistributedDataParallel(model, broadcast_buffers=False, **ddp_devices)
|
| 217 |
+
optimizer = torch.optim.Adam(
|
| 218 |
+
model.parameters(), lr=float(config["training"]["learning_rate"]),
|
| 219 |
+
)
|
| 220 |
+
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
|
| 221 |
+
optimizer, T_max=max(1, int(config["training"]["max_epoch"])),
|
| 222 |
+
)
|
| 223 |
+
train_adapter = OneForecastERA5Adapter(
|
| 224 |
+
_resolve_path(settings["dataset_dir"], args.config), settings["train_years"],
|
| 225 |
+
batch_size=settings["batch_size"], input_steps=1, output_steps=1,
|
| 226 |
+
normalize=settings["normalize"], num_workers=settings["num_workers"], distributed=distributed,
|
| 227 |
+
)
|
| 228 |
+
valid_adapter = OneForecastERA5Adapter(
|
| 229 |
+
_resolve_path(settings["dataset_dir"], args.config), settings["valid_years"],
|
| 230 |
+
batch_size=settings["batch_size"], input_steps=1, output_steps=1,
|
| 231 |
+
normalize=settings["normalize"], num_workers=settings["num_workers"], distributed=distributed,
|
| 232 |
+
)
|
| 233 |
+
train_loader, train_sampler = train_adapter.get_dataloader("train")
|
| 234 |
+
valid_loader, valid_sampler = valid_adapter.get_dataloader("val")
|
| 235 |
+
checkpoint_dir = Path(config["training"]["checkpoint_dir"])
|
| 236 |
+
checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
| 237 |
+
max_batches = int(config["training"].get("max_batches", -1))
|
| 238 |
+
for epoch in range(int(config["training"]["start_epoch"]), int(config["training"]["max_epoch"])):
|
| 239 |
+
if train_sampler is not None:
|
| 240 |
+
train_sampler.set_epoch(epoch)
|
| 241 |
+
if valid_sampler is not None:
|
| 242 |
+
valid_sampler.set_epoch(epoch)
|
| 243 |
+
model.train()
|
| 244 |
+
train_loss = 0.0
|
| 245 |
+
train_batches = 0
|
| 246 |
+
for batch in train_loader:
|
| 247 |
+
inputs, targets = _loader_batch(batch)
|
| 248 |
+
optimizer.zero_grad(set_to_none=True)
|
| 249 |
+
prediction = _LossScaleFunction.apply(model(inputs.to(device)), 1e-5)
|
| 250 |
+
loss, _ = _relative_channel_l2(prediction, targets.to(device))
|
| 251 |
+
loss.backward()
|
| 252 |
+
optimizer.step()
|
| 253 |
+
train_loss += float(loss.detach())
|
| 254 |
+
train_batches += 1
|
| 255 |
+
if max_batches >= 0 and train_batches >= max_batches:
|
| 256 |
+
break
|
| 257 |
+
model.eval()
|
| 258 |
+
valid_loss = 0.0
|
| 259 |
+
with torch.no_grad():
|
| 260 |
+
valid_batches = 0
|
| 261 |
+
for batch in valid_loader:
|
| 262 |
+
inputs, targets = _loader_batch(batch)
|
| 263 |
+
prediction = model(inputs.to(device))
|
| 264 |
+
valid_loss += float(F.mse_loss(prediction, targets.to(device)))
|
| 265 |
+
valid_batches += 1
|
| 266 |
+
if max_batches >= 0 and valid_batches >= max_batches:
|
| 267 |
+
break
|
| 268 |
+
train_mean = _reduce_metrics(train_loss, train_batches, device, distributed)
|
| 269 |
+
valid_mean = _reduce_metrics(valid_loss, valid_batches, device, distributed)
|
| 270 |
+
if rank == 0:
|
| 271 |
+
print({"epoch": epoch + 1, "train_loss": train_mean, "valid_loss": valid_mean,
|
| 272 |
+
"world_size": world_size})
|
| 273 |
+
if rank == 0 and (epoch + 1) % int(config["training"].get("save_every_epoch", 1)) == 0:
|
| 274 |
+
model_name = config["training"].get("model_name", "model_bak")
|
| 275 |
+
state = model.module.state_dict() if distributed else model.state_dict()
|
| 276 |
+
torch.save({"model_state": state, "epoch": epoch + 1, "world_size": world_size},
|
| 277 |
+
checkpoint_dir / f"{model_name}.tar")
|
| 278 |
+
scheduler.step()
|
| 279 |
+
if distributed:
|
| 280 |
+
dist.destroy_process_group()
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
if __name__ == "__main__":
|
| 284 |
+
main()
|
weight/.gitkeep
ADDED
|
File without changes
|