yzt15806542928 commited on
Commit
8bfc737
·
verified ·
1 Parent(s): d2f0618

Upload folder using huggingface_hub

Browse files
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