Upload folder using huggingface_hub
Browse files- README.md +151 -0
- conf/config.yaml +54 -0
- config.json +77 -0
- configuration.json +12 -0
- model/earthformer.py +213 -0
- script/data_loader.py +121 -0
- script/fake_data.py +149 -0
- script/inference.py +48 -0
- script/metrics.py +77 -0
- script/result.py +159 -0
- script/train.py +161 -0
- script/utils.py +86 -0
- weight/.gitkeep +0 -0
README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
datasets:
|
| 3 |
+
- SEVIR
|
| 4 |
+
frameworks:
|
| 5 |
+
- PyTorch
|
| 6 |
+
language:
|
| 7 |
+
- en
|
| 8 |
+
- zh
|
| 9 |
+
license: apache-2.0
|
| 10 |
+
tags:
|
| 11 |
+
- OneScience
|
| 12 |
+
- Earth Science
|
| 13 |
+
- Weather Forecast
|
| 14 |
+
- Spatiotemporal Forecast
|
| 15 |
+
- SEVIR
|
| 16 |
+
- Earthformer
|
| 17 |
+
tasks: []
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
<p align="center">
|
| 21 |
+
<strong>
|
| 22 |
+
<span style="font-size: 30px;">Earthformer</span>
|
| 23 |
+
</strong>
|
| 24 |
+
</p>
|
| 25 |
+
|
| 26 |
+
# Model Introduction
|
| 27 |
+
|
| 28 |
+
Earthformer was proposed by researchers from Amazon Web Services (AWS) in collaboration with the Hong Kong University of Science and Technology. It aims to address the prohibitive computational cost of conventional Transformers when processing high-dimensional geophysical data.
|
| 29 |
+
|
| 30 |
+
Earthformer: Exploring Space-Time Transformers for Earth System Forecasting
|
| 31 |
+
|
| 32 |
+
https://arxiv.org/abs/2207.05833
|
| 33 |
+
|
| 34 |
+
# Model Description
|
| 35 |
+
|
| 36 |
+
Earthformer is a space-time Transformer model designed for Earth system forecasting, such as weather and climate. Its core component is a novel attention mechanism called Cuboid Attention.
|
| 37 |
+
|
| 38 |
+
# Use Cases
|
| 39 |
+
|
| 40 |
+
| Scenario | Description |
|
| 41 |
+
| :---: | :--- |
|
| 42 |
+
| Weather Forecast Training | Train the Earthformer weather forecasting model using SEVIR structured data. |
|
| 43 |
+
| Local Quick Validation | Use synthetic data to verify data loading, model training, inference, and inference result visualization. |
|
| 44 |
+
| ModelScope / OneCode Execution | Download as a standalone model package, install dependencies, and run scripts directly. |
|
| 45 |
+
| Multi-GPU Training | Launch multi-process training via `torchrun`. |
|
| 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 |
+
The commands below assume they are run from the Earthformer project root directory.
|
| 58 |
+
|
| 59 |
+
**Hardware Requirements**
|
| 60 |
+
|
| 61 |
+
- Training and inference require a GPU or DCU recognized by PyTorch; CPU can be used to generate synthetic data and verify configuration, but cannot run the current training and inference scripts.
|
| 62 |
+
- Multi-GPU training uses the NCCL backend. Please make sure the device driver, communication libraries, and PyTorch version are compatible.
|
| 63 |
+
- DCU users must install DTK in advance. DTK 25.04.2 or above, or the OneScience recommended version matching your cluster, is recommended.
|
| 64 |
+
|
| 65 |
+
### Download the Model Package
|
| 66 |
+
|
| 67 |
+
```bash
|
| 68 |
+
hf download OneScience-Group/Earthformer --local-dir ./Earthformer
|
| 69 |
+
cd Earthformer
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
### Install the Runtime Environment
|
| 73 |
+
|
| 74 |
+
**DCU Environment**
|
| 75 |
+
|
| 76 |
+
```bash
|
| 77 |
+
# Please activate DTK and CONDA first
|
| 78 |
+
conda create -n onescience311 python=3.11 -y
|
| 79 |
+
conda activate onescience311
|
| 80 |
+
# uv installation is supported
|
| 81 |
+
pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
**GPU Environment**
|
| 85 |
+
|
| 86 |
+
```bash
|
| 87 |
+
# Please activate CONDA first
|
| 88 |
+
conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
|
| 89 |
+
conda activate onescience311
|
| 90 |
+
# uv installation is supported
|
| 91 |
+
pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
### Generate Synthetic Data
|
| 95 |
+
|
| 96 |
+
Synthetic data is only used to verify the data protocol and program flow; it does not represent real SEVIR data or the model's forecast quality:
|
| 97 |
+
|
| 98 |
+
```bash
|
| 99 |
+
python script/fake_data.py
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
By default this generates `data/synthetic_sevir/{train,val,test}.npz` and `metadata.json`. To generate synthetic data matching the official SEVIR spatial size:
|
| 103 |
+
|
| 104 |
+
```bash
|
| 105 |
+
python script/fake_data.py --output-dir data/synthetic_sevir_384 --height 384 --width 384
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
### Training
|
| 109 |
+
|
| 110 |
+
Single GPU:
|
| 111 |
+
|
| 112 |
+
```bash
|
| 113 |
+
python script/train.py
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
Multi-GPU:
|
| 117 |
+
|
| 118 |
+
```bash
|
| 119 |
+
torchrun --nproc_per_node=8 script/train.py
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
Training starts from random initialization and saves to `data/checkpoint/earthformer.pt` by default.
|
| 123 |
+
|
| 124 |
+
### Training Weights
|
| 125 |
+
|
| 126 |
+
This repository provides weights trained on SEVIR data in the `weights/` folder. The weight files will be uploaded soon and are expected to be available in the near future.
|
| 127 |
+
|
| 128 |
+
### Inference
|
| 129 |
+
|
| 130 |
+
Inference reads the training checkpoint by default and writes the output to `output/predictions.npz`.
|
| 131 |
+
|
| 132 |
+
```bash
|
| 133 |
+
python script/inference.py
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
### Evaluation and Visualization
|
| 137 |
+
|
| 138 |
+
```bash
|
| 139 |
+
python script/result.py
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
# OneScience Official Information
|
| 143 |
+
|
| 144 |
+
| Platform | OneScience Main Repository | Skills Repository |
|
| 145 |
+
| --- | --- | --- |
|
| 146 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 147 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 148 |
+
|
| 149 |
+
# Citation & License
|
| 150 |
+
|
| 151 |
+
- This repository is the OneScience reproduction of the original Earthformer paper. The official Earthformer implementation is released under the Apache License 2.0; the use of this repository's code and the SEVIR data remains subject to the licenses and terms of use of the respective projects.
|
conf/config.yaml
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
data:
|
| 2 |
+
protocol: synthetic_sevir
|
| 3 |
+
# Reference dataset: official SEVIR VIL (earthformer_sevir_v1.yaml) uses
|
| 4 |
+
# 384x384 frames at 5-minute intervals, seq_len = 25 (13 input + 12 output),
|
| 5 |
+
# "sequent" sampling with stride 12, single channel. The small spatial shape
|
| 6 |
+
# below is intentionally the default for CPU smoke verification.
|
| 7 |
+
input_length: 13
|
| 8 |
+
output_length: 12
|
| 9 |
+
height: 32
|
| 10 |
+
width: 32
|
| 11 |
+
channels: 1
|
| 12 |
+
frame_interval_minutes: 5
|
| 13 |
+
normalization: unit
|
| 14 |
+
data_dir: data/synthetic_sevir
|
| 15 |
+
train_samples: 8
|
| 16 |
+
val_samples: 2
|
| 17 |
+
test_samples: 2
|
| 18 |
+
train_npz: data/synthetic_sevir/train.npz
|
| 19 |
+
val_npz: data/synthetic_sevir/val.npz
|
| 20 |
+
test_npz: data/synthetic_sevir/test.npz
|
| 21 |
+
fallback_if_missing: true
|
| 22 |
+
|
| 23 |
+
model:
|
| 24 |
+
dims: [4, 8]
|
| 25 |
+
depths: [1, 1]
|
| 26 |
+
heads: 1
|
| 27 |
+
pattern: [[2, 4, 4]]
|
| 28 |
+
num_global_vectors: 1
|
| 29 |
+
ff_ratio: 2.0
|
| 30 |
+
dropout: 0.0
|
| 31 |
+
|
| 32 |
+
train:
|
| 33 |
+
seed: 42
|
| 34 |
+
# Training always starts from a randomly initialized model (from scratch).
|
| 35 |
+
# The --resume flag only restores a locally trained checkpoint for continued
|
| 36 |
+
# training; no official pretrained Earthformer weights are ever downloaded.
|
| 37 |
+
batch_size: 1
|
| 38 |
+
epochs: 5
|
| 39 |
+
validation_steps: 1
|
| 40 |
+
# Per-epoch validation computes only cheap MSE/MAE (the training loss is
|
| 41 |
+
# already MSE). Enable this to also compute SSIM/CSI every epoch; the
|
| 42 |
+
# authoritative full evaluation lives in script/result.py.
|
| 43 |
+
compute_full_metrics: false
|
| 44 |
+
learning_rate: 0.001
|
| 45 |
+
weight_decay: 0.00001
|
| 46 |
+
# Trained weights for inference are saved here as earthformer.pt.
|
| 47 |
+
output_dir: data/checkpoint
|
| 48 |
+
|
| 49 |
+
dataloader:
|
| 50 |
+
num_workers: 0
|
| 51 |
+
pin_memory: false
|
| 52 |
+
|
| 53 |
+
distributed:
|
| 54 |
+
backend: auto
|
config.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_name": "Earthformer",
|
| 3 |
+
"model_type": "earthformer",
|
| 4 |
+
"architectures": [
|
| 5 |
+
"Earthformer"
|
| 6 |
+
],
|
| 7 |
+
"framework": "PyTorch",
|
| 8 |
+
"domain": "climate-and-atmosphere",
|
| 9 |
+
"task": "spatiotemporal-forecasting",
|
| 10 |
+
"implementation": {
|
| 11 |
+
"entry_point": "model/earthformer.py",
|
| 12 |
+
"scope": "two-level hierarchical Cuboid Attention space-time Transformer with BTHWC input and output"
|
| 13 |
+
},
|
| 14 |
+
"architecture": {
|
| 15 |
+
"family": "hierarchical space-time Transformer with Cuboid Attention",
|
| 16 |
+
"attention_mechanism": "cuboid-window attention with optional global vectors",
|
| 17 |
+
"input_format": "BTHWC",
|
| 18 |
+
"encoder": {
|
| 19 |
+
"levels": 2,
|
| 20 |
+
"stem": "Conv2d 3x3 stride 2",
|
| 21 |
+
"downsample": "Conv2d 3x3 stride 2",
|
| 22 |
+
"cuboid_pattern": [
|
| 23 |
+
[
|
| 24 |
+
2,
|
| 25 |
+
4,
|
| 26 |
+
4
|
| 27 |
+
]
|
| 28 |
+
]
|
| 29 |
+
},
|
| 30 |
+
"decoder": {
|
| 31 |
+
"cuboid_pattern": "axial",
|
| 32 |
+
"cross_attention": "CuboidCross(T,1,1): future queries attend to history at each spatial site",
|
| 33 |
+
"prediction": "learned future query with per-frame up-projection and encoder skip connection"
|
| 34 |
+
},
|
| 35 |
+
"activation": "GELU",
|
| 36 |
+
"normalization": "LayerNorm",
|
| 37 |
+
"repository_default_config": {
|
| 38 |
+
"purpose": "CPU smoke verification with synthetic SEVIR data",
|
| 39 |
+
"dims": [
|
| 40 |
+
4,
|
| 41 |
+
8
|
| 42 |
+
],
|
| 43 |
+
"depths": [
|
| 44 |
+
1,
|
| 45 |
+
1
|
| 46 |
+
],
|
| 47 |
+
"heads": 1,
|
| 48 |
+
"num_global_vectors": 1,
|
| 49 |
+
"ff_ratio": 2.0,
|
| 50 |
+
"dropout": 0.0
|
| 51 |
+
}
|
| 52 |
+
},
|
| 53 |
+
"data": {
|
| 54 |
+
"dataset": "SEVIR",
|
| 55 |
+
"variable": "vertically_integrated_liquid",
|
| 56 |
+
"frame_interval_minutes": 5,
|
| 57 |
+
"input_length": 13,
|
| 58 |
+
"output_length": 12,
|
| 59 |
+
"channels": 1,
|
| 60 |
+
"official_spatial_size": [
|
| 61 |
+
384,
|
| 62 |
+
384
|
| 63 |
+
],
|
| 64 |
+
"official_sampling": "sequent with stride 12",
|
| 65 |
+
"default_smoke_spatial_size": [
|
| 66 |
+
32,
|
| 67 |
+
32
|
| 68 |
+
],
|
| 69 |
+
"normalization": "unit"
|
| 70 |
+
},
|
| 71 |
+
"configuration_sources": [
|
| 72 |
+
"conf/config.yaml",
|
| 73 |
+
"model/earthformer.py",
|
| 74 |
+
"script/data_loader.py",
|
| 75 |
+
"configuration.json"
|
| 76 |
+
]
|
| 77 |
+
}
|
configuration.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"framework": "PyTorch",
|
| 3 |
+
"task": "spatiotemporal_forecasting",
|
| 4 |
+
"model": "Earthformer",
|
| 5 |
+
"input_format": "BTHWC",
|
| 6 |
+
"protocol": "synthetic_sevir",
|
| 7 |
+
"default_config": "conf/config.yaml",
|
| 8 |
+
"train": "script/train.py",
|
| 9 |
+
"inference": "script/inference.py",
|
| 10 |
+
"evaluation": "script/result.py",
|
| 11 |
+
"visualization": "script/result.py"
|
| 12 |
+
}
|
model/earthformer.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
from torch import nn
|
| 7 |
+
from torch.nn import functional as F
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class CuboidMeta:
|
| 12 |
+
batch: int
|
| 13 |
+
shape: tuple[int, int, int]
|
| 14 |
+
padded: tuple[int, int, int]
|
| 15 |
+
cuboid: tuple[int, int, int]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def cuboid_partition(x: torch.Tensor, cuboid: tuple[int, int, int]) -> tuple[torch.Tensor, CuboidMeta]:
|
| 19 |
+
b, t, h, w, c = x.shape
|
| 20 |
+
bt, bh, bw = (min(size, dim) for size, dim in zip(cuboid, (t, h, w)))
|
| 21 |
+
pt, ph, pw = (-t) % bt, (-h) % bh, (-w) % bw
|
| 22 |
+
padded = F.pad(x.permute(0, 4, 1, 2, 3), (0, pw, 0, ph, 0, pt)).permute(0, 2, 3, 4, 1)
|
| 23 |
+
tp, hp, wp = padded.shape[1:4]
|
| 24 |
+
windows = padded.reshape(b, tp // bt, bt, hp // bh, bh, wp // bw, bw, c)
|
| 25 |
+
windows = windows.permute(0, 1, 3, 5, 2, 4, 6, 7).reshape(-1, bt * bh * bw, c)
|
| 26 |
+
return windows, CuboidMeta(b, (t, h, w), (tp, hp, wp), (bt, bh, bw))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def cuboid_merge(windows: torch.Tensor, meta: CuboidMeta) -> torch.Tensor:
|
| 30 |
+
b, (t, h, w), (tp, hp, wp), (bt, bh, bw) = meta.batch, meta.shape, meta.padded, meta.cuboid
|
| 31 |
+
c = windows.shape[-1]
|
| 32 |
+
x = windows.reshape(b, tp // bt, hp // bh, wp // bw, bt, bh, bw, c)
|
| 33 |
+
x = x.permute(0, 1, 4, 2, 5, 3, 6, 7).reshape(b, tp, hp, wp, c)
|
| 34 |
+
return x[:, :t, :h, :w]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class FeedForward(nn.Module):
|
| 38 |
+
def __init__(self, dim: int, ratio: float, dropout: float):
|
| 39 |
+
super().__init__()
|
| 40 |
+
hidden = int(dim * ratio)
|
| 41 |
+
self.net = nn.Sequential(nn.Linear(dim, hidden), nn.GELU(), nn.Dropout(dropout), nn.Linear(hidden, dim))
|
| 42 |
+
|
| 43 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 44 |
+
return self.net(x)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class CuboidAttentionLayer(nn.Module):
|
| 48 |
+
def __init__(self, dim: int, heads: int, cuboid: tuple[int, int, int], ff_ratio: float, dropout: float, use_global: bool):
|
| 49 |
+
super().__init__()
|
| 50 |
+
self.cuboid = cuboid
|
| 51 |
+
self.use_global = use_global
|
| 52 |
+
self.local_norm = nn.LayerNorm(dim)
|
| 53 |
+
self.global_norm = nn.LayerNorm(dim) if use_global else None
|
| 54 |
+
self.local_attention = nn.MultiheadAttention(dim, heads, dropout=dropout, batch_first=True)
|
| 55 |
+
self.global_attention = nn.MultiheadAttention(dim, heads, dropout=dropout, batch_first=True) if use_global else None
|
| 56 |
+
self.local_ff_norm = nn.LayerNorm(dim)
|
| 57 |
+
self.local_ff = FeedForward(dim, ff_ratio, dropout)
|
| 58 |
+
self.global_ff_norm = nn.LayerNorm(dim) if use_global else None
|
| 59 |
+
self.global_ff = FeedForward(dim, ff_ratio, dropout) if use_global else None
|
| 60 |
+
|
| 61 |
+
def forward(self, x: torch.Tensor, global_vectors: torch.Tensor | None) -> tuple[torch.Tensor, torch.Tensor | None]:
|
| 62 |
+
normalized = self.local_norm(x)
|
| 63 |
+
windows, meta = cuboid_partition(normalized, self.cuboid)
|
| 64 |
+
if self.use_global:
|
| 65 |
+
if global_vectors is None:
|
| 66 |
+
raise ValueError("global vectors are required when use_global=True")
|
| 67 |
+
windows_per_batch = windows.shape[0] // x.shape[0]
|
| 68 |
+
repeated_global = self.global_norm(global_vectors).repeat_interleave(windows_per_batch, dim=0)
|
| 69 |
+
key_value = torch.cat((windows, repeated_global), dim=1)
|
| 70 |
+
else:
|
| 71 |
+
key_value = windows
|
| 72 |
+
attended = self.local_attention(windows, key_value, key_value, need_weights=False)[0]
|
| 73 |
+
x = x + cuboid_merge(attended, meta)
|
| 74 |
+
x = x + self.local_ff(self.local_ff_norm(x))
|
| 75 |
+
if self.use_global:
|
| 76 |
+
global_query = self.global_norm(global_vectors)
|
| 77 |
+
all_tokens = self.local_norm(x).reshape(x.shape[0], -1, x.shape[-1])
|
| 78 |
+
global_kv = torch.cat((global_query, all_tokens), dim=1)
|
| 79 |
+
global_vectors = global_vectors + self.global_attention(global_query, global_kv, global_kv, need_weights=False)[0]
|
| 80 |
+
global_vectors = global_vectors + self.global_ff(self.global_ff_norm(global_vectors))
|
| 81 |
+
return x, global_vectors
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def resolve_pattern(pattern: str | list[list[int]], shape: tuple[int, int, int]) -> list[tuple[int, int, int]]:
|
| 85 |
+
if pattern == "axial":
|
| 86 |
+
t, h, w = shape
|
| 87 |
+
return [(t, 1, 1), (1, h, 1), (1, 1, w)]
|
| 88 |
+
return [tuple(int(value) for value in item) for item in pattern]
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class CuboidBlock(nn.Module):
|
| 92 |
+
def __init__(self, dim: int, heads: int, pattern: str | list[list[int]], shape: tuple[int, int, int], ff_ratio: float, dropout: float, use_global: bool):
|
| 93 |
+
super().__init__()
|
| 94 |
+
self.layers = nn.ModuleList(
|
| 95 |
+
CuboidAttentionLayer(dim, heads, cuboid, ff_ratio, dropout, use_global)
|
| 96 |
+
for cuboid in resolve_pattern(pattern, shape)
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
def forward(self, x: torch.Tensor, global_vectors: torch.Tensor | None) -> tuple[torch.Tensor, torch.Tensor | None]:
|
| 100 |
+
for layer in self.layers:
|
| 101 |
+
x, global_vectors = layer(x, global_vectors)
|
| 102 |
+
return x, global_vectors
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class CuboidCrossAttention(nn.Module):
|
| 106 |
+
"""CuboidCross(T,1,1): future queries attend to history at each spatial site."""
|
| 107 |
+
|
| 108 |
+
def __init__(self, dim: int, heads: int, ff_ratio: float, dropout: float):
|
| 109 |
+
super().__init__()
|
| 110 |
+
self.query_norm = nn.LayerNorm(dim)
|
| 111 |
+
self.memory_norm = nn.LayerNorm(dim)
|
| 112 |
+
self.attention = nn.MultiheadAttention(dim, heads, dropout=dropout, batch_first=True)
|
| 113 |
+
self.ff_norm = nn.LayerNorm(dim)
|
| 114 |
+
self.ff = FeedForward(dim, ff_ratio, dropout)
|
| 115 |
+
|
| 116 |
+
def forward(self, query: torch.Tensor, memory: torch.Tensor) -> torch.Tensor:
|
| 117 |
+
b, k, h, w, c = query.shape
|
| 118 |
+
if memory.shape[0] != b or memory.shape[2:4] != (h, w):
|
| 119 |
+
raise ValueError("cross-attention memory must match batch and spatial dimensions")
|
| 120 |
+
q = self.query_norm(query).permute(0, 2, 3, 1, 4).reshape(b * h * w, k, c)
|
| 121 |
+
m = self.memory_norm(memory).permute(0, 2, 3, 1, 4).reshape(b * h * w, memory.shape[1], c)
|
| 122 |
+
attended = self.attention(q, m, m, need_weights=False)[0]
|
| 123 |
+
attended = attended.reshape(b, h, w, k, c).permute(0, 3, 1, 2, 4)
|
| 124 |
+
query = query + attended
|
| 125 |
+
return query + self.ff(self.ff_norm(query))
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class DecoderBlock(nn.Module):
|
| 129 |
+
def __init__(self, dim: int, heads: int, pattern: str | list[list[int]], shape: tuple[int, int, int], ff_ratio: float, dropout: float, use_global: bool):
|
| 130 |
+
super().__init__()
|
| 131 |
+
self.self_block = CuboidBlock(dim, heads, pattern, shape, ff_ratio, dropout, use_global)
|
| 132 |
+
self.cross = CuboidCrossAttention(dim, heads, ff_ratio, dropout)
|
| 133 |
+
|
| 134 |
+
def forward(self, x: torch.Tensor, memory: torch.Tensor, global_vectors: torch.Tensor | None) -> tuple[torch.Tensor, torch.Tensor | None]:
|
| 135 |
+
x, global_vectors = self.self_block(x, global_vectors)
|
| 136 |
+
return self.cross(x, memory), global_vectors
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class Earthformer(nn.Module):
|
| 140 |
+
"""Two-level Cuboid Attention Earthformer with BTHWC input and output."""
|
| 141 |
+
|
| 142 |
+
def __init__(self, config: dict):
|
| 143 |
+
super().__init__()
|
| 144 |
+
data, model = config["data"], config["model"]
|
| 145 |
+
self.input_length = int(data["input_length"])
|
| 146 |
+
self.output_length = int(data["output_length"])
|
| 147 |
+
self.height, self.width = int(data["height"]), int(data["width"])
|
| 148 |
+
channels = int(data["channels"])
|
| 149 |
+
d0, d1 = (int(value) for value in model["dims"])
|
| 150 |
+
depths = model["depths"]
|
| 151 |
+
heads = int(model["heads"])
|
| 152 |
+
pattern = model.get("pattern", "axial")
|
| 153 |
+
ff_ratio, dropout = float(model.get("ff_ratio", 2.0)), float(model.get("dropout", 0.0))
|
| 154 |
+
self.num_global = int(model.get("num_global_vectors", 0))
|
| 155 |
+
use_global = self.num_global > 0
|
| 156 |
+
h0, w0, h1, w1 = self.height // 2, self.width // 2, self.height // 4, self.width // 4
|
| 157 |
+
self.stem = nn.Conv2d(channels, d0, 3, stride=2, padding=1)
|
| 158 |
+
self.downsample = nn.Conv2d(d0, d1, 3, stride=2, padding=1)
|
| 159 |
+
self.encoder_pos0 = nn.Parameter(torch.zeros(1, self.input_length, h0, w0, d0))
|
| 160 |
+
self.encoder_pos1 = nn.Parameter(torch.zeros(1, self.input_length, h1, w1, d1))
|
| 161 |
+
self.future_query = nn.Parameter(torch.empty(1, self.output_length, h1, w1, d1))
|
| 162 |
+
nn.init.trunc_normal_(self.future_query, std=0.02)
|
| 163 |
+
self.encoder0 = nn.ModuleList(CuboidBlock(d0, heads, pattern, (self.input_length, h0, w0), ff_ratio, dropout, use_global) for _ in range(depths[0]))
|
| 164 |
+
self.encoder1 = nn.ModuleList(CuboidBlock(d1, heads, pattern, (self.input_length, h1, w1), ff_ratio, dropout, use_global) for _ in range(depths[1]))
|
| 165 |
+
self.decoder1 = nn.ModuleList(DecoderBlock(d1, heads, "axial", (self.output_length, h1, w1), ff_ratio, dropout, use_global) for _ in range(depths[1]))
|
| 166 |
+
self.decoder0 = nn.ModuleList(DecoderBlock(d0, heads, "axial", (self.output_length, h0, w0), ff_ratio, dropout, use_global) for _ in range(depths[0]))
|
| 167 |
+
self.up_project = nn.Conv2d(d1, d0, 3, padding=1)
|
| 168 |
+
self.skip_project = nn.Linear(d0, d0)
|
| 169 |
+
self.head = nn.Conv2d(d0, channels, 3, padding=1)
|
| 170 |
+
if use_global:
|
| 171 |
+
self.encoder_global0 = nn.Parameter(torch.zeros(1, self.num_global, d0))
|
| 172 |
+
self.encoder_global1 = nn.Parameter(torch.zeros(1, self.num_global, d1))
|
| 173 |
+
self.decoder_global1 = nn.Parameter(torch.zeros(1, self.num_global, d1))
|
| 174 |
+
self.decoder_global0 = nn.Parameter(torch.zeros(1, self.num_global, d0))
|
| 175 |
+
|
| 176 |
+
@staticmethod
|
| 177 |
+
def _frames(module: nn.Module, x: torch.Tensor) -> torch.Tensor:
|
| 178 |
+
b, t, h, w, c = x.shape
|
| 179 |
+
result = module(x.permute(0, 1, 4, 2, 3).reshape(b * t, c, h, w))
|
| 180 |
+
return result.reshape(b, t, result.shape[1], result.shape[2], result.shape[3]).permute(0, 1, 3, 4, 2)
|
| 181 |
+
|
| 182 |
+
def _global(self, name: str, batch: int) -> torch.Tensor | None:
|
| 183 |
+
value = getattr(self, name, None)
|
| 184 |
+
return value.expand(batch, -1, -1) if value is not None else None
|
| 185 |
+
|
| 186 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 187 |
+
expected = (self.input_length, self.height, self.width)
|
| 188 |
+
if x.ndim != 5 or x.shape[1:4] != expected:
|
| 189 |
+
raise ValueError(f"expected input [B,{expected[0]},{expected[1]},{expected[2]},C], got {tuple(x.shape)}")
|
| 190 |
+
batch = x.shape[0]
|
| 191 |
+
e0 = self._frames(self.stem, x) + self.encoder_pos0
|
| 192 |
+
g0 = self._global("encoder_global0", batch)
|
| 193 |
+
for block in self.encoder0:
|
| 194 |
+
e0, g0 = block(e0, g0)
|
| 195 |
+
e1 = self._frames(self.downsample, e0) + self.encoder_pos1
|
| 196 |
+
g1 = self._global("encoder_global1", batch)
|
| 197 |
+
for block in self.encoder1:
|
| 198 |
+
e1, g1 = block(e1, g1)
|
| 199 |
+
d1 = self.future_query.expand(batch, -1, -1, -1, -1)
|
| 200 |
+
gd1 = self._global("decoder_global1", batch)
|
| 201 |
+
for block in self.decoder1:
|
| 202 |
+
d1, gd1 = block(d1, e1, gd1)
|
| 203 |
+
b, k, h, w, c = d1.shape
|
| 204 |
+
up = F.interpolate(d1.permute(0, 1, 4, 2, 3).reshape(b * k, c, h, w), scale_factor=2, mode="nearest")
|
| 205 |
+
d0 = self.up_project(up).reshape(b, k, -1, h * 2, w * 2).permute(0, 1, 3, 4, 2)
|
| 206 |
+
d0 = d0 + self.skip_project(e0.mean(dim=1, keepdim=True)).expand(-1, k, -1, -1, -1)
|
| 207 |
+
gd0 = self._global("decoder_global0", batch)
|
| 208 |
+
for block in self.decoder0:
|
| 209 |
+
d0, gd0 = block(d0, e0, gd0)
|
| 210 |
+
b, k, h, w, c = d0.shape
|
| 211 |
+
full = F.interpolate(d0.permute(0, 1, 4, 2, 3).reshape(b * k, c, h, w), scale_factor=2, mode="nearest")
|
| 212 |
+
output = self.head(full)
|
| 213 |
+
return output.reshape(b, k, -1, self.height, self.width).permute(0, 1, 3, 4, 2)
|
script/data_loader.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import random
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
from torch.utils.data import DataLoader, Dataset, DistributedSampler
|
| 10 |
+
|
| 11 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 12 |
+
|
| 13 |
+
from script.fake_data import SPLIT_OFFSETS, generate_sequence
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class SyntheticSEVIRDataset(Dataset):
|
| 17 |
+
def __init__(self, size: int, config: dict, seed: int):
|
| 18 |
+
data = config["data"]
|
| 19 |
+
self.size = size
|
| 20 |
+
self.input_length = int(data["input_length"])
|
| 21 |
+
self.output_length = int(data["output_length"])
|
| 22 |
+
self.height = int(data["height"])
|
| 23 |
+
self.width = int(data["width"])
|
| 24 |
+
self.seed = seed
|
| 25 |
+
|
| 26 |
+
def __len__(self) -> int:
|
| 27 |
+
return self.size
|
| 28 |
+
|
| 29 |
+
def __getitem__(self, index: int) -> tuple[torch.Tensor, torch.Tensor]:
|
| 30 |
+
sequence = generate_sequence(
|
| 31 |
+
self.height, self.width, self.input_length, self.output_length, self.seed + index
|
| 32 |
+
)
|
| 33 |
+
tensor = torch.from_numpy(sequence)
|
| 34 |
+
return tensor[: self.input_length], tensor[self.input_length :]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class NPZSequenceDataset(Dataset):
|
| 38 |
+
def __init__(self, path: str | Path, config: dict):
|
| 39 |
+
data = config["data"]
|
| 40 |
+
with np.load(path) as payload:
|
| 41 |
+
if "inputs" not in payload or "targets" not in payload:
|
| 42 |
+
raise ValueError("NPZ must contain 'inputs' and 'targets'")
|
| 43 |
+
inputs, targets = payload["inputs"], payload["targets"]
|
| 44 |
+
expected_input = (int(data["input_length"]), int(data["height"]), int(data["width"]), int(data["channels"]))
|
| 45 |
+
expected_target = (int(data["output_length"]), int(data["height"]), int(data["width"]), int(data["channels"]))
|
| 46 |
+
if inputs.ndim != 5 or tuple(inputs.shape[1:]) != expected_input:
|
| 47 |
+
raise ValueError(f"inputs must have shape [N,{','.join(map(str, expected_input))}], got {inputs.shape}")
|
| 48 |
+
if targets.ndim != 5 or tuple(targets.shape[1:]) != expected_target:
|
| 49 |
+
raise ValueError(f"targets must have shape [N,{','.join(map(str, expected_target))}], got {targets.shape}")
|
| 50 |
+
if len(inputs) != len(targets) or len(inputs) == 0:
|
| 51 |
+
raise ValueError("inputs and targets must have the same non-zero sample count")
|
| 52 |
+
normalization = data.get("normalization", "unit")
|
| 53 |
+
if normalization == "uint8_255":
|
| 54 |
+
if inputs.dtype != np.uint8 or targets.dtype != np.uint8:
|
| 55 |
+
raise ValueError("uint8_255 normalization requires uint8 NPZ arrays")
|
| 56 |
+
inputs, targets = inputs.astype(np.float32) / 255.0, targets.astype(np.float32) / 255.0
|
| 57 |
+
else:
|
| 58 |
+
if not np.issubdtype(inputs.dtype, np.floating) or not np.issubdtype(targets.dtype, np.floating):
|
| 59 |
+
raise ValueError("unit normalization requires floating-point NPZ arrays")
|
| 60 |
+
inputs, targets = inputs.astype(np.float32), targets.astype(np.float32)
|
| 61 |
+
if not np.isfinite(inputs).all() or not np.isfinite(targets).all():
|
| 62 |
+
raise ValueError("NPZ arrays contain non-finite values")
|
| 63 |
+
if inputs.min() < 0 or inputs.max() > 1 or targets.min() < 0 or targets.max() > 1:
|
| 64 |
+
raise ValueError("unit-normalized NPZ arrays must be within [0,1]; float values are never implicitly divided by 255")
|
| 65 |
+
self.inputs = inputs
|
| 66 |
+
self.targets = targets
|
| 67 |
+
|
| 68 |
+
def __len__(self) -> int:
|
| 69 |
+
return len(self.inputs)
|
| 70 |
+
|
| 71 |
+
def __getitem__(self, index: int) -> tuple[torch.Tensor, torch.Tensor]:
|
| 72 |
+
return torch.from_numpy(self.inputs[index]), torch.from_numpy(self.targets[index])
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _seed_worker(worker_id: int) -> None:
|
| 76 |
+
del worker_id
|
| 77 |
+
worker_seed = torch.initial_seed() % 2**32
|
| 78 |
+
np.random.seed(worker_seed)
|
| 79 |
+
random.seed(worker_seed)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def make_loader(
|
| 83 |
+
config: dict,
|
| 84 |
+
split: str,
|
| 85 |
+
distributed: bool = False,
|
| 86 |
+
rank: int = 0,
|
| 87 |
+
world_size: int = 1,
|
| 88 |
+
shuffle: bool | None = None,
|
| 89 |
+
) -> tuple[DataLoader, DistributedSampler | None]:
|
| 90 |
+
if split not in SPLIT_OFFSETS:
|
| 91 |
+
raise ValueError(f"unknown split: {split}")
|
| 92 |
+
data, train = config["data"], config["train"]
|
| 93 |
+
path_value = data.get(f"{split}_npz")
|
| 94 |
+
path = Path(path_value) if path_value else None
|
| 95 |
+
if path is not None and path.is_file():
|
| 96 |
+
dataset: Dataset = NPZSequenceDataset(path, config)
|
| 97 |
+
elif bool(data.get("fallback_if_missing", True)):
|
| 98 |
+
dataset = SyntheticSEVIRDataset(
|
| 99 |
+
int(data[f"{split}_samples"]), config, int(train["seed"]) + SPLIT_OFFSETS[split]
|
| 100 |
+
)
|
| 101 |
+
else:
|
| 102 |
+
raise FileNotFoundError(f"configured {split} NPZ does not exist: {path}")
|
| 103 |
+
should_shuffle = split == "train" if shuffle is None else shuffle
|
| 104 |
+
sampler = None
|
| 105 |
+
if distributed:
|
| 106 |
+
sampler = DistributedSampler(
|
| 107 |
+
dataset, num_replicas=world_size, rank=rank, shuffle=should_shuffle, seed=int(train["seed"]), drop_last=False
|
| 108 |
+
)
|
| 109 |
+
generator = torch.Generator().manual_seed(int(train["seed"]) + SPLIT_OFFSETS[split] + rank)
|
| 110 |
+
options = config["dataloader"]
|
| 111 |
+
loader = DataLoader(
|
| 112 |
+
dataset,
|
| 113 |
+
batch_size=int(train["batch_size"]),
|
| 114 |
+
shuffle=should_shuffle and sampler is None,
|
| 115 |
+
sampler=sampler,
|
| 116 |
+
num_workers=int(options.get("num_workers", 0)),
|
| 117 |
+
pin_memory=bool(options.get("pin_memory", False)),
|
| 118 |
+
worker_init_fn=_seed_worker,
|
| 119 |
+
generator=generator,
|
| 120 |
+
)
|
| 121 |
+
return loader, sampler
|
script/fake_data.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
if __package__ in (None, ""):
|
| 11 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 12 |
+
|
| 13 |
+
from script.utils import DEFAULT_CONFIG, load_config
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
SPLIT_OFFSETS = {"train": 0, "val": 100_000, "test": 200_000}
|
| 17 |
+
|
| 18 |
+
# Matches the official Earthformer SEVIR config
|
| 19 |
+
# (scripts/cuboid_transformer/sevir/earthformer_sevir_v1.yaml):
|
| 20 |
+
# dataset.img_height/img_width = 384, in_len = 13, out_len = 12,
|
| 21 |
+
# seq_len = 25, interval_real_time = 5, sample_mode = "sequent",
|
| 22 |
+
# stride = 12, metrics_list = ['csi', 'pod', 'sucr', 'bias'],
|
| 23 |
+
# threshold_list = [16, 74, 133, 160, 181, 219].
|
| 24 |
+
SEVIR_VIL_REFERENCE = {
|
| 25 |
+
"dataset": "SEVIR VIL",
|
| 26 |
+
"spatial_shape": [384, 384, 1],
|
| 27 |
+
"sequence": {"input_frames": 13, "output_frames": 12},
|
| 28 |
+
"seq_len": 25,
|
| 29 |
+
"sample_mode": "sequent",
|
| 30 |
+
"stride": 12,
|
| 31 |
+
"frame_interval_minutes": 5,
|
| 32 |
+
"thresholds": [16, 74, 133, 160, 181, 219],
|
| 33 |
+
"layout": "train/val/test NPZ splits; each holds inputs [N,13,H,W,1] and targets [N,12,H,W,1]",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def generate_sequence(height: int, width: int, input_length: int, output_length: int, seed: int) -> np.ndarray:
|
| 38 |
+
"""Generate a continuous, temporally coherent 25-frame SEVIR-like VIL window.
|
| 39 |
+
|
| 40 |
+
The window is sampled exactly like official SEVIR "sequent" mode: a single
|
| 41 |
+
continuous sequence of seq_len = input_length + output_length frames is
|
| 42 |
+
produced and later split into the first 13 input and the last 12 target
|
| 43 |
+
frames, matching the official 13 -> 12 (65 -> 60 minute) task at 5-minute
|
| 44 |
+
intervals.
|
| 45 |
+
"""
|
| 46 |
+
rng = np.random.default_rng(seed)
|
| 47 |
+
total = input_length + output_length
|
| 48 |
+
yy, xx = np.mgrid[:height, :width]
|
| 49 |
+
background = rng.normal(0.0, 0.008, (height, width)).astype(np.float32)
|
| 50 |
+
background = (background + np.roll(background, 1, 0) + np.roll(background, 1, 1)) / 3.0
|
| 51 |
+
cell_count = int(rng.integers(2, 5))
|
| 52 |
+
cells = []
|
| 53 |
+
for _ in range(cell_count):
|
| 54 |
+
cells.append(
|
| 55 |
+
(
|
| 56 |
+
rng.uniform(0.15 * width, 0.85 * width),
|
| 57 |
+
rng.uniform(0.15 * height, 0.85 * height),
|
| 58 |
+
rng.uniform(-0.45, 0.45),
|
| 59 |
+
rng.uniform(-0.45, 0.45),
|
| 60 |
+
rng.uniform(max(1.2, width / 18), max(2.0, width / 8)),
|
| 61 |
+
rng.uniform(max(1.2, height / 18), max(2.0, height / 8)),
|
| 62 |
+
rng.uniform(0.45, 0.95),
|
| 63 |
+
rng.uniform(-0.035, 0.035),
|
| 64 |
+
rng.uniform(0, np.pi),
|
| 65 |
+
)
|
| 66 |
+
)
|
| 67 |
+
frames = np.empty((total, height, width, 1), dtype=np.float32)
|
| 68 |
+
for time in range(total):
|
| 69 |
+
frame = np.maximum(background * (0.8 + 0.2 * np.sin(time / 5)), 0.0)
|
| 70 |
+
for cx, cy, vx, vy, sx, sy, amplitude, growth, angle in cells:
|
| 71 |
+
dx, dy = xx - (cx + vx * time), yy - (cy + vy * time)
|
| 72 |
+
ca, sa = np.cos(angle), np.sin(angle)
|
| 73 |
+
xr, yr = ca * dx + sa * dy, -sa * dx + ca * dy
|
| 74 |
+
scale = np.clip(1.0 + growth * time, 0.55, 1.6)
|
| 75 |
+
intensity = amplitude * np.exp(-0.5 * ((xr / (sx * scale)) ** 2 + (yr / (sy * scale)) ** 2))
|
| 76 |
+
lifecycle = np.clip(1.0 + growth * time, 0.35, 1.25)
|
| 77 |
+
frame += intensity.astype(np.float32) * lifecycle
|
| 78 |
+
noise = rng.normal(0.0, 0.004, (height, width)).astype(np.float32)
|
| 79 |
+
frames[time, ..., 0] = np.clip(frame + noise, 0.0, 1.0)
|
| 80 |
+
return frames
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def generate_split(samples: int, height: int, width: int, input_length: int, output_length: int, seed: int) -> tuple[np.ndarray, np.ndarray]:
|
| 84 |
+
sequences = np.stack(
|
| 85 |
+
[generate_sequence(height, width, input_length, output_length, seed + index) for index in range(samples)]
|
| 86 |
+
)
|
| 87 |
+
return sequences[:, :input_length], sequences[:, input_length:]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def parse_args() -> argparse.Namespace:
|
| 91 |
+
config_parser = argparse.ArgumentParser(add_help=False)
|
| 92 |
+
config_parser.add_argument("--config", default=str(DEFAULT_CONFIG))
|
| 93 |
+
config_args, _ = config_parser.parse_known_args()
|
| 94 |
+
defaults = load_config(config_args.config)
|
| 95 |
+
data = defaults["data"]
|
| 96 |
+
parser = argparse.ArgumentParser(description="Generate deterministic synthetic SEVIR-like VIL sequences")
|
| 97 |
+
parser.add_argument("--config", default=config_args.config)
|
| 98 |
+
parser.add_argument("--output-dir", default=data["data_dir"])
|
| 99 |
+
parser.add_argument("--height", type=int, default=int(data["height"]))
|
| 100 |
+
parser.add_argument("--width", type=int, default=int(data["width"]))
|
| 101 |
+
parser.add_argument("--train-samples", type=int, default=int(data["train_samples"]))
|
| 102 |
+
parser.add_argument("--val-samples", type=int, default=int(data["val_samples"]))
|
| 103 |
+
parser.add_argument("--test-samples", type=int, default=int(data["test_samples"]))
|
| 104 |
+
parser.add_argument("--seed", type=int, default=int(defaults["train"]["seed"]))
|
| 105 |
+
return parser.parse_args()
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def main() -> None:
|
| 109 |
+
args = parse_args()
|
| 110 |
+
config = load_config(args.config)
|
| 111 |
+
data = config["data"]
|
| 112 |
+
output_dir = Path(args.output_dir)
|
| 113 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 114 |
+
split_sizes = {"train": args.train_samples, "val": args.val_samples, "test": args.test_samples}
|
| 115 |
+
for split, samples in split_sizes.items():
|
| 116 |
+
if samples <= 0:
|
| 117 |
+
raise ValueError(f"{split} samples must be positive")
|
| 118 |
+
inputs, targets = generate_split(
|
| 119 |
+
samples,
|
| 120 |
+
args.height,
|
| 121 |
+
args.width,
|
| 122 |
+
int(data["input_length"]),
|
| 123 |
+
int(data["output_length"]),
|
| 124 |
+
args.seed + SPLIT_OFFSETS[split],
|
| 125 |
+
)
|
| 126 |
+
np.savez_compressed(output_dir / f"{split}.npz", inputs=inputs, targets=targets)
|
| 127 |
+
metadata = {
|
| 128 |
+
"synthetic": True,
|
| 129 |
+
"official_sevir": False,
|
| 130 |
+
"description": "Deterministic synthetic SEVIR-like VIL; not official SEVIR data",
|
| 131 |
+
"protocol": "synthetic_sevir",
|
| 132 |
+
"reference": SEVIR_VIL_REFERENCE,
|
| 133 |
+
"seq_len": int(data["input_length"]) + int(data["output_length"]),
|
| 134 |
+
"sample_mode": "sequent",
|
| 135 |
+
"stride": SEVIR_VIL_REFERENCE["stride"],
|
| 136 |
+
"frame_interval_minutes": int(data["frame_interval_minutes"]),
|
| 137 |
+
"input_frames": int(data["input_length"]),
|
| 138 |
+
"output_frames": int(data["output_length"]),
|
| 139 |
+
"shape": [args.height, args.width, 1],
|
| 140 |
+
"normalization": "unit [0,1] float32",
|
| 141 |
+
"seed": args.seed,
|
| 142 |
+
"splits": split_sizes,
|
| 143 |
+
}
|
| 144 |
+
(output_dir / "metadata.json").write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
|
| 145 |
+
print(json.dumps({"output_dir": str(output_dir), "metadata": metadata}, indent=2))
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
if __name__ == "__main__":
|
| 149 |
+
main()
|
script/inference.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
|
| 11 |
+
if __package__ in (None, ""):
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 13 |
+
|
| 14 |
+
from model.earthformer import Earthformer
|
| 15 |
+
from script.data_loader import make_loader
|
| 16 |
+
from script.utils import clean_state_dict, load_checkpoint_payload, load_config, resolve_cli_path, resolve_device
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def parse_args() -> argparse.Namespace:
|
| 20 |
+
parser = argparse.ArgumentParser(description="Run Earthformer inference on the first batch")
|
| 21 |
+
parser.add_argument("--config", help="Optional data/config override; checkpoint config is used by default")
|
| 22 |
+
parser.add_argument("--checkpoint", default="data/checkpoint/earthformer.pt")
|
| 23 |
+
parser.add_argument("--split", choices=("train", "val", "test"), default="test")
|
| 24 |
+
parser.add_argument("--output", default="output/predictions.npz")
|
| 25 |
+
parser.add_argument("--device", choices=("auto", "cpu", "cuda"), default="auto")
|
| 26 |
+
return parser.parse_args()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def main() -> None:
|
| 30 |
+
args = parse_args()
|
| 31 |
+
device = resolve_device(args.device)
|
| 32 |
+
payload = load_checkpoint_payload(resolve_cli_path(args.checkpoint), device)
|
| 33 |
+
config = load_config(args.config) if args.config else payload["config"]
|
| 34 |
+
model = Earthformer(config).to(device)
|
| 35 |
+
model.load_state_dict(clean_state_dict(payload["model"]))
|
| 36 |
+
model.eval()
|
| 37 |
+
loader, _ = make_loader(config, args.split, shuffle=False)
|
| 38 |
+
inputs, targets = next(iter(loader))
|
| 39 |
+
with torch.no_grad():
|
| 40 |
+
predictions = model(inputs.to(device)).clamp(0.0, 1.0).cpu().numpy()
|
| 41 |
+
output = Path(resolve_cli_path(args.output))
|
| 42 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 43 |
+
np.savez_compressed(output, inputs=inputs.numpy(), targets=targets.numpy(), predictions=predictions)
|
| 44 |
+
print(json.dumps({"output": str(output), "shape": list(predictions.shape)}, indent=2))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
if __name__ == "__main__":
|
| 48 |
+
main()
|
script/metrics.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
CSI_THRESHOLDS = torch.tensor([16, 74, 133, 160, 181, 219], dtype=torch.float32) / 255.0
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def mse(prediction: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
|
| 10 |
+
return torch.mean((prediction - target) ** 2)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _ssim_per_sample(prediction: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
|
| 14 |
+
prediction, target = prediction.clamp(0.0, 1.0), target.clamp(0.0, 1.0)
|
| 15 |
+
mu_x, mu_y = prediction.mean((2, 3)), target.mean((2, 3))
|
| 16 |
+
centered_x = prediction - mu_x[:, :, None, None, :]
|
| 17 |
+
centered_y = target - mu_y[:, :, None, None, :]
|
| 18 |
+
var_x = centered_x.square().mean((2, 3))
|
| 19 |
+
var_y = centered_y.square().mean((2, 3))
|
| 20 |
+
covariance = (centered_x * centered_y).mean((2, 3))
|
| 21 |
+
c1, c2 = 0.01**2, 0.03**2
|
| 22 |
+
score = ((2 * mu_x * mu_y + c1) * (2 * covariance + c2)) / (
|
| 23 |
+
(mu_x.square() + mu_y.square() + c1) * (var_x + var_y + c2)
|
| 24 |
+
)
|
| 25 |
+
return score.mean((1, 2))
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def metric_sums(prediction: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
|
| 29 |
+
"""Return additive sample-level MSE/MAE/SSIM sums and CSI event counts."""
|
| 30 |
+
prediction, target = prediction.float(), target.float()
|
| 31 |
+
reduce_dims = tuple(range(1, prediction.ndim))
|
| 32 |
+
values = [
|
| 33 |
+
(prediction - target).square().mean(reduce_dims).sum(),
|
| 34 |
+
(prediction - target).abs().mean(reduce_dims).sum(),
|
| 35 |
+
_ssim_per_sample(prediction, target).sum(),
|
| 36 |
+
prediction.new_tensor(prediction.shape[0]),
|
| 37 |
+
]
|
| 38 |
+
for threshold in CSI_THRESHOLDS.to(prediction.device):
|
| 39 |
+
predicted, observed = prediction >= threshold, target >= threshold
|
| 40 |
+
values.extend([(predicted & observed).sum(), (predicted & ~observed).sum(), (~predicted & observed).sum()])
|
| 41 |
+
return torch.stack(values).to(torch.float64)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def metrics_from_sums(sums: torch.Tensor) -> dict[str, float]:
|
| 45 |
+
count = sums[3].clamp_min(1)
|
| 46 |
+
csi = []
|
| 47 |
+
for index in range(len(CSI_THRESHOLDS)):
|
| 48 |
+
hits, false_alarms, misses = sums[4 + index * 3 : 7 + index * 3]
|
| 49 |
+
csi.append((hits / (hits + false_alarms + misses).clamp_min(1)).item())
|
| 50 |
+
return {
|
| 51 |
+
"mse": (sums[0] / count).item(),
|
| 52 |
+
"mae": (sums[1] / count).item(),
|
| 53 |
+
"ssim": (sums[2] / count).item(),
|
| 54 |
+
"mean_csi": sum(csi) / len(csi),
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def metric_sums_light(prediction: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
|
| 59 |
+
"""Additive MSE/MAE sums for cheap per-epoch validation: [mse_sum, mae_sum, count]."""
|
| 60 |
+
prediction, target = prediction.float(), target.float()
|
| 61 |
+
reduce_dims = tuple(range(1, prediction.ndim))
|
| 62 |
+
return torch.stack(
|
| 63 |
+
[
|
| 64 |
+
(prediction - target).square().mean(reduce_dims).sum(),
|
| 65 |
+
(prediction - target).abs().mean(reduce_dims).sum(),
|
| 66 |
+
prediction.new_tensor(prediction.shape[0]),
|
| 67 |
+
]
|
| 68 |
+
).to(torch.float64)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def metrics_from_light_sums(sums: torch.Tensor) -> dict[str, float]:
|
| 72 |
+
count = sums[2].clamp_min(1)
|
| 73 |
+
return {"mse": (sums[0] / count).item(), "mae": (sums[1] / count).item()}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def compute_metrics(prediction: torch.Tensor, target: torch.Tensor) -> dict[str, float]:
|
| 77 |
+
return metrics_from_sums(metric_sums(prediction.detach(), target.detach()))
|
script/result.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import matplotlib
|
| 9 |
+
|
| 10 |
+
matplotlib.use("Agg")
|
| 11 |
+
import matplotlib.pyplot as plt
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
| 14 |
+
|
| 15 |
+
if __package__ in (None, ""):
|
| 16 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 17 |
+
|
| 18 |
+
from model.earthformer import Earthformer
|
| 19 |
+
from script.data_loader import make_loader
|
| 20 |
+
from script.metrics import metric_sums, metrics_from_sums
|
| 21 |
+
from script.utils import clean_state_dict, load_checkpoint_payload, resolve_cli_path, resolve_device
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _squeeze(frame: np.ndarray) -> np.ndarray:
|
| 25 |
+
return np.squeeze(frame)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def plot_lead_time(truth: np.ndarray, prediction: np.ndarray, output_path: Path, title: str) -> Path:
|
| 29 |
+
"""Three-panel Truth / Prediction / Difference image, fuxi/fengwu style."""
|
| 30 |
+
truth, prediction = _squeeze(truth), _squeeze(prediction)
|
| 31 |
+
difference = truth - prediction
|
| 32 |
+
rmse = float(np.sqrt(np.mean(difference**2)))
|
| 33 |
+
vmin, vmax = min(truth.min(), prediction.min()), max(truth.max(), prediction.max())
|
| 34 |
+
diff_abs_max = max(float(np.abs(difference).max()), 1e-8)
|
| 35 |
+
|
| 36 |
+
panels = [
|
| 37 |
+
{"data": truth, "title": "Truth", "cmap": "viridis", "vmin": vmin, "vmax": vmax},
|
| 38 |
+
{"data": prediction, "title": "Prediction", "cmap": "viridis", "vmin": vmin, "vmax": vmax},
|
| 39 |
+
{"data": difference, "title": f"Difference (RMSE={rmse:.2f})", "cmap": "RdBu_r", "vmin": -diff_abs_max, "vmax": diff_abs_max},
|
| 40 |
+
]
|
| 41 |
+
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
|
| 42 |
+
for ax, panel in zip(axes, panels):
|
| 43 |
+
image = ax.imshow(panel["data"], cmap=panel["cmap"], vmin=panel["vmin"], vmax=panel["vmax"])
|
| 44 |
+
ax.set_title(panel["title"], fontsize=12, pad=4)
|
| 45 |
+
ax.set_xlabel("Pixel")
|
| 46 |
+
ax.set_ylabel("Pixel")
|
| 47 |
+
plt.colorbar(image, ax=ax, orientation="horizontal")
|
| 48 |
+
fig.suptitle(title, fontsize=14, fontweight="bold", y=0.98)
|
| 49 |
+
fig.savefig(output_path, dpi=300, bbox_inches="tight")
|
| 50 |
+
plt.close(fig)
|
| 51 |
+
return output_path
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def visualize_predictions(
|
| 55 |
+
truth: np.ndarray,
|
| 56 |
+
prediction: np.ndarray,
|
| 57 |
+
output_dir: str | Path,
|
| 58 |
+
max_samples: int = 2,
|
| 59 |
+
stride: int = 2,
|
| 60 |
+
) -> list[str]:
|
| 61 |
+
"""Write per-sample/lead-time PNG images and a per-sample mosaic into output_dir."""
|
| 62 |
+
output_dir = Path(output_dir)
|
| 63 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 64 |
+
if truth.shape != prediction.shape:
|
| 65 |
+
raise ValueError(f"truth and prediction must have the same shape, got {truth.shape} vs {prediction.shape}")
|
| 66 |
+
samples = min(max_samples, int(truth.shape[0]))
|
| 67 |
+
lead_times = list(range(0, int(truth.shape[1]), stride))
|
| 68 |
+
generated: list[str] = []
|
| 69 |
+
for sample in range(samples):
|
| 70 |
+
for lead_time in lead_times:
|
| 71 |
+
path = output_dir / f"earthformer_sample_{sample:03d}_t{lead_time:02d}.png"
|
| 72 |
+
plot_lead_time(
|
| 73 |
+
truth[sample, lead_time],
|
| 74 |
+
prediction[sample, lead_time],
|
| 75 |
+
path,
|
| 76 |
+
f"Earthformer VIL sample {sample} lead time {lead_time} (+{lead_time * 5} min)",
|
| 77 |
+
)
|
| 78 |
+
generated.append(str(path))
|
| 79 |
+
mosaic = output_dir / f"earthformer_overview_sample_{sample:03d}.png"
|
| 80 |
+
_plot_mosaic(truth[sample], prediction[sample], lead_times, mosaic)
|
| 81 |
+
generated.append(str(mosaic))
|
| 82 |
+
return generated
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _plot_mosaic(truth: np.ndarray, prediction: np.ndarray, lead_times: list[int], output_path: Path) -> Path:
|
| 86 |
+
rows, columns = len(lead_times) * 2, len(lead_times)
|
| 87 |
+
fig, axes = plt.subplots(rows, columns, figsize=(columns * 2.6, rows * 2.2))
|
| 88 |
+
for column, lead_time in enumerate(lead_times):
|
| 89 |
+
truth_frame = _squeeze(truth[lead_time])
|
| 90 |
+
pred_frame = _squeeze(prediction[lead_time])
|
| 91 |
+
vmin, vmax = min(truth_frame.min(), pred_frame.min()), max(truth_frame.max(), pred_frame.max())
|
| 92 |
+
axes[0, column].imshow(truth_frame, cmap="viridis", vmin=vmin, vmax=vmax)
|
| 93 |
+
axes[0, column].set_title(f"+{lead_time * 5} min", fontsize=9)
|
| 94 |
+
axes[len(lead_times), column].imshow(pred_frame, cmap="viridis", vmin=vmin, vmax=vmax)
|
| 95 |
+
for row in range(rows):
|
| 96 |
+
axes[row, column].set_xticks([])
|
| 97 |
+
axes[row, column].set_yticks([])
|
| 98 |
+
axes[0, 0].set_ylabel("Truth", fontsize=10)
|
| 99 |
+
axes[len(lead_times), 0].set_ylabel("Prediction", fontsize=10)
|
| 100 |
+
fig.suptitle("Earthformer VIL sample overview", fontsize=13, fontweight="bold", y=0.99)
|
| 101 |
+
fig.tight_layout(rect=(0, 0, 1, 0.97))
|
| 102 |
+
fig.savefig(output_path, dpi=200, bbox_inches="tight")
|
| 103 |
+
plt.close(fig)
|
| 104 |
+
return output_path
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def evaluate(model: torch.nn.Module, loader, device: torch.device) -> dict[str, float]:
|
| 108 |
+
sums = torch.zeros(22, dtype=torch.float64, device=device)
|
| 109 |
+
with torch.no_grad():
|
| 110 |
+
for inputs, targets in loader:
|
| 111 |
+
prediction = model(inputs.to(device)).clamp(0.0, 1.0)
|
| 112 |
+
sums += metric_sums(prediction, targets.to(device))
|
| 113 |
+
result = metrics_from_sums(sums.cpu())
|
| 114 |
+
result["note"] = "Lightweight metrics on configured data; mean CSI is not the official complete SEVIR evaluation"
|
| 115 |
+
return result
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def parse_args() -> argparse.Namespace:
|
| 119 |
+
parser = argparse.ArgumentParser(
|
| 120 |
+
description="Evaluate Earthformer and visualize predictions (fuxi/fengwu style PNG images)"
|
| 121 |
+
)
|
| 122 |
+
parser.add_argument("--checkpoint", default="data/checkpoint/earthformer.pt")
|
| 123 |
+
parser.add_argument("--split", choices=("train", "val", "test"), default="test")
|
| 124 |
+
parser.add_argument("--device", choices=("auto", "cpu", "cuda"), default="auto")
|
| 125 |
+
parser.add_argument("--predictions", default="output/predictions.npz", help="Inference arrays for visualization")
|
| 126 |
+
parser.add_argument("--output-dir", default="output/visualization", help="Directory for visualization PNG images")
|
| 127 |
+
parser.add_argument("--max-samples", type=int, default=2)
|
| 128 |
+
parser.add_argument("--stride", type=int, default=2, help="Lead-time stride, aligned with official plot_stride")
|
| 129 |
+
parser.add_argument("--skip-visualization", action="store_true", help="Only evaluate, do not render PNG images")
|
| 130 |
+
return parser.parse_args()
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def main() -> None:
|
| 134 |
+
args = parse_args()
|
| 135 |
+
device = resolve_device(args.device)
|
| 136 |
+
payload = load_checkpoint_payload(resolve_cli_path(args.checkpoint), device)
|
| 137 |
+
config = payload["config"]
|
| 138 |
+
model = Earthformer(config).to(device)
|
| 139 |
+
model.load_state_dict(clean_state_dict(payload["model"]))
|
| 140 |
+
model.eval()
|
| 141 |
+
loader, _ = make_loader(config, args.split, shuffle=False)
|
| 142 |
+
result = evaluate(model, loader, device)
|
| 143 |
+
report = {"metrics": result}
|
| 144 |
+
if args.skip_visualization:
|
| 145 |
+
print(json.dumps(report, indent=2))
|
| 146 |
+
return
|
| 147 |
+
predictions_path = Path(resolve_cli_path(args.predictions))
|
| 148 |
+
with np.load(predictions_path) as payload:
|
| 149 |
+
truth = payload["targets"]
|
| 150 |
+
prediction = payload["predictions"]
|
| 151 |
+
output_dir = resolve_cli_path(args.output_dir)
|
| 152 |
+
images = visualize_predictions(truth, prediction, output_dir, args.max_samples, args.stride)
|
| 153 |
+
report["output_dir"] = output_dir
|
| 154 |
+
report["images"] = images
|
| 155 |
+
print(json.dumps(report, indent=2))
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
if __name__ == "__main__":
|
| 159 |
+
main()
|
script/train.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import random
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
import torch.distributed as dist
|
| 13 |
+
from torch.nn.parallel import DistributedDataParallel
|
| 14 |
+
|
| 15 |
+
if __package__ in (None, ""):
|
| 16 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 17 |
+
|
| 18 |
+
from model.earthformer import Earthformer
|
| 19 |
+
from script.data_loader import make_loader
|
| 20 |
+
from script.metrics import metric_sums, metric_sums_light, metrics_from_light_sums, metrics_from_sums, mse
|
| 21 |
+
from script.utils import DEFAULT_CONFIG, atomic_torch_save, clean_state_dict, load_checkpoint_payload, load_config, resolve_device
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def seed_everything(seed: int) -> None:
|
| 25 |
+
random.seed(seed)
|
| 26 |
+
np.random.seed(seed)
|
| 27 |
+
torch.manual_seed(seed)
|
| 28 |
+
if torch.cuda.is_available():
|
| 29 |
+
torch.cuda.manual_seed_all(seed)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def validate(
|
| 33 |
+
model: torch.nn.Module,
|
| 34 |
+
loader,
|
| 35 |
+
device: torch.device,
|
| 36 |
+
max_batches: int | None = None,
|
| 37 |
+
full_metrics: bool = False,
|
| 38 |
+
) -> dict[str, float]:
|
| 39 |
+
"""Run validation with only cheap MSE/MAE by default.
|
| 40 |
+
|
| 41 |
+
The training loss is already the per-sample MSE, so recomputing MSE during
|
| 42 |
+
training is redundant monitoring. SSIM and per-threshold CSI are expensive
|
| 43 |
+
on full-resolution 384x384 data, so they are computed only when
|
| 44 |
+
`train.compute_full_metrics` is enabled; the authoritative full evaluation
|
| 45 |
+
lives in script/result.py.
|
| 46 |
+
"""
|
| 47 |
+
model.eval()
|
| 48 |
+
sums = torch.zeros(22 if full_metrics else 3, dtype=torch.float64, device=device)
|
| 49 |
+
with torch.no_grad():
|
| 50 |
+
for batch_index, (inputs, targets) in enumerate(loader):
|
| 51 |
+
predictions = model(inputs.to(device, non_blocking=True)).clamp(0.0, 1.0)
|
| 52 |
+
targets_device = targets.to(device, non_blocking=True)
|
| 53 |
+
if full_metrics:
|
| 54 |
+
sums += metric_sums(predictions, targets_device)
|
| 55 |
+
else:
|
| 56 |
+
sums += metric_sums_light(predictions, targets_device)
|
| 57 |
+
if max_batches is not None and batch_index + 1 >= max_batches:
|
| 58 |
+
break
|
| 59 |
+
if dist.is_initialized():
|
| 60 |
+
dist.all_reduce(sums, op=dist.ReduceOp.SUM)
|
| 61 |
+
count_index = 3 if full_metrics else 2
|
| 62 |
+
if sums[count_index].item() == 0:
|
| 63 |
+
raise ValueError("validation loader is empty")
|
| 64 |
+
return (metrics_from_sums if full_metrics else metrics_from_light_sums)(sums.cpu())
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def run_training(config: dict, requested_device: str = "auto", resume: str | None = None) -> tuple[Path, dict[str, float]]:
|
| 68 |
+
world_size = int(os.environ.get("WORLD_SIZE", "1"))
|
| 69 |
+
rank = int(os.environ.get("RANK", "0"))
|
| 70 |
+
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
|
| 71 |
+
distributed = world_size > 1
|
| 72 |
+
device = resolve_device(requested_device, local_rank)
|
| 73 |
+
backend = config["distributed"].get("backend", "auto")
|
| 74 |
+
if backend == "auto":
|
| 75 |
+
backend = "nccl" if device.type == "cuda" else "gloo"
|
| 76 |
+
if distributed:
|
| 77 |
+
dist.init_process_group(backend=backend, rank=rank, world_size=world_size)
|
| 78 |
+
try:
|
| 79 |
+
seed_everything(int(config["train"]["seed"]))
|
| 80 |
+
model = Earthformer(config).to(device)
|
| 81 |
+
optimizer = torch.optim.AdamW(
|
| 82 |
+
model.parameters(),
|
| 83 |
+
lr=float(config["train"]["learning_rate"]),
|
| 84 |
+
weight_decay=float(config["train"]["weight_decay"]),
|
| 85 |
+
)
|
| 86 |
+
start_epoch, step = 0, 0
|
| 87 |
+
if resume:
|
| 88 |
+
payload = load_checkpoint_payload(resume, device)
|
| 89 |
+
model.load_state_dict(clean_state_dict(payload["model"]))
|
| 90 |
+
if "optimizer" in payload:
|
| 91 |
+
optimizer.load_state_dict(payload["optimizer"])
|
| 92 |
+
start_epoch = int(payload.get("epoch", -1)) + 1
|
| 93 |
+
step = int(payload.get("step", 0))
|
| 94 |
+
if distributed:
|
| 95 |
+
model = DistributedDataParallel(
|
| 96 |
+
model,
|
| 97 |
+
device_ids=[local_rank] if device.type == "cuda" else None,
|
| 98 |
+
find_unused_parameters=True,
|
| 99 |
+
)
|
| 100 |
+
train_loader, train_sampler = make_loader(config, "train", distributed, rank, world_size)
|
| 101 |
+
val_loader, _ = make_loader(config, "val", distributed, rank, world_size, shuffle=False)
|
| 102 |
+
full_metrics = bool(config["train"].get("compute_full_metrics", False))
|
| 103 |
+
last_epoch = max(start_epoch - 1, 0)
|
| 104 |
+
metrics: dict[str, float] = {}
|
| 105 |
+
for epoch in range(start_epoch, int(config["train"]["epochs"])):
|
| 106 |
+
last_epoch = epoch
|
| 107 |
+
if train_sampler is not None:
|
| 108 |
+
train_sampler.set_epoch(epoch)
|
| 109 |
+
model.train()
|
| 110 |
+
epoch_loss_sum, epoch_steps = 0.0, 0
|
| 111 |
+
for inputs, targets in train_loader:
|
| 112 |
+
optimizer.zero_grad(set_to_none=True)
|
| 113 |
+
loss = mse(model(inputs.to(device, non_blocking=True)), targets.to(device, non_blocking=True))
|
| 114 |
+
if not torch.isfinite(loss):
|
| 115 |
+
raise FloatingPointError("training loss is not finite")
|
| 116 |
+
loss.backward()
|
| 117 |
+
optimizer.step()
|
| 118 |
+
epoch_loss_sum += float(loss.detach().item())
|
| 119 |
+
epoch_steps += 1
|
| 120 |
+
step += 1
|
| 121 |
+
if epoch_steps == 0:
|
| 122 |
+
break
|
| 123 |
+
metrics = validate(model, val_loader, device, int(config["train"].get("validation_steps", 1)), full_metrics)
|
| 124 |
+
if rank == 0:
|
| 125 |
+
epoch_line = {"epoch": epoch, "train_loss": epoch_loss_sum / epoch_steps, "validation": metrics}
|
| 126 |
+
print(json.dumps(epoch_line))
|
| 127 |
+
checkpoint = Path(config["train"]["output_dir"]) / "earthformer.pt"
|
| 128 |
+
if rank == 0:
|
| 129 |
+
raw_model = model.module if isinstance(model, DistributedDataParallel) else model
|
| 130 |
+
atomic_torch_save(
|
| 131 |
+
{
|
| 132 |
+
"model": raw_model.state_dict(),
|
| 133 |
+
"optimizer": optimizer.state_dict(),
|
| 134 |
+
"config": config,
|
| 135 |
+
"metrics": metrics,
|
| 136 |
+
"epoch": last_epoch,
|
| 137 |
+
"step": step,
|
| 138 |
+
"world_size": world_size,
|
| 139 |
+
},
|
| 140 |
+
checkpoint,
|
| 141 |
+
)
|
| 142 |
+
print(json.dumps({"checkpoint": str(checkpoint), "step": step, "world_size": world_size, "metrics": metrics}, indent=2))
|
| 143 |
+
if distributed:
|
| 144 |
+
dist.barrier()
|
| 145 |
+
return checkpoint, metrics
|
| 146 |
+
finally:
|
| 147 |
+
if dist.is_initialized():
|
| 148 |
+
dist.destroy_process_group()
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def parse_args() -> argparse.Namespace:
|
| 152 |
+
parser = argparse.ArgumentParser(description="Train Earthformer with one process or DDP")
|
| 153 |
+
parser.add_argument("--config", default=str(DEFAULT_CONFIG))
|
| 154 |
+
parser.add_argument("--device", choices=("auto", "cpu", "cuda"), default="auto")
|
| 155 |
+
parser.add_argument("--resume")
|
| 156 |
+
return parser.parse_args()
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
if __name__ == "__main__":
|
| 160 |
+
args = parse_args()
|
| 161 |
+
run_training(load_config(args.config), args.device, args.resume)
|
script/utils.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
import yaml
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 12 |
+
DEFAULT_CONFIG = ROOT / "conf/config.yaml"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def load_config(path: str | Path = DEFAULT_CONFIG) -> dict[str, Any]:
|
| 16 |
+
config_path = Path(path).expanduser().resolve()
|
| 17 |
+
with config_path.open("r", encoding="utf-8") as handle:
|
| 18 |
+
config = yaml.safe_load(handle)
|
| 19 |
+
if not isinstance(config, dict):
|
| 20 |
+
raise ValueError("configuration must be a YAML mapping")
|
| 21 |
+
for section in ("data", "model", "train", "dataloader", "distributed"):
|
| 22 |
+
if section not in config:
|
| 23 |
+
raise ValueError(f"missing configuration section: {section}")
|
| 24 |
+
data, model = config["data"], config["model"]
|
| 25 |
+
for key in ("input_length", "output_length", "height", "width", "channels"):
|
| 26 |
+
if int(data.get(key, 0)) <= 0:
|
| 27 |
+
raise ValueError(f"data.{key} must be positive")
|
| 28 |
+
if int(data["height"]) % 4 or int(data["width"]) % 4:
|
| 29 |
+
raise ValueError("data height and width must be divisible by four")
|
| 30 |
+
dims, depths = model.get("dims"), model.get("depths")
|
| 31 |
+
if not isinstance(dims, list) or len(dims) != 2 or int(dims[1]) != 2 * int(dims[0]):
|
| 32 |
+
raise ValueError("model.dims must be [D, 2*D]")
|
| 33 |
+
if not isinstance(depths, list) or len(depths) != 2 or min(int(x) for x in depths) < 1:
|
| 34 |
+
raise ValueError("model.depths must contain two positive integers")
|
| 35 |
+
heads = int(model.get("heads", 0))
|
| 36 |
+
if heads < 1 or any(int(dim) % heads for dim in dims):
|
| 37 |
+
raise ValueError("model.heads must divide both hidden dimensions")
|
| 38 |
+
normalization = data.get("normalization", "unit")
|
| 39 |
+
if normalization not in ("unit", "uint8_255"):
|
| 40 |
+
raise ValueError("data.normalization must be 'unit' or 'uint8_255'")
|
| 41 |
+
for key in ("data_dir", "train_npz", "val_npz", "test_npz"):
|
| 42 |
+
if data.get(key):
|
| 43 |
+
value = Path(data[key]).expanduser()
|
| 44 |
+
data[key] = str(value if value.is_absolute() else ROOT / value)
|
| 45 |
+
output_dir = Path(config["train"]["output_dir"]).expanduser()
|
| 46 |
+
config["train"]["output_dir"] = str(output_dir if output_dir.is_absolute() else ROOT / output_dir)
|
| 47 |
+
return config
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def resolve_device(requested: str, local_rank: int = 0) -> torch.device:
|
| 51 |
+
if requested not in ("auto", "cpu", "cuda"):
|
| 52 |
+
raise ValueError("device must be auto, cpu, or cuda")
|
| 53 |
+
use_accelerator = requested == "cuda" or (requested == "auto" and torch.cuda.is_available())
|
| 54 |
+
if use_accelerator:
|
| 55 |
+
if not torch.cuda.is_available():
|
| 56 |
+
raise RuntimeError("CUDA/ROCm device requested but torch.cuda.is_available() is false")
|
| 57 |
+
torch.cuda.set_device(local_rank)
|
| 58 |
+
return torch.device("cuda", local_rank)
|
| 59 |
+
return torch.device("cpu")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def resolve_cli_path(value: str | None) -> str | None:
|
| 63 |
+
if not value:
|
| 64 |
+
return value
|
| 65 |
+
path = Path(value).expanduser()
|
| 66 |
+
return str(path if path.is_absolute() else ROOT / path)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def load_checkpoint_payload(path: str | Path, device: torch.device) -> dict[str, Any]:
|
| 70 |
+
payload = torch.load(path, map_location=device, weights_only=False)
|
| 71 |
+
if not isinstance(payload, dict) or "model" not in payload or "config" not in payload:
|
| 72 |
+
raise ValueError("checkpoint must contain model and config")
|
| 73 |
+
return payload
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def clean_state_dict(state_dict: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
|
| 77 |
+
return {key.removeprefix("module."): value for key, value in state_dict.items()}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def atomic_torch_save(payload: dict[str, Any], path: str | Path) -> Path:
|
| 81 |
+
path = Path(path)
|
| 82 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 83 |
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
| 84 |
+
torch.save(payload, temporary)
|
| 85 |
+
os.replace(temporary, path)
|
| 86 |
+
return path
|
weight/.gitkeep
ADDED
|
File without changes
|