yzt15806542928 commited on
Commit
7180154
·
verified ·
1 Parent(s): 0d4fb01

Upload folder using huggingface_hub

Browse files
README.md ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ frameworks: JAX
3
+ language:
4
+ - en
5
+ license: apache-2.0
6
+ tags:
7
+ - OneScience
8
+ - Earth Science
9
+ - Weather Forecasting
10
+ - Ensemble Forecasting
11
+ - ERA5
12
+ tasks: []
13
+ datasets:
14
+ - OneScience/ERA5
15
+ ---
16
+
17
+ <p align="center">
18
+ <strong>
19
+ <span style="font-size: 30px;">GenCast</span>
20
+ </strong>
21
+ </p>
22
+
23
+ # Model Overview
24
+
25
+ GenCast is a probabilistic global weather forecasting model developed by Google DeepMind. Its paper appeared as the cover article of the leading scientific journal *Nature* on December 4, 2024.
26
+
27
+ Paper: *GenCast: Diffusion-Based Ensemble Forecasting for Medium-Range Weather*
28
+
29
+ https://arxiv.org/abs/2312.15796
30
+
31
+ # Model Description
32
+
33
+ GenCast is an ensemble forecasting model built with graph neural networks and diffusion models. Across a comprehensive set of evaluations, it outperformed ENS, the European Centre for Medium-Range Weather Forecasts' (ECMWF) leading ensemble forecasting system.
34
+
35
+
36
+ # Use Cases
37
+
38
+ | Use Case | Description |
39
+ | :---: | :--- |
40
+ | Weather forecasting training | Train the model on ERA5 data in HDF5 format that conforms to the GenCast data protocol. |
41
+ | Quick local validation | Use synthetic data to validate data loading, model training and inference, and visualization of inference results. |
42
+ | ModelScope/OneCode execution | Download the standalone model package, install its dependencies, and run the included scripts directly. |
43
+ | Multi-GPU training | Use JAX `pmap` for data-parallel training across multiple GPUs or accelerators on a single host. |
44
+
45
+
46
+ # Usage
47
+
48
+ ## 1. Using OneCode
49
+
50
+ Use the OneCode online environment for an intelligent, one-click AI4S development experience:
51
+
52
+ [Try one-click AI4S development with OneCode](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
53
+
54
+ ## 2. Manual Setup
55
+
56
+ **Hardware Requirements**
57
+
58
+ - A GPU or DCU is recommended.
59
+ - A CPU can be used for import checks and connectivity validation with a minimal configuration, but full training and inference will be slow.
60
+ - DCU users must install DTK in advance. DTK 25.04.2 or later is recommended; alternatively, use the OneScience-recommended version compatible with your cluster.
61
+
62
+ ### Download the Model Package
63
+
64
+ ```bash
65
+ hf download --model OneScience-Group/GenCast --local-dir ./GenCast
66
+ cd GenCast
67
+ ```
68
+
69
+ ### Set Up the Runtime Environment
70
+
71
+ **DCU Environment**
72
+
73
+ ```bash
74
+ # Activate DTK and conda first.
75
+ conda create -n onescience311 python=3.11 -y
76
+ conda activate onescience311
77
+ # Installation with uv is also supported.
78
+ pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
79
+ ```
80
+
81
+ **GPU Environment**
82
+
83
+ ```bash
84
+ # Activate conda first.
85
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
86
+ conda activate onescience311
87
+ # Installation with uv is also supported.
88
+ pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
89
+ ```
90
+
91
+ ### Training Data
92
+
93
+ The OneScience community provides ERA5 data for training. Because of file-size constraints, the repository currently contains a self-contained data slice. Download the data with the following command and ensure that the data path in `conf/config.yaml` is configured correctly:
94
+
95
+ ```bash
96
+ hf download --dataset OneScience-Group/ERA5 --local-dir ./data
97
+ ```
98
+
99
+
100
+ ### Training
101
+
102
+ Single GPU:
103
+
104
+ ```bash
105
+ # If real data is unavailable, first run `python scripts/fake_data.py` to generate synthetic data.
106
+ python scripts/train.py
107
+ ```
108
+
109
+ Multiple GPUs:
110
+
111
+ ```bash
112
+ CUDA_VISIBLE_DEVICES=0,1 python scripts/train.py --config conf/config.yaml --parallel-mode pmap --num-devices 2 --global-batch-size 2
113
+ # CUDA_VISIBLE_DEVICES specifies the GPU indices to expose.
114
+ # --num-devices specifies the number of GPUs to use.
115
+ # --global-batch-size specifies the batch size and must be divisible by the number of GPUs.
116
+ ```
117
+
118
+ After training, the weights are saved to `data/checkpoints/model_bak.npz`.
119
+
120
+
121
+ ### Pre-trained Weights
122
+
123
+ This repository will provide weights trained on ERA5 reanalysis data in the `weights/` directory. The weight files are being prepared and will be uploaded soon.
124
+
125
+
126
+ ### Inference
127
+
128
+ By default, inference loads `data/checkpoints/model_bak.npz`:
129
+
130
+ ```bash
131
+ python scripts/inference.py
132
+ ```
133
+
134
+ ### Evaluation and Visualization
135
+
136
+ ```bash
137
+ python scripts/result.py
138
+ ```
139
+
140
+
141
+ # Official OneScience Resources
142
+
143
+ | Platform | OneScience Main Repository | Skills Repository |
144
+ | --- | --- | --- |
145
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
146
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
147
+
148
+
149
+ # Citation and License
150
+
151
+ - This repository is a reproduction of the original GenCast paper.
conf/config.yaml ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ runtime:
2
+ platform: "auto"
3
+
4
+ data:
5
+ data_dir: "./data"
6
+ static_dir: "./data/static"
7
+ stats_dir: "./data/stats"
8
+ train_years: [2000, 2001]
9
+ test_years: [2003]
10
+ train_stride: 1
11
+ test_stride: 1
12
+ reintroduce_sst_nans: true
13
+ # Source total_precipitation must be accumulated over each source interval,
14
+ # in the same units as the official GenCast statistics.
15
+ precipitation_interval_hours: 6
16
+
17
+ # Full-scale random initialization. For released weights, inference ignores
18
+ # this section and loads every architecture field from the official checkpoint.
19
+ model:
20
+ mesh_size: 4 # GenCast Mini (2562 nodes); 6=full (40962 nodes, TPU-scale)
21
+ latent_size: 512
22
+ hidden_layers: 1
23
+ radius_query_fraction_edge_length: 0.6
24
+ attention_k_hop: 16
25
+ attention_type: "triblockdiag_mha" # GPU/CPU; use "splash_mha" only for TPU
26
+ mask_type: "lazy"
27
+ num_layers: 16
28
+ num_heads: 4
29
+ ffw_hidden: 2048
30
+
31
+ sampler:
32
+ max_noise_level: 80.0
33
+ min_noise_level: 0.03
34
+ num_noise_levels: 20
35
+ rho: 7.0
36
+ stochastic_churn_rate: 2.5
37
+ churn_min_noise_level: 0.75
38
+ churn_max_noise_level: .inf
39
+ noise_level_inflation_factor: 1.05
40
+
41
+ training:
42
+ max_steps: 10
43
+ learning_rate: 0.0001
44
+ betas: [0.9, 0.999]
45
+ epsilon: 1.0e-8
46
+ seed: 42
47
+ save_interval: 1000
48
+
49
+ parallel:
50
+ mode: "pmap"
51
+ num_devices: 1
52
+ global_batch_size: 1
53
+ axis_name: "devices"
54
+
55
+ inference:
56
+ official_checkpoint: null
57
+ # Officially documented GPU substitution for TPU splash attention.
58
+ attention_type_override: null
59
+ prediction_steps: 30
60
+ num_members: 4
61
+ seed: 42
62
+ # Keep full-resolution ensemble memory bounded by writing each lead/member.
63
+ stream_chunks: true
64
+
65
+ checkpoint:
66
+ trainer: "./data/checkpoints/model_bak.npz"
67
+ resume: null
68
+
69
+ output:
70
+ prediction: "./result/prediction.nc"
71
+ plot: "./result/gencast_forecast.png"
72
+
73
+ fake_data:
74
+ height: 9
75
+ width: 16
76
+ timesteps: 8
configuration.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"framework":"Pytorch","task":"other"}
model/common.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ import xarray
10
+ import yaml
11
+
12
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
13
+
14
+
15
+ def load_config(path: str | Path) -> dict[str, Any]:
16
+ with Path(path).open(encoding="utf-8") as source:
17
+ return yaml.safe_load(source)
18
+
19
+
20
+ def resolve_path(path: str | Path) -> Path:
21
+ candidate = Path(path).expanduser()
22
+ return candidate if candidate.is_absolute() else PROJECT_ROOT / candidate
23
+
24
+
25
+ def configure_jax(platform: str) -> None:
26
+ if platform != "auto" and "JAX_PLATFORM_NAME" not in os.environ:
27
+ os.environ["JAX_PLATFORM_NAME"] = platform
28
+
29
+
30
+ def load_stats(stats_dir: str | Path) -> dict[str, xarray.Dataset]:
31
+ directory = resolve_path(stats_dir)
32
+ names = (
33
+ "diffs_stddev_by_level",
34
+ "mean_by_level",
35
+ "stddev_by_level",
36
+ "min_by_level",
37
+ )
38
+ stats = {}
39
+ for name in names:
40
+ path = directory / f"{name}.nc"
41
+ if not path.exists():
42
+ raise FileNotFoundError(f"Missing GenCast statistic: {path}")
43
+ stats[name] = xarray.load_dataset(path).compute()
44
+ from model.graphcast import gencast, graphcast
45
+
46
+ inputs = set(gencast.TASK.input_variables) - set(graphcast.GENERATED_FORCING_VARS)
47
+ targets = set(gencast.TASK.target_variables)
48
+ required_by_stat = {
49
+ "mean_by_level": inputs | (targets - inputs),
50
+ "stddev_by_level": inputs | (targets - inputs),
51
+ "diffs_stddev_by_level": targets & inputs,
52
+ "min_by_level": {"sea_surface_temperature"},
53
+ }
54
+ for stat_name, dataset in stats.items():
55
+ missing = sorted(required_by_stat[stat_name] - set(dataset.data_vars))
56
+ if missing:
57
+ raise ValueError(f"{stat_name} is missing GenCast variables: {missing}")
58
+ for name, values in dataset.data_vars.items():
59
+ array = np.asarray(values)
60
+ if not np.all(np.isfinite(array)):
61
+ raise ValueError(f"{stat_name}.{name} contains non-finite values")
62
+ if "level" in values.dims and tuple(values.level.values) != tuple(
63
+ gencast.TASK.pressure_levels
64
+ ):
65
+ raise ValueError(f"{stat_name}.{name} does not use GenCast WB13 order")
66
+ if stat_name in ("stddev_by_level", "diffs_stddev_by_level") and np.any(array <= 0):
67
+ raise ValueError(f"{stat_name}.{name} must be strictly positive")
68
+ return stats
69
+
70
+
71
+ def save_trainer_checkpoint(
72
+ path: str | Path,
73
+ *,
74
+ params: Any,
75
+ state: Any,
76
+ optimizer_state: Any,
77
+ step: int,
78
+ config: dict[str, Any],
79
+ ) -> None:
80
+ import jax
81
+
82
+ destination = resolve_path(path)
83
+ destination.parent.mkdir(parents=True, exist_ok=True)
84
+ leaves, treedef = jax.tree_util.tree_flatten(
85
+ {"params": params, "state": state, "optimizer_state": optimizer_state}
86
+ )
87
+ arrays = {f"leaf_{i}": np.asarray(value) for i, value in enumerate(leaves)}
88
+ arrays["treedef"] = np.array([treedef], dtype=object)
89
+ arrays["step"] = np.asarray(step, dtype=np.int64)
90
+ arrays["config_json"] = np.asarray(json.dumps(config, sort_keys=True))
91
+ temporary = destination.with_suffix(destination.suffix + ".tmp")
92
+ with temporary.open("wb") as output:
93
+ np.savez(output, **arrays)
94
+ os.replace(temporary, destination)
95
+
96
+
97
+ def load_trainer_checkpoint(
98
+ path: str | Path,
99
+ ) -> tuple[Any, Any, Any, int, dict[str, Any]]:
100
+ import jax
101
+
102
+ source_path = resolve_path(path)
103
+ with np.load(source_path, allow_pickle=True) as source:
104
+ treedef = source["treedef"].item()
105
+ leaves = [source[f"leaf_{i}"] for i in range(len(source.files) - 3)]
106
+ tree = jax.tree_util.tree_unflatten(treedef, leaves)
107
+ saved_config = json.loads(str(source["config_json"]))
108
+ return (
109
+ tree["params"], tree["state"], tree["optimizer_state"],
110
+ int(source["step"]), saved_config,
111
+ )
112
+
113
+
114
+ def validate_checkpoint_config(
115
+ current: dict[str, Any],
116
+ saved: dict[str, Any],
117
+ *,
118
+ scope: str = "resume",
119
+ ) -> None:
120
+ """Validate checkpoint compatibility for training resume or inference."""
121
+ if scope not in ("resume", "inference"):
122
+ raise ValueError("scope must be 'resume' or 'inference'")
123
+
124
+ inference_paths = (
125
+ ("model",), ("sampler",), ("data", "stats_dir"),
126
+ ("data", "static_dir"), ("data", "precipitation_interval_hours"),
127
+ )
128
+ resume_only_paths = (
129
+ ("training", "learning_rate"),
130
+ ("training", "betas"), ("training", "epsilon"),
131
+ ("training", "seed"), ("data", "data_dir"),
132
+ ("data", "train_years"), ("data", "train_stride"),
133
+ ("parallel", "mode"), ("parallel", "num_devices"),
134
+ ("parallel", "global_batch_size"), ("parallel", "axis_name"),
135
+ )
136
+ if scope == "resume":
137
+ if "parallel" not in saved:
138
+ saved = dict(saved)
139
+ saved["parallel"] = {
140
+ "mode": "single",
141
+ "num_devices": 1,
142
+ "global_batch_size": 1,
143
+ "axis_name": "devices",
144
+ }
145
+ paths = inference_paths + resume_only_paths
146
+ else:
147
+ paths = inference_paths
148
+
149
+ for path in paths:
150
+ current_value: Any = current
151
+ saved_value: Any = saved
152
+ for key in path:
153
+ current_value = current_value[key]
154
+ saved_value = saved_value[key]
155
+ if current_value != saved_value:
156
+ name = ".".join(path)
157
+ raise ValueError(
158
+ f"Trainer checkpoint configuration mismatch for {name} "
159
+ f"during {scope}: "
160
+ f"saved={saved_value!r}, current={current_value!r}"
161
+ )
model/data_loader.py ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """将 ERA5 HDF5 严格适配为官方 GenCast xarray 数据协议。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import bisect
6
+ import datetime as dt
7
+ from pathlib import Path
8
+ from typing import Any, Iterator
9
+
10
+ import h5py
11
+ import numpy as np
12
+ import xarray
13
+
14
+ try:
15
+ from onescience.datapipes.climate import ERA5Dataset as _ERA5Dataset
16
+ except ModuleNotFoundError as error:
17
+ if error.name not in ("torch", "onescience"):
18
+ raise
19
+
20
+ class _ERA5Dataset:
21
+ """Minimal discovery fallback for JAX-only OneScience environments."""
22
+
23
+ def __init__(self, dataset_dir, used_years, used_variables, **_):
24
+ self.dataset_dir = dataset_dir
25
+ self.used_years = used_years
26
+ self.used_variables = used_variables
27
+ self._init_avail_samples()
28
+ self._init_normalized_files()
29
+
30
+ def _init_avail_samples(self):
31
+ data_dir = Path(self.dataset_dir) / "data"
32
+ available = {int(path.stem): path for path in data_dir.glob("*.h5")}
33
+ missing_years = sorted(set(self.used_years) - set(available))
34
+ if missing_years:
35
+ raise ValueError(f"Years not found in dataset: {missing_years}")
36
+ first = available[self.used_years[0]]
37
+ with h5py.File(first, "r") as source:
38
+ fields = source["fields"]
39
+ variables = [
40
+ value.decode() if isinstance(value, bytes) else str(value)
41
+ for value in fields.attrs["variables"]
42
+ ]
43
+ self.T, self.C, self.H, self.W = fields.shape
44
+ self.time_step = int(fields.attrs["time_step"])
45
+ missing_variables = sorted(set(self.used_variables) - set(variables))
46
+ if missing_variables:
47
+ raise ValueError(f"Variables not found in dataset: {missing_variables}")
48
+ self.file_map = {year: str(available[year]) for year in self.used_years}
49
+
50
+ def _init_normalized_files(self):
51
+ pass
52
+
53
+ from model.graphcast import data_utils
54
+ from model.graphcast import gencast
55
+ from model.graphcast import graphcast
56
+
57
+
58
+ PRESSURE_LEVELS = tuple(graphcast.PRESSURE_LEVELS_WEATHERBENCH_13)
59
+ SURFACE_VARIABLES = tuple(gencast.TARGET_SURFACE_NO_PRECIP_VARS)
60
+ ATMOSPHERIC_VARIABLES = tuple(graphcast.TARGET_ATMOSPHERIC_VARS)
61
+ STATIC_VARIABLES = tuple(graphcast.STATIC_VARS)
62
+ RAW_PRECIPITATION = "total_precipitation"
63
+ TARGET_PRECIPITATION = "total_precipitation_12hr"
64
+ MODEL_TARGET_CHANNELS = 6 + 6 * len(PRESSURE_LEVELS)
65
+ ERA5_VARIABLES = (
66
+ *SURFACE_VARIABLES,
67
+ RAW_PRECIPITATION,
68
+ *(f"{name}_{level}" for name in ATMOSPHERIC_VARIABLES for level in PRESSURE_LEVELS),
69
+ )
70
+
71
+
72
+ def expected_target_channel_names() -> tuple[str, ...]:
73
+ channels: list[str] = []
74
+ atmospheric = set(ATMOSPHERIC_VARIABLES)
75
+ for name in sorted(gencast.TASK.target_variables):
76
+ if name in atmospheric:
77
+ channels.extend(f"{name}_{level}" for level in PRESSURE_LEVELS)
78
+ else:
79
+ channels.append(name)
80
+ return tuple(channels)
81
+
82
+
83
+ class GenCastERA5Dataset(_ERA5Dataset):
84
+ """Reuse ERA5Dataset discovery while enforcing GenCast's named protocol."""
85
+
86
+ def __init__(
87
+ self,
88
+ dataset_dir: str | Path,
89
+ used_years: list[int],
90
+ *,
91
+ static_dir: str | Path | None = None,
92
+ prediction_steps: int = 1,
93
+ stride: int = 1,
94
+ task_config: Any = gencast.TASK,
95
+ precipitation_interval_hours: int = 6,
96
+ load_future_targets: bool = True,
97
+ ) -> None:
98
+ super().__init__(
99
+ dataset_dir=str(dataset_dir),
100
+ used_years=used_years,
101
+ used_variables=list(ERA5_VARIABLES),
102
+ input_steps=1,
103
+ output_steps=1,
104
+ normalize=False,
105
+ )
106
+ self.static_dir = Path(static_dir or Path(dataset_dir) / "static")
107
+ self.prediction_steps = int(prediction_steps)
108
+ self.stride = int(stride)
109
+ self.task_config = task_config
110
+ self.precipitation_interval_hours = int(precipitation_interval_hours)
111
+ self.load_future_targets = bool(load_future_targets)
112
+ self._validate_task_config()
113
+ if self.prediction_steps < 1 or self.stride < 1:
114
+ raise ValueError("prediction_steps and stride must be positive")
115
+ self._inspect_years()
116
+
117
+ def _init_normalized_files(self) -> None:
118
+ # GenCast uses named by-level NetCDF statistics in the model wrapper.
119
+ self.mu = self.sd = None
120
+
121
+ def _inspect_years(self) -> None:
122
+ self._year_meta: list[dict[str, Any]] = []
123
+ self._cumulative: list[int] = []
124
+ total = 0
125
+ for year in self.used_years:
126
+ path = Path(self.file_map[year])
127
+ with h5py.File(path, "r") as source:
128
+ fields = source["fields"]
129
+ variables = [
130
+ value.decode() if isinstance(value, bytes) else str(value)
131
+ for value in fields.attrs["variables"]
132
+ ]
133
+ time_step = int(fields.attrs["time_step"])
134
+ shape = tuple(fields.shape)
135
+ if time_step not in (6, 12):
136
+ raise ValueError(f"{path}: GenCast requires 6h or 12h ERA5, got {time_step}h")
137
+ if self.precipitation_interval_hours != time_step:
138
+ raise ValueError(
139
+ f"{path}: total_precipitation must be an accumulation over each "
140
+ f"{time_step}h source interval; configured "
141
+ f"{self.precipitation_interval_hours}h"
142
+ )
143
+ missing = sorted(set(ERA5_VARIABLES) - set(variables))
144
+ if missing:
145
+ raise ValueError(f"{path}: missing GenCast ERA5 variables: {missing}")
146
+ frame_stride = 12 // time_step
147
+ # The -12h input also needs a complete 12h precipitation window.
148
+ first_reference = 2 * frame_stride - 1
149
+ last_reference = (
150
+ shape[0] - frame_stride * self.prediction_steps - 1
151
+ if self.load_future_targets else shape[0] - 1
152
+ )
153
+ references = list(range(first_reference, last_reference + 1, self.stride))
154
+ meta = {
155
+ "year": year,
156
+ "path": path,
157
+ "shape": shape,
158
+ "time_step": time_step,
159
+ "frame_stride": frame_stride,
160
+ "variables": variables,
161
+ "references": references,
162
+ }
163
+ self._year_meta.append(meta)
164
+ total += len(references)
165
+ self._cumulative.append(total)
166
+ self.total_samples = total
167
+ if not total:
168
+ raise ValueError("No complete GenCast samples are available")
169
+
170
+ def __len__(self) -> int:
171
+ return self.total_samples
172
+
173
+ def __getitem__(self, index: int):
174
+ if index < 0:
175
+ index += len(self)
176
+ if index < 0 or index >= len(self):
177
+ raise IndexError(index)
178
+ year_index = bisect.bisect_right(self._cumulative, index)
179
+ start = 0 if year_index == 0 else self._cumulative[year_index - 1]
180
+ meta = self._year_meta[year_index]
181
+ reference_index = meta["references"][index - start]
182
+ dataset = self._read_dataset(meta, reference_index)
183
+ return data_utils.extract_inputs_targets_forcings(
184
+ dataset,
185
+ target_lead_times=slice("12h", f"{12 * self.prediction_steps}h"),
186
+ input_variables=self.task_config.input_variables,
187
+ target_variables=self.task_config.target_variables,
188
+ forcing_variables=self.task_config.forcing_variables,
189
+ pressure_levels=self.task_config.pressure_levels,
190
+ input_duration=self.task_config.input_duration,
191
+ )
192
+
193
+ def _read_dataset(self, meta: dict[str, Any], reference_index: int) -> xarray.Dataset:
194
+ frame_stride = meta["frame_stride"]
195
+ frame_indices = [
196
+ reference_index - frame_stride,
197
+ reference_index,
198
+ *(reference_index + frame_stride * step for step in range(1, self.prediction_steps + 1)),
199
+ ]
200
+ variable_index = {name: i for i, name in enumerate(meta["variables"])}
201
+ selected_names = list(SURFACE_VARIABLES) + [
202
+ f"{name}_{level}"
203
+ for name in ATMOSPHERIC_VARIABLES
204
+ for level in PRESSURE_LEVELS
205
+ ]
206
+ selected_indices = [variable_index[name] for name in selected_names]
207
+ order = np.argsort(selected_indices)
208
+ inverse = np.empty(len(order), dtype=np.int64)
209
+ inverse[order] = np.arange(len(order))
210
+ read_count = len(frame_indices) if self.load_future_targets else 2
211
+ with h5py.File(meta["path"], "r") as source:
212
+ fields = source["fields"]
213
+ loaded = np.stack([
214
+ fields[t, np.asarray(selected_indices)[order], :, :][inverse]
215
+ for t in frame_indices[:read_count]
216
+ ]).astype(np.float32)
217
+ values = np.full(
218
+ (len(frame_indices), *loaded.shape[1:]), np.nan, dtype=np.float32
219
+ )
220
+ values[:read_count] = loaded
221
+ precipitation = np.full(
222
+ (len(frame_indices), *loaded.shape[-2:]), np.nan, dtype=np.float32
223
+ )
224
+ if self.load_future_targets:
225
+ precipitation[:] = np.stack([
226
+ self._precipitation_12h(
227
+ fields, variable_index[RAW_PRECIPITATION], t, frame_stride
228
+ )
229
+ for t in frame_indices
230
+ ]).astype(np.float32)
231
+
232
+ # OneScience ERA5 uses north-to-south storage; GenCast spherical noise requires ascending lat.
233
+ values = values[..., ::-1, :]
234
+ precipitation = precipitation[..., ::-1, :]
235
+ height, width = values.shape[-2:]
236
+ lat = np.linspace(-90.0, 90.0, height, dtype=np.float32)
237
+ lon = np.linspace(0.0, 360.0, width, endpoint=False, dtype=np.float32)
238
+ reference_time = dt.datetime(meta["year"], 1, 1) + dt.timedelta(
239
+ hours=reference_index * meta["time_step"]
240
+ )
241
+ datetimes = np.asarray([
242
+ np.datetime64(reference_time + dt.timedelta(hours=(t - reference_index) * meta["time_step"]))
243
+ for t in frame_indices
244
+ ], dtype="datetime64[ns]")
245
+ times = np.asarray([
246
+ np.timedelta64((t - reference_index) * meta["time_step"], "h")
247
+ for t in frame_indices
248
+ ], dtype="timedelta64[ns]")
249
+
250
+ data_vars: dict[str, Any] = {}
251
+ cursor = 0
252
+ for name in SURFACE_VARIABLES:
253
+ data_vars[name] = (("batch", "time", "lat", "lon"), values[:, cursor][None])
254
+ cursor += 1
255
+ for name in ATMOSPHERIC_VARIABLES:
256
+ data_vars[name] = (
257
+ ("batch", "time", "level", "lat", "lon"),
258
+ values[:, cursor:cursor + len(PRESSURE_LEVELS)][None],
259
+ )
260
+ cursor += len(PRESSURE_LEVELS)
261
+ data_vars[TARGET_PRECIPITATION] = (
262
+ ("batch", "time", "lat", "lon"), precipitation[None]
263
+ )
264
+ data_vars.update(self._load_static(height, width))
265
+ dataset = xarray.Dataset(
266
+ data_vars=data_vars,
267
+ coords={
268
+ "batch": np.arange(1),
269
+ "time": times,
270
+ "datetime": (("batch", "time"), datetimes[None]),
271
+ "level": np.asarray(PRESSURE_LEVELS, dtype=np.int32),
272
+ "lat": lat,
273
+ "lon": lon,
274
+ },
275
+ )
276
+ dataset.attrs["forecast_reference_time"] = np.datetime_as_string(
277
+ np.datetime64(reference_time), unit="h"
278
+ )
279
+ self.validate_dataset(dataset)
280
+ return dataset
281
+
282
+ @staticmethod
283
+ def _precipitation_12h(fields, channel: int, end: int, frame_stride: int):
284
+ start = end - frame_stride + 1
285
+ if start < 0:
286
+ raise IndexError("Insufficient precipitation history for 12h accumulation")
287
+ return np.sum(fields[start:end + 1, channel], axis=0)
288
+
289
+ def _load_static(self, height: int, width: int) -> dict[str, Any]:
290
+ paths = {
291
+ "geopotential_at_surface": self.static_dir / "geopotential_at_surface.npy",
292
+ "land_sea_mask": self.static_dir / "land_mask.npy",
293
+ }
294
+ result = {}
295
+ for name, path in paths.items():
296
+ if not path.exists():
297
+ raise FileNotFoundError(f"Missing GenCast static field: {path}")
298
+ values = np.load(path).astype(np.float32)
299
+ if values.shape != (height, width):
300
+ raise ValueError(f"{path}: expected {(height, width)}, got {values.shape}")
301
+ result[name] = (("lat", "lon"), values[::-1])
302
+ return result
303
+
304
+ def _validate_task_config(self) -> None:
305
+ expected = gencast.TASK
306
+ for field in (
307
+ "input_variables", "target_variables", "forcing_variables",
308
+ "pressure_levels", "input_duration",
309
+ ):
310
+ if getattr(self.task_config, field) != getattr(expected, field):
311
+ raise ValueError(
312
+ "This ERA5 adapter supports the official WB13 GenCast task "
313
+ f"only; checkpoint field {field} differs"
314
+ )
315
+
316
+ @staticmethod
317
+ def validate_dataset(dataset: xarray.Dataset) -> None:
318
+ missing = sorted(
319
+ set(gencast.TASK.input_variables + gencast.TASK.target_variables)
320
+ - set(dataset.data_vars)
321
+ - set(graphcast.GENERATED_FORCING_VARS)
322
+ )
323
+ if missing:
324
+ raise ValueError(f"Missing GenCast variables: {missing}")
325
+ if tuple(int(level) for level in dataset.level.values) != PRESSURE_LEVELS:
326
+ raise ValueError("GenCast WB13 pressure-level order changed")
327
+ if not np.all(np.diff(dataset.lat.values) > 0):
328
+ raise ValueError("GenCast latitude must be strictly ascending")
329
+ height, width = dataset.sizes["lat"], dataset.sizes["lon"]
330
+ if width != 2 * (height - 1):
331
+ raise ValueError(
332
+ "GenCast equiangular grids with poles require lon=2*(lat-1), "
333
+ f"got lat={height}, lon={width}"
334
+ )
335
+ if len(expected_target_channel_names()) != MODEL_TARGET_CHANNELS:
336
+ raise AssertionError("The official GenCast target contract must contain 84 channels")
337
+
338
+
339
+ def batch_iterator(
340
+ dataset: GenCastERA5Dataset,
341
+ *,
342
+ shuffle: bool,
343
+ seed: int,
344
+ batch_size: int = 1,
345
+ ) -> Iterator:
346
+ if batch_size < 1:
347
+ raise ValueError("batch_size must be positive")
348
+ indices = np.arange(len(dataset))
349
+ if shuffle:
350
+ np.random.default_rng(seed).shuffle(indices)
351
+ for start in range(0, len(indices) - batch_size + 1, batch_size):
352
+ samples = [dataset[int(index)] for index in indices[start:start + batch_size]]
353
+ if batch_size == 1:
354
+ yield samples[0]
355
+ continue
356
+ yield tuple(
357
+ xarray.concat(values, dim="batch", data_vars="minimal", coords="minimal")
358
+ for values in zip(*samples)
359
+ )
model/gencast.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """项目内官方等价 GenCast JAX/Haiku 实现封装。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import haiku as hk
10
+ import jax
11
+ import xarray
12
+
13
+ from model.graphcast import checkpoint
14
+ from model.graphcast import denoiser
15
+ from model.graphcast import gencast
16
+ from model.graphcast import nan_cleaning
17
+ from model.graphcast import normalization
18
+ from model.graphcast import xarray_jax
19
+ from model.graphcast import xarray_tree
20
+
21
+
22
+ def build_model_config(config: dict[str, Any]) -> tuple[
23
+ Any, denoiser.DenoiserArchitectureConfig, gencast.SamplerConfig,
24
+ gencast.NoiseConfig, denoiser.NoiseEncoderConfig
25
+ ]:
26
+ """Build a random-weight configuration without changing GenCast semantics."""
27
+ model_cfg = config["model"]
28
+ sampler_cfg = config["sampler"]
29
+ transformer = denoiser.SparseTransformerConfig(
30
+ attention_k_hop=int(model_cfg["attention_k_hop"]),
31
+ d_model=int(model_cfg["latent_size"]),
32
+ num_layers=int(model_cfg["num_layers"]),
33
+ num_heads=int(model_cfg["num_heads"]),
34
+ attention_type=str(model_cfg["attention_type"]),
35
+ mask_type=str(model_cfg.get("mask_type", "full")),
36
+ ffw_hidden=int(model_cfg["ffw_hidden"]),
37
+ )
38
+ architecture = denoiser.DenoiserArchitectureConfig(
39
+ sparse_transformer_config=transformer,
40
+ mesh_size=int(model_cfg["mesh_size"]),
41
+ latent_size=int(model_cfg["latent_size"]),
42
+ hidden_layers=int(model_cfg.get("hidden_layers", 1)),
43
+ radius_query_fraction_edge_length=float(
44
+ model_cfg.get("radius_query_fraction_edge_length", 0.6)
45
+ ),
46
+ )
47
+ sampler = gencast.SamplerConfig(**sampler_cfg)
48
+ return (
49
+ gencast.TASK,
50
+ architecture,
51
+ sampler,
52
+ gencast.NoiseConfig(),
53
+ denoiser.NoiseEncoderConfig(),
54
+ )
55
+
56
+
57
+ def load_model_checkpoint(path: str | Path) -> gencast.CheckPoint:
58
+ """Load the typed official GenCast NPZ checkpoint."""
59
+ with Path(path).open("rb") as source:
60
+ return checkpoint.load(source, gencast.CheckPoint)
61
+
62
+
63
+ class GenCastModel:
64
+ """Owns official-equivalent GenCast loss and sampling Haiku transforms."""
65
+
66
+ def __init__(
67
+ self,
68
+ *,
69
+ task_config: Any,
70
+ architecture_config: denoiser.DenoiserArchitectureConfig,
71
+ sampler_config: gencast.SamplerConfig,
72
+ noise_config: gencast.NoiseConfig,
73
+ noise_encoder_config: denoiser.NoiseEncoderConfig,
74
+ diffs_stddev_by_level: xarray.Dataset,
75
+ mean_by_level: xarray.Dataset,
76
+ stddev_by_level: xarray.Dataset,
77
+ min_by_level: xarray.Dataset,
78
+ reintroduce_nans: bool = True,
79
+ ) -> None:
80
+ self.task_config = task_config
81
+ self.architecture_config = architecture_config
82
+ self.sampler_config = sampler_config
83
+ self.noise_config = noise_config
84
+ self.noise_encoder_config = noise_encoder_config
85
+ self.diffs_stddev_by_level = diffs_stddev_by_level
86
+ self.mean_by_level = mean_by_level
87
+ self.stddev_by_level = stddev_by_level
88
+ self.min_by_level = min_by_level
89
+ self.reintroduce_nans = reintroduce_nans
90
+
91
+ def construct() -> Any:
92
+ predictor = gencast.GenCast(
93
+ task_config=self.task_config,
94
+ denoiser_architecture_config=self.architecture_config,
95
+ sampler_config=self.sampler_config,
96
+ noise_config=self.noise_config,
97
+ noise_encoder_config=self.noise_encoder_config,
98
+ )
99
+ predictor = normalization.InputsAndResiduals(
100
+ predictor,
101
+ diffs_stddev_by_level=self.diffs_stddev_by_level,
102
+ mean_by_level=self.mean_by_level,
103
+ stddev_by_level=self.stddev_by_level,
104
+ )
105
+ return nan_cleaning.NaNCleaner(
106
+ predictor,
107
+ var_to_clean="sea_surface_temperature",
108
+ fill_value=self.min_by_level,
109
+ reintroduce_nans=self.reintroduce_nans,
110
+ )
111
+
112
+ @hk.transform_with_state
113
+ def loss_fn(inputs, targets, forcings):
114
+ loss, diagnostics = construct().loss(inputs, targets, forcings)
115
+ return xarray_tree.map_structure(
116
+ lambda value: xarray_jax.unwrap_data(
117
+ value.mean(), require_jax=True
118
+ ),
119
+ (loss, diagnostics),
120
+ )
121
+
122
+ @hk.transform_with_state
123
+ def forward_fn(inputs, targets_template, forcings):
124
+ return construct()(
125
+ inputs,
126
+ targets_template=targets_template,
127
+ forcings=forcings,
128
+ )
129
+
130
+ self.loss_fn = loss_fn
131
+ self.forward_fn = forward_fn
132
+
133
+ @classmethod
134
+ def from_config_and_stats(
135
+ cls, config: dict[str, Any], stats: dict[str, xarray.Dataset]
136
+ ) -> "GenCastModel":
137
+ configs = build_model_config(config)
138
+ return cls(
139
+ task_config=configs[0],
140
+ architecture_config=configs[1],
141
+ sampler_config=configs[2],
142
+ noise_config=configs[3],
143
+ noise_encoder_config=configs[4],
144
+ diffs_stddev_by_level=stats["diffs_stddev_by_level"],
145
+ mean_by_level=stats["mean_by_level"],
146
+ stddev_by_level=stats["stddev_by_level"],
147
+ min_by_level=stats["min_by_level"],
148
+ reintroduce_nans=bool(config.get("data", {}).get("reintroduce_sst_nans", True)),
149
+ )
150
+
151
+ @classmethod
152
+ def from_checkpoint_and_stats(
153
+ cls,
154
+ model_checkpoint: gencast.CheckPoint,
155
+ stats: dict[str, xarray.Dataset],
156
+ *,
157
+ attention_type: str | None = None,
158
+ ) -> "GenCastModel":
159
+ architecture = model_checkpoint.denoiser_architecture_config
160
+ if attention_type is not None:
161
+ architecture = dataclasses.replace(
162
+ architecture,
163
+ sparse_transformer_config=dataclasses.replace(
164
+ architecture.sparse_transformer_config,
165
+ attention_type=attention_type,
166
+ mask_type="full",
167
+ ),
168
+ )
169
+ return cls(
170
+ task_config=model_checkpoint.task_config,
171
+ architecture_config=architecture,
172
+ sampler_config=model_checkpoint.sampler_config,
173
+ noise_config=model_checkpoint.noise_config,
174
+ noise_encoder_config=model_checkpoint.noise_encoder_config,
175
+ diffs_stddev_by_level=stats["diffs_stddev_by_level"],
176
+ mean_by_level=stats["mean_by_level"],
177
+ stddev_by_level=stats["stddev_by_level"],
178
+ min_by_level=stats["min_by_level"],
179
+ )
180
+
181
+ def init(self, rng, inputs, targets, forcings):
182
+ return self.loss_fn.init(rng, inputs, targets, forcings)
183
+
184
+ def loss(self, params, state, rng, inputs, targets, forcings):
185
+ return self.loss_fn.apply(params, state, rng, inputs, targets, forcings)
186
+
187
+ def predict(self, params, state, rng, inputs, targets_template, forcings):
188
+ return self.forward_fn.apply(
189
+ params, state, rng, inputs, targets_template, forcings
190
+ )
191
+
192
+
193
+ def parameter_count(params: Any) -> int:
194
+ return sum(int(value.size) for value in jax.tree_util.tree_leaves(params))
model/graphcast/autoregressive.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2023 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """A Predictor wrapping a one-step Predictor to make autoregressive predictions.
15
+ """
16
+
17
+ from typing import Optional, cast
18
+
19
+ from absl import logging
20
+ from . import predictor_base
21
+ from . import xarray_jax
22
+ from . import xarray_tree
23
+ import haiku as hk
24
+ import jax
25
+ import xarray
26
+
27
+
28
+ def _unflatten_and_expand_time(flat_variables, tree_def, time_coords):
29
+ variables = jax.tree_util.tree_unflatten(tree_def, flat_variables)
30
+ return variables.expand_dims(time=time_coords, axis=0)
31
+
32
+
33
+ def _get_flat_arrays_and_single_timestep_treedef(variables):
34
+ flat_arrays = jax.tree_util.tree_leaves(variables.transpose('time', ...))
35
+ _, treedef = jax.tree_util.tree_flatten(variables.isel(time=0, drop=True))
36
+ return flat_arrays, treedef
37
+
38
+
39
+ class Predictor(predictor_base.Predictor):
40
+ """Wraps a one-step Predictor to make multi-step predictions autoregressively.
41
+
42
+ The wrapped Predictor will be used to predict a single timestep conditional
43
+ on the inputs passed to the outer Predictor. Its predictions are then
44
+ passed back in as inputs at the next timestep, for as many timesteps as are
45
+ requested in the targets_template. (When multiple timesteps of input are
46
+ used, a rolling window of inputs is maintained with new predictions
47
+ concatenated onto the end).
48
+
49
+ You may ask for additional variables to be predicted as targets which aren't
50
+ used as inputs. These will be predicted as output variables only and not fed
51
+ back in autoregressively. All target variables must be time-dependent however.
52
+
53
+ You may also specify static (non-time-dependent) inputs which will be passed
54
+ in at each timestep but are not predicted.
55
+
56
+ At present, any time-dependent inputs must also be present as targets so they
57
+ can be passed in autoregressively.
58
+
59
+ The loss of the wrapped one-step Predictor is averaged over all timesteps to
60
+ give a loss for the autoregressive Predictor.
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ predictor: predictor_base.Predictor,
66
+ noise_level: Optional[float] = None,
67
+ gradient_checkpointing: bool = False,
68
+ ):
69
+ """Initializes an autoregressive predictor wrapper.
70
+
71
+ Args:
72
+ predictor: A predictor to wrap in an auto-regressive way.
73
+ noise_level: Optional value that multiplies the standard normal noise
74
+ added to the time-dependent variables of the predictor inputs. In
75
+ particular, no noise is added to the predictions that are fed back
76
+ auto-regressively. Defaults to not adding noise.
77
+ gradient_checkpointing: If True, gradient checkpointing will be
78
+ used at each step of the computation to save on memory. Roughtly this
79
+ should make the backwards pass two times more expensive, and the time
80
+ per step counting the forward pass, should only increase by about 50%.
81
+ Note this parameter will be ignored with a warning if the scan sequence
82
+ length is 1.
83
+ """
84
+ self._predictor = predictor
85
+ self._noise_level = noise_level
86
+ self._gradient_checkpointing = gradient_checkpointing
87
+
88
+ def _get_and_validate_constant_inputs(self, inputs, targets, forcings):
89
+ constant_inputs = inputs.drop_vars(targets.keys(), errors='ignore')
90
+ constant_inputs = constant_inputs.drop_vars(
91
+ forcings.keys(), errors='ignore')
92
+ for name, var in constant_inputs.items():
93
+ if 'time' in var.dims:
94
+ raise ValueError(
95
+ f'Time-dependent input variable {name} must either be a forcing '
96
+ 'variable, or a target variable to allow for auto-regressive '
97
+ 'feedback.')
98
+ return constant_inputs
99
+
100
+ def _validate_targets_and_forcings(self, targets, forcings):
101
+ for name, var in targets.items():
102
+ if 'time' not in var.dims:
103
+ raise ValueError(f'Target variable {name} must be time-dependent.')
104
+
105
+ for name, var in forcings.items():
106
+ if 'time' not in var.dims:
107
+ raise ValueError(f'Forcing variable {name} must be time-dependent.')
108
+
109
+ overlap = forcings.keys() & targets.keys()
110
+ if overlap:
111
+ raise ValueError('The following were specified as both targets and '
112
+ f'forcings, which isn\'t allowed: {overlap}')
113
+
114
+ def _update_inputs(self, inputs, next_frame):
115
+ num_inputs = inputs.dims['time']
116
+
117
+ predicted_or_forced_inputs = next_frame[list(inputs.keys())]
118
+
119
+ # Combining datasets with inputs and target time stamps aligns them.
120
+ # Only keep the num_inputs trailing frames for use as next inputs.
121
+ return (xarray.concat([inputs, predicted_or_forced_inputs], dim='time')
122
+ .tail(time=num_inputs)
123
+ # Update the time coordinate to reset the lead times for
124
+ # next AR iteration.
125
+ .assign_coords(time=inputs.coords['time']))
126
+
127
+ def __call__(self,
128
+ inputs: xarray.Dataset,
129
+ targets_template: xarray.Dataset,
130
+ forcings: xarray.Dataset,
131
+ **kwargs) -> xarray.Dataset:
132
+ """Calls the Predictor.
133
+
134
+ Args:
135
+ inputs: input variable used to make predictions. Inputs can include both
136
+ time-dependent and time independent variables. Any time-dependent
137
+ input variables must also be present in the targets_template or the
138
+ forcings.
139
+ targets_template: A target template containing informations about which
140
+ variables should be predicted and the time alignment of the predictions.
141
+ All target variables must be time-dependent.
142
+ The number of time frames is used to set the number of unroll of the AR
143
+ predictor (e.g. multiple unroll of the inner predictor for one time step
144
+ in the targets is not supported yet).
145
+ forcings: Variables that will be fed to the model. The variables
146
+ should not overlap with the target ones. The time coordinates of the
147
+ forcing variables should match the target ones.
148
+ Forcing variables which are also present in the inputs, will be used to
149
+ supply ground-truth values for those inputs when they are passed to the
150
+ underlying predictor at timesteps beyond the first timestep.
151
+ **kwargs: Additional arguments passed along to the inner Predictor.
152
+
153
+ Returns:
154
+ predictions: the model predictions matching the target template.
155
+
156
+ Raise:
157
+ ValueError: if the time coordinates of the inputs and targets are not
158
+ different by a constant time step.
159
+ """
160
+
161
+ constant_inputs = self._get_and_validate_constant_inputs(
162
+ inputs, targets_template, forcings)
163
+ self._validate_targets_and_forcings(targets_template, forcings)
164
+
165
+ # After the above checks, the remaining inputs must be time-dependent:
166
+ inputs = inputs.drop_vars(constant_inputs.keys())
167
+
168
+ # A predictions template only including the next time to predict.
169
+ target_template = targets_template.isel(time=[0])
170
+
171
+ flat_forcings, forcings_treedef = (
172
+ _get_flat_arrays_and_single_timestep_treedef(forcings))
173
+ scan_variables = flat_forcings
174
+
175
+ def one_step_prediction(inputs, scan_variables):
176
+
177
+ flat_forcings = scan_variables
178
+ forcings = _unflatten_and_expand_time(flat_forcings, forcings_treedef,
179
+ target_template.coords['time'])
180
+
181
+ # Add constant inputs:
182
+ all_inputs = xarray.merge([constant_inputs, inputs])
183
+ predictions: xarray.Dataset = self._predictor(
184
+ all_inputs, target_template,
185
+ forcings=forcings,
186
+ **kwargs)
187
+
188
+ next_frame = xarray.merge([predictions, forcings])
189
+ next_inputs = self._update_inputs(inputs, next_frame)
190
+
191
+ # Drop the length-1 time dimension, since scan will concat all the outputs
192
+ # for different times along a new leading time dimension:
193
+ predictions = predictions.squeeze('time', drop=True)
194
+ # We return the prediction flattened into plain jax arrays, because the
195
+ # extra leading dimension added by scan prevents the tree_util
196
+ # registrations in xarray_jax from unflattening them back into an
197
+ # xarray.Dataset automatically:
198
+ flat_pred = jax.tree_util.tree_leaves(predictions)
199
+ return next_inputs, flat_pred
200
+
201
+ if self._gradient_checkpointing:
202
+ scan_length = targets_template.dims['time']
203
+ if scan_length <= 1:
204
+ logging.warning(
205
+ 'Skipping gradient checkpointing for sequence length of 1')
206
+ else:
207
+ # Just in case we take gradients (e.g. for control), although
208
+ # in most cases this will just be for a forward pass.
209
+ one_step_prediction = hk.remat(one_step_prediction)
210
+
211
+ # Loop (without unroll) with hk states in cell (jax.lax.scan won't do).
212
+ _, flat_preds = hk.scan(one_step_prediction, inputs, scan_variables)
213
+
214
+ # The result of scan will have an extra leading axis on all arrays,
215
+ # corresponding to the target times in this case. We need to be prepared for
216
+ # it when unflattening the arrays back into a Dataset:
217
+ scan_result_template = (
218
+ target_template.squeeze('time', drop=True)
219
+ .expand_dims(time=targets_template.coords['time'], axis=0))
220
+ _, scan_result_treedef = jax.tree_util.tree_flatten(scan_result_template)
221
+ predictions = jax.tree_util.tree_unflatten(scan_result_treedef, flat_preds)
222
+ return predictions
223
+
224
+ def loss(self,
225
+ inputs: xarray.Dataset,
226
+ targets: xarray.Dataset,
227
+ forcings: xarray.Dataset,
228
+ **kwargs
229
+ ) -> predictor_base.LossAndDiagnostics:
230
+ """The mean of the per-timestep losses of the underlying predictor."""
231
+ if targets.sizes['time'] == 1:
232
+ # If there is only a single target timestep then we don't need any
233
+ # autoregressive feedback and can delegate the loss directly to the
234
+ # underlying single-step predictor. This means the underlying predictor
235
+ # doesn't need to implement .loss_and_predictions.
236
+ return self._predictor.loss(inputs, targets, forcings, **kwargs)
237
+
238
+ constant_inputs = self._get_and_validate_constant_inputs(
239
+ inputs, targets, forcings)
240
+ self._validate_targets_and_forcings(targets, forcings)
241
+ # After the above checks, the remaining inputs must be time-dependent:
242
+ inputs = inputs.drop_vars(constant_inputs.keys())
243
+
244
+ if self._noise_level:
245
+ def add_noise(x):
246
+ return x + self._noise_level * jax.random.normal(
247
+ hk.next_rng_key(), shape=x.shape)
248
+ # Add noise to time-dependent variables of the inputs.
249
+ inputs = jax.tree.map(add_noise, inputs)
250
+
251
+ # The per-timestep targets passed by scan to one_step_loss below will have
252
+ # no leading time axis. We need a treedef without the time axis to use
253
+ # inside one_step_loss to unflatten it back into a dataset:
254
+ flat_targets, target_treedef = _get_flat_arrays_and_single_timestep_treedef(
255
+ targets)
256
+ scan_variables = flat_targets
257
+
258
+ flat_forcings, forcings_treedef = (
259
+ _get_flat_arrays_and_single_timestep_treedef(forcings))
260
+ scan_variables = (flat_targets, flat_forcings)
261
+
262
+ def one_step_loss(inputs, scan_variables):
263
+ flat_target, flat_forcings = scan_variables
264
+ forcings = _unflatten_and_expand_time(flat_forcings, forcings_treedef,
265
+ targets.coords['time'][:1])
266
+
267
+ target = _unflatten_and_expand_time(flat_target, target_treedef,
268
+ targets.coords['time'][:1])
269
+
270
+ # Add constant inputs:
271
+ all_inputs = xarray.merge([constant_inputs, inputs])
272
+
273
+ (loss, diagnostics), predictions = self._predictor.loss_and_predictions(
274
+ all_inputs,
275
+ target,
276
+ forcings=forcings,
277
+ **kwargs)
278
+
279
+ # Unwrap to jax arrays shape (batch,):
280
+ loss, diagnostics = xarray_tree.map_structure(
281
+ xarray_jax.unwrap_data, (loss, diagnostics))
282
+
283
+ predictions = cast(xarray.Dataset, predictions) # Keeps pytype happy.
284
+ next_frame = xarray.merge([predictions, forcings])
285
+ next_inputs = self._update_inputs(inputs, next_frame)
286
+
287
+ return next_inputs, (loss, diagnostics)
288
+
289
+ if self._gradient_checkpointing:
290
+ scan_length = targets.dims['time']
291
+ if scan_length <= 1:
292
+ logging.warning(
293
+ 'Skipping gradient checkpointing for sequence length of 1')
294
+ else:
295
+ one_step_loss = hk.remat(one_step_loss)
296
+
297
+ # We can pass inputs (the initial state of the loop) in directly as a
298
+ # Dataset because the shape we pass in to scan is the same as the shape scan
299
+ # passes to the inner function. But, for scan_variables, we must flatten the
300
+ # targets (and unflatten them inside the inner function) because they are
301
+ # passed to the inner function per-timestep without the original time axis.
302
+ # The same apply to the optional forcing.
303
+ _, (per_timestep_losses, per_timestep_diagnostics) = hk.scan(
304
+ one_step_loss, inputs, scan_variables)
305
+
306
+ # Re-wrap loss and diagnostics as DataArray and average them over time:
307
+ (loss, diagnostics) = jax.tree_util.tree_map(
308
+ lambda x: xarray_jax.DataArray(x, dims=('time', 'batch')).mean( # pylint: disable=g-long-lambda
309
+ 'time', skipna=False),
310
+ (per_timestep_losses, per_timestep_diagnostics))
311
+
312
+ return loss, diagnostics
model/graphcast/casting.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2023 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Wrappers that take care of casting."""
15
+
16
+ import contextlib
17
+ from typing import Any, Mapping, Tuple
18
+
19
+ import chex
20
+ from . import predictor_base
21
+ import haiku as hk
22
+ import jax
23
+ import jax.numpy as jnp
24
+ import numpy as np
25
+ import xarray
26
+
27
+
28
+ PyTree = Any
29
+
30
+
31
+ class Bfloat16Cast(predictor_base.Predictor):
32
+ """Wrapper that casts all inputs to bfloat16 and outputs to targets dtype."""
33
+
34
+ def __init__(self, predictor: predictor_base.Predictor, enabled: bool = True):
35
+ """Inits the wrapper.
36
+
37
+ Args:
38
+ predictor: predictor being wrapped.
39
+ enabled: disables the wrapper if False, for simpler hyperparameter scans.
40
+
41
+ """
42
+ self._enabled = enabled
43
+ self._predictor = predictor
44
+
45
+ def __call__(self,
46
+ inputs: xarray.Dataset,
47
+ targets_template: xarray.Dataset,
48
+ forcings: xarray.Dataset,
49
+ **kwargs
50
+ ) -> xarray.Dataset:
51
+ if not self._enabled:
52
+ return self._predictor(inputs, targets_template, forcings, **kwargs)
53
+
54
+ with bfloat16_variable_view():
55
+ predictions = self._predictor(
56
+ *_all_inputs_to_bfloat16(inputs, targets_template, forcings),
57
+ **kwargs,)
58
+
59
+ predictions_dtype = infer_floating_dtype(predictions) # pytype: disable=wrong-arg-types
60
+ if predictions_dtype != jnp.bfloat16:
61
+ raise ValueError(f'Expected bfloat16 output, got {predictions_dtype}')
62
+
63
+ targets_dtype = infer_floating_dtype(targets_template) # pytype: disable=wrong-arg-types
64
+ return tree_map_cast(
65
+ predictions, input_dtype=jnp.bfloat16, output_dtype=targets_dtype)
66
+
67
+ def loss(self,
68
+ inputs: xarray.Dataset,
69
+ targets: xarray.Dataset,
70
+ forcings: xarray.Dataset,
71
+ **kwargs,
72
+ ) -> predictor_base.LossAndDiagnostics:
73
+ if not self._enabled:
74
+ return self._predictor.loss(inputs, targets, forcings, **kwargs)
75
+
76
+ with bfloat16_variable_view():
77
+ loss, scalars = self._predictor.loss(
78
+ *_all_inputs_to_bfloat16(inputs, targets, forcings), **kwargs)
79
+
80
+ if loss.dtype != jnp.bfloat16:
81
+ raise ValueError(f'Expected bfloat16 loss, got {loss.dtype}')
82
+
83
+ targets_dtype = infer_floating_dtype(targets) # pytype: disable=wrong-arg-types
84
+
85
+ # Note that casting back the loss to e.g. float32 should not affect data
86
+ # types of the backwards pass, because the first thing the backwards pass
87
+ # should do is to go backwards the casting op and cast back to bfloat16
88
+ # (and xprofs seem to confirm this).
89
+ return tree_map_cast((loss, scalars),
90
+ input_dtype=jnp.bfloat16, output_dtype=targets_dtype)
91
+
92
+ def loss_and_predictions( # pytype: disable=signature-mismatch # jax-ndarray
93
+ self,
94
+ inputs: xarray.Dataset,
95
+ targets: xarray.Dataset,
96
+ forcings: xarray.Dataset,
97
+ **kwargs,
98
+ ) -> Tuple[predictor_base.LossAndDiagnostics,
99
+ xarray.Dataset]:
100
+ if not self._enabled:
101
+ return self._predictor.loss_and_predictions(inputs, targets, forcings, # pytype: disable=bad-return-type # jax-ndarray
102
+ **kwargs)
103
+
104
+ with bfloat16_variable_view():
105
+ (loss, scalars), predictions = self._predictor.loss_and_predictions(
106
+ *_all_inputs_to_bfloat16(inputs, targets, forcings), **kwargs)
107
+
108
+ if loss.dtype != jnp.bfloat16:
109
+ raise ValueError(f'Expected bfloat16 loss, got {loss.dtype}')
110
+
111
+ predictions_dtype = infer_floating_dtype(predictions) # pytype: disable=wrong-arg-types
112
+ if predictions_dtype != jnp.bfloat16:
113
+ raise ValueError(f'Expected bfloat16 output, got {predictions_dtype}')
114
+
115
+ targets_dtype = infer_floating_dtype(targets) # pytype: disable=wrong-arg-types
116
+ return tree_map_cast(((loss, scalars), predictions),
117
+ input_dtype=jnp.bfloat16, output_dtype=targets_dtype)
118
+
119
+
120
+ def infer_floating_dtype(data_vars: Mapping[str, chex.Array]) -> np.dtype:
121
+ """Infers a floating dtype from an input mapping of data."""
122
+ dtypes = {
123
+ v.dtype
124
+ for k, v in data_vars.items() if jnp.issubdtype(v.dtype, np.floating)}
125
+ if len(dtypes) != 1:
126
+ dtypes_and_shapes = {
127
+ k: (v.dtype, v.shape)
128
+ for k, v in data_vars.items() if jnp.issubdtype(v.dtype, np.floating)}
129
+ raise ValueError(
130
+ f'Did not found exactly one floating dtype {dtypes} in input variables:'
131
+ f'{dtypes_and_shapes}')
132
+ return list(dtypes)[0]
133
+
134
+
135
+ def _all_inputs_to_bfloat16(
136
+ inputs: xarray.Dataset,
137
+ targets: xarray.Dataset,
138
+ forcings: xarray.Dataset,
139
+ ) -> Tuple[xarray.Dataset,
140
+ xarray.Dataset,
141
+ xarray.Dataset]:
142
+ return (inputs.astype(jnp.bfloat16),
143
+ jax.tree.map(lambda x: x.astype(jnp.bfloat16), targets),
144
+ forcings.astype(jnp.bfloat16))
145
+
146
+
147
+ def tree_map_cast(inputs: PyTree, input_dtype: np.dtype, output_dtype: np.dtype,
148
+ ) -> PyTree:
149
+ def cast_fn(x):
150
+ if x.dtype == input_dtype:
151
+ return x.astype(output_dtype)
152
+ return jax.tree.map(cast_fn, inputs)
153
+
154
+
155
+ @contextlib.contextmanager
156
+ def bfloat16_variable_view(enabled: bool = True):
157
+ """Context for Haiku modules with float32 params, but bfloat16 activations.
158
+
159
+ It works as follows:
160
+ * Every time a variable is requested to be created/set as np.bfloat16,
161
+ it will create an underlying float32 variable, instead.
162
+ * Every time a variable a variable is requested as bfloat16, it will check the
163
+ variable is of float32 type, and cast the variable to bfloat16.
164
+
165
+ Note the gradients are still computed and accumulated as float32, because
166
+ the params returned by init are float32, so the gradient function with
167
+ respect to the params will already include an implicit casting to float32.
168
+
169
+ Args:
170
+ enabled: Only enables bfloat16 behavior if True.
171
+
172
+ Yields:
173
+ None
174
+ """
175
+
176
+ if enabled:
177
+ with hk.custom_creator(
178
+ _bfloat16_creator, state=True), hk.custom_getter(
179
+ _bfloat16_getter, state=True), hk.custom_setter(
180
+ _bfloat16_setter):
181
+ yield
182
+ else:
183
+ yield
184
+
185
+
186
+ def _bfloat16_creator(next_creator, shape, dtype, init, context):
187
+ """Creates float32 variables when bfloat16 is requested."""
188
+ if context.original_dtype == jnp.bfloat16:
189
+ dtype = jnp.float32
190
+ return next_creator(shape, dtype, init)
191
+
192
+
193
+ def _bfloat16_getter(next_getter, value, context):
194
+ """Casts float32 to bfloat16 when bfloat16 was originally requested."""
195
+ if context.original_dtype == jnp.bfloat16:
196
+ assert value.dtype == jnp.float32
197
+ value = value.astype(jnp.bfloat16)
198
+ return next_getter(value)
199
+
200
+
201
+ def _bfloat16_setter(next_setter, value, context):
202
+ """Casts bfloat16 to float32 when bfloat16 was originally set."""
203
+ if context.original_dtype == jnp.bfloat16:
204
+ value = value.astype(jnp.float32)
205
+ return next_setter(value)
model/graphcast/checkpoint.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2023 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Serialize and deserialize trees."""
15
+
16
+ import dataclasses
17
+ import io
18
+ import types
19
+ from typing import Any, BinaryIO, Optional, TypeVar
20
+
21
+ import numpy as np
22
+
23
+ _T = TypeVar("_T")
24
+
25
+
26
+ def dump(dest: BinaryIO, value: Any) -> None:
27
+ """Dump a tree of dicts/dataclasses to a file object.
28
+
29
+ Args:
30
+ dest: a file object to write to.
31
+ value: A tree of dicts, lists, tuples and dataclasses of numpy arrays and
32
+ other basic types. Unions are not supported, other than Optional/None
33
+ which is only supported in dataclasses, not in dicts, lists or tuples.
34
+ All leaves must be coercible to a numpy array, and recoverable as a single
35
+ arg to a type.
36
+ """
37
+ buffer = io.BytesIO() # In case the destination doesn't support seeking.
38
+ np.savez(buffer, **_flatten(value))
39
+ dest.write(buffer.getvalue())
40
+
41
+
42
+ def load(source: BinaryIO, typ: type[_T]) -> _T:
43
+ """Load from a file object and convert it to the specified type.
44
+
45
+ Args:
46
+ source: a file object to read from.
47
+ typ: a type object that acts as a schema for deserialization. It must match
48
+ what was serialized. If a type is Any, it will be returned however numpy
49
+ serialized it, which is what you want for a tree of numpy arrays.
50
+
51
+ Returns:
52
+ the deserialized value as the specified type.
53
+ """
54
+ return _convert_types(typ, _unflatten(np.load(source)))
55
+
56
+
57
+ _SEP = ":"
58
+
59
+
60
+ def _flatten(tree: Any) -> dict[str, Any]:
61
+ """Flatten a tree of dicts/dataclasses/lists/tuples to a single dict."""
62
+ if dataclasses.is_dataclass(tree):
63
+ # Don't use dataclasses.asdict as it is recursive so skips dropping None.
64
+ tree = {f.name: v for f in dataclasses.fields(tree)
65
+ if (v := getattr(tree, f.name)) is not None}
66
+ elif isinstance(tree, (list, tuple)):
67
+ tree = dict(enumerate(tree))
68
+
69
+ assert isinstance(tree, dict)
70
+
71
+ flat = {}
72
+ for k, v in tree.items():
73
+ k = str(k)
74
+ assert _SEP not in k
75
+ if dataclasses.is_dataclass(v) or isinstance(v, (dict, list, tuple)):
76
+ for a, b in _flatten(v).items():
77
+ flat[f"{k}{_SEP}{a}"] = b
78
+ else:
79
+ assert v is not None
80
+ flat[k] = v
81
+ return flat
82
+
83
+
84
+ def _unflatten(flat: dict[str, Any]) -> dict[str, Any]:
85
+ """Unflatten a dict to a tree of dicts."""
86
+ tree = {}
87
+ for flat_key, v in flat.items():
88
+ node = tree
89
+ keys = flat_key.split(_SEP)
90
+ for k in keys[:-1]:
91
+ if k not in node:
92
+ node[k] = {}
93
+ node = node[k]
94
+ node[keys[-1]] = v
95
+ return tree
96
+
97
+
98
+ def _convert_types(typ: type[_T], value: Any) -> _T:
99
+ """Convert some structure into the given type. The structures must match."""
100
+ if typ in (Any, ...):
101
+ return value
102
+
103
+ if typ in (int, float, str, bool):
104
+ return typ(value)
105
+
106
+ if typ is np.ndarray:
107
+ assert isinstance(value, np.ndarray)
108
+ return value
109
+
110
+ if dataclasses.is_dataclass(typ):
111
+ kwargs = {}
112
+ for f in dataclasses.fields(typ):
113
+ # Only support Optional for dataclasses, as numpy can't serialize it
114
+ # directly (without pickle), and dataclasses are the only case where we
115
+ # can know the full set of values and types and therefore know the
116
+ # non-existence must mean None.
117
+ if isinstance(f.type, (types.UnionType, type(Optional[int]))):
118
+ constructors = [t for t in f.type.__args__ if t is not types.NoneType]
119
+ if len(constructors) != 1:
120
+ raise TypeError(
121
+ "Optional works, Union with anything except None doesn't")
122
+ if f.name not in value:
123
+ kwargs[f.name] = None
124
+ continue
125
+ constructor = constructors[0]
126
+ else:
127
+ constructor = f.type
128
+
129
+ if f.name in value:
130
+ kwargs[f.name] = _convert_types(constructor, value[f.name])
131
+ else:
132
+ raise ValueError(f"Missing value: {f.name}")
133
+ return typ(**kwargs)
134
+
135
+ base_type = getattr(typ, "__origin__", None)
136
+
137
+ if base_type is dict:
138
+ assert len(typ.__args__) == 2
139
+ key_type, value_type = typ.__args__
140
+ return {_convert_types(key_type, k): _convert_types(value_type, v)
141
+ for k, v in value.items()}
142
+
143
+ if base_type is list:
144
+ assert len(typ.__args__) == 1
145
+ value_type = typ.__args__[0]
146
+ return [_convert_types(value_type, v)
147
+ for _, v in sorted(value.items(), key=lambda x: int(x[0]))]
148
+
149
+ if base_type is tuple:
150
+ if len(typ.__args__) == 2 and typ.__args__[1] == ...:
151
+ # An arbitrary length tuple of a single type, eg: tuple[int, ...]
152
+ value_type = typ.__args__[0]
153
+ return tuple(_convert_types(value_type, v)
154
+ for _, v in sorted(value.items(), key=lambda x: int(x[0])))
155
+ else:
156
+ # A fixed length tuple of arbitrary types, eg: tuple[int, str, float]
157
+ assert len(typ.__args__) == len(value)
158
+ return tuple(
159
+ _convert_types(t, v)
160
+ for t, (_, v) in zip(
161
+ typ.__args__, sorted(value.items(), key=lambda x: int(x[0]))))
162
+
163
+ # This is probably unreachable with reasonable serializable inputs.
164
+ try:
165
+ return typ(value)
166
+ except TypeError as e:
167
+ raise TypeError(
168
+ "_convert_types expects the type argument to be a dataclass defined "
169
+ "with types that are valid constructors (eg tuple is fine, Tuple "
170
+ "isn't), and accept a numpy array as the sole argument.") from e
model/graphcast/data_utils.py ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2023 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Dataset utilities."""
15
+
16
+ from typing import Any, Mapping, Sequence, Tuple, Union
17
+
18
+ from . import solar_radiation
19
+ import numpy as np
20
+ import pandas as pd
21
+ import xarray
22
+
23
+ TimedeltaLike = Any # Something convertible to pd.Timedelta.
24
+ TimedeltaStr = str # A string convertible to pd.Timedelta.
25
+
26
+ TargetLeadTimes = Union[
27
+ TimedeltaLike,
28
+ Sequence[TimedeltaLike],
29
+ slice # with TimedeltaLike as its start and stop.
30
+ ]
31
+
32
+ _SEC_PER_HOUR = 3600
33
+ _HOUR_PER_DAY = 24
34
+ SEC_PER_DAY = _SEC_PER_HOUR * _HOUR_PER_DAY
35
+ _AVG_DAY_PER_YEAR = 365.24219
36
+ AVG_SEC_PER_YEAR = SEC_PER_DAY * _AVG_DAY_PER_YEAR
37
+
38
+ DAY_PROGRESS = "day_progress"
39
+ YEAR_PROGRESS = "year_progress"
40
+ _DERIVED_VARS = {
41
+ DAY_PROGRESS,
42
+ f"{DAY_PROGRESS}_sin",
43
+ f"{DAY_PROGRESS}_cos",
44
+ YEAR_PROGRESS,
45
+ f"{YEAR_PROGRESS}_sin",
46
+ f"{YEAR_PROGRESS}_cos",
47
+ }
48
+ TISR = "toa_incident_solar_radiation"
49
+
50
+
51
+ def get_year_progress(seconds_since_epoch: np.ndarray) -> np.ndarray:
52
+ """Computes year progress for times in seconds.
53
+
54
+ Args:
55
+ seconds_since_epoch: Times in seconds since the "epoch" (the point at which
56
+ UNIX time starts).
57
+
58
+ Returns:
59
+ Year progress normalized to be in the [0, 1) interval for each time point.
60
+ """
61
+
62
+ # Start with the pure integer division, and then float at the very end.
63
+ # We will try to keep as much precision as possible.
64
+ years_since_epoch = (
65
+ seconds_since_epoch / SEC_PER_DAY / np.float64(_AVG_DAY_PER_YEAR)
66
+ )
67
+ # Note depending on how these ops are down, we may end up with a "weak_type"
68
+ # which can cause issues in subtle ways, and hard to track here.
69
+ # In any case, casting to float32 should get rid of the weak type.
70
+ # [0, 1.) Interval.
71
+ return np.mod(years_since_epoch, 1.0).astype(np.float32)
72
+
73
+
74
+ def get_day_progress(
75
+ seconds_since_epoch: np.ndarray,
76
+ longitude: np.ndarray,
77
+ ) -> np.ndarray:
78
+ """Computes day progress for times in seconds at each longitude.
79
+
80
+ Args:
81
+ seconds_since_epoch: 1D array of times in seconds since the 'epoch' (the
82
+ point at which UNIX time starts).
83
+ longitude: 1D array of longitudes at which day progress is computed.
84
+
85
+ Returns:
86
+ 2D array of day progress values normalized to be in the [0, 1) inverval
87
+ for each time point at each longitude.
88
+ """
89
+
90
+ # [0.0, 1.0) Interval.
91
+ day_progress_greenwich = (
92
+ np.mod(seconds_since_epoch, SEC_PER_DAY) / SEC_PER_DAY
93
+ )
94
+
95
+ # Offset the day progress to the longitude of each point on Earth.
96
+ longitude_offsets = np.deg2rad(longitude) / (2 * np.pi)
97
+ day_progress = np.mod(
98
+ day_progress_greenwich[..., np.newaxis] + longitude_offsets, 1.0
99
+ )
100
+ return day_progress.astype(np.float32)
101
+
102
+
103
+ def featurize_progress(
104
+ name: str, dims: Sequence[str], progress: np.ndarray
105
+ ) -> Mapping[str, xarray.Variable]:
106
+ """Derives features used by ML models from the `progress` variable.
107
+
108
+ Args:
109
+ name: Base variable name from which features are derived.
110
+ dims: List of the output feature dimensions, e.g. ("day", "lon").
111
+ progress: Progress variable values.
112
+
113
+ Returns:
114
+ Dictionary of xarray variables derived from the `progress` values. It
115
+ includes the original `progress` variable along with its sin and cos
116
+ transformations.
117
+
118
+ Raises:
119
+ ValueError if the number of feature dimensions is not equal to the number
120
+ of data dimensions.
121
+ """
122
+ if len(dims) != progress.ndim:
123
+ raise ValueError(
124
+ f"Number of feature dimensions ({len(dims)}) must be equal to the"
125
+ f" number of data dimensions: {progress.ndim}."
126
+ )
127
+ progress_phase = progress * (2 * np.pi)
128
+ return {
129
+ name: xarray.Variable(dims, progress),
130
+ name + "_sin": xarray.Variable(dims, np.sin(progress_phase)),
131
+ name + "_cos": xarray.Variable(dims, np.cos(progress_phase)),
132
+ }
133
+
134
+
135
+ def get_seconds_since_epoch(datetime_sequence: xarray.DataArray) -> np.ndarray:
136
+ """Computes seconds since epoch from `data` in place if missing."""
137
+ # Note `datetime_sequence.astype("datetime64[s]").astype(np.int64)`
138
+ # does not work as xarrays always cast dates into nanoseconds!
139
+ return datetime_sequence.data.astype("datetime64[s]").astype(np.int64)
140
+
141
+
142
+ def add_derived_vars(data: xarray.Dataset) -> None:
143
+ """Adds year and day progress features to `data` in place if missing.
144
+
145
+ Args:
146
+ data: Xarray dataset to which derived features will be added.
147
+
148
+ Raises:
149
+ ValueError if `datetime` or `lon` are not in `data` coordinates.
150
+ """
151
+
152
+ for coord in ("datetime", "lon"):
153
+ if coord not in data.coords:
154
+ raise ValueError(f"'{coord}' must be in `data` coordinates.")
155
+
156
+ # Compute seconds since epoch.
157
+ seconds_since_epoch = get_seconds_since_epoch(data.coords["datetime"])
158
+ batch_dim = ("batch",) if "batch" in data.dims else ()
159
+
160
+ # Add year progress features if missing.
161
+ if YEAR_PROGRESS not in data.data_vars:
162
+ year_progress = get_year_progress(seconds_since_epoch)
163
+ data.update(
164
+ featurize_progress(
165
+ name=YEAR_PROGRESS,
166
+ dims=batch_dim + ("time",),
167
+ progress=year_progress,
168
+ )
169
+ )
170
+
171
+ # Add day progress features if missing.
172
+ if DAY_PROGRESS not in data.data_vars:
173
+ longitude_coord = data.coords["lon"]
174
+ day_progress = get_day_progress(seconds_since_epoch, longitude_coord.data)
175
+ data.update(
176
+ featurize_progress(
177
+ name=DAY_PROGRESS,
178
+ dims=batch_dim + ("time",) + longitude_coord.dims,
179
+ progress=day_progress,
180
+ )
181
+ )
182
+
183
+
184
+ def add_tisr_var(data: xarray.Dataset) -> None:
185
+ """Adds TISR feature to `data` in place if missing.
186
+
187
+ Args:
188
+ data: Xarray dataset to which TISR feature will be added.
189
+
190
+ Raises:
191
+ ValueError if `datetime`, 'lat', or `lon` are not in `data` coordinates.
192
+ """
193
+
194
+ if TISR in data.data_vars:
195
+ return
196
+
197
+ for coord in ("datetime", "lat", "lon"):
198
+ if coord not in data.coords:
199
+ raise ValueError(f"'{coord}' must be in `data` coordinates.")
200
+
201
+ # Remove `batch` dimension of size one if present. An error will be raised if
202
+ # the `batch` dimension exists and has size greater than one.
203
+ data_no_batch = data.squeeze("batch") if "batch" in data.dims else data
204
+
205
+ tisr = solar_radiation.get_toa_incident_solar_radiation_for_xarray(
206
+ data_no_batch, use_jit=True
207
+ )
208
+
209
+ if "batch" in data.dims:
210
+ tisr = tisr.expand_dims("batch", axis=0)
211
+
212
+ data.update({TISR: tisr})
213
+
214
+
215
+ def extract_input_target_times(
216
+ dataset: xarray.Dataset,
217
+ input_duration: TimedeltaLike,
218
+ target_lead_times: TargetLeadTimes,
219
+ ) -> Tuple[xarray.Dataset, xarray.Dataset]:
220
+ """Extracts inputs and targets for prediction, from a Dataset with a time dim.
221
+
222
+ The input period is assumed to be contiguous (specified by a duration), but
223
+ the targets can be a list of arbitrary lead times.
224
+
225
+ Examples:
226
+
227
+ # Use 18 hours of data as inputs, and two specific lead times as targets:
228
+ # 3 days and 5 days after the final input.
229
+ extract_inputs_targets(
230
+ dataset,
231
+ input_duration='18h',
232
+ target_lead_times=('3d', '5d')
233
+ )
234
+
235
+ # Use 1 day of data as input, and all lead times between 6 hours and
236
+ # 24 hours inclusive as targets. Demonstrates a friendlier supported string
237
+ # syntax.
238
+ extract_inputs_targets(
239
+ dataset,
240
+ input_duration='1 day',
241
+ target_lead_times=slice('6 hours', '24 hours')
242
+ )
243
+
244
+ # Just use a single target lead time of 3 days:
245
+ extract_inputs_targets(
246
+ dataset,
247
+ input_duration='24h',
248
+ target_lead_times='3d'
249
+ )
250
+
251
+ Args:
252
+ dataset: An xarray.Dataset with a 'time' dimension whose coordinates are
253
+ timedeltas. It's assumed that the time coordinates have a fixed offset /
254
+ time resolution, and that the input_duration and target_lead_times are
255
+ multiples of this.
256
+ input_duration: pandas.Timedelta or something convertible to it (e.g. a
257
+ shorthand string like '6h' or '5d12h').
258
+ target_lead_times: Either a single lead time, a slice with start and stop
259
+ (inclusive) lead times, or a sequence of lead times. Lead times should be
260
+ Timedeltas (or something convertible to). They are given relative to the
261
+ final input timestep, and should be positive.
262
+
263
+ Returns:
264
+ inputs:
265
+ targets:
266
+ Two datasets with the same shape as the input dataset except that a
267
+ selection has been made from the time axis, and the origin of the
268
+ time coordinate will be shifted to refer to lead times relative to the
269
+ final input timestep. So for inputs the times will end at lead time 0,
270
+ for targets the time coordinates will refer to the lead times requested.
271
+ """
272
+
273
+ (target_lead_times, target_duration
274
+ ) = _process_target_lead_times_and_get_duration(target_lead_times)
275
+
276
+ # Shift the coordinates for the time axis so that a timedelta of zero
277
+ # corresponds to the forecast reference time. That is, the final timestep
278
+ # that's available as input to the forecast, with all following timesteps
279
+ # forming the target period which needs to be predicted.
280
+ # This means the time coordinates are now forecast lead times.
281
+ time = dataset.coords["time"]
282
+ dataset = dataset.assign_coords(time=time + target_duration - time[-1])
283
+
284
+ # Slice out targets:
285
+ targets = dataset.sel({"time": target_lead_times})
286
+
287
+ input_duration = pd.Timedelta(input_duration)
288
+ # Both endpoints are inclusive with label-based slicing, so we offset by a
289
+ # small epsilon to make one of the endpoints non-inclusive:
290
+ zero = pd.Timedelta(0)
291
+ epsilon = pd.Timedelta(1, "ns")
292
+ inputs = dataset.sel({"time": slice(-input_duration + epsilon, zero)})
293
+ return inputs, targets
294
+
295
+
296
+ def _process_target_lead_times_and_get_duration(
297
+ target_lead_times: TargetLeadTimes) -> TimedeltaLike:
298
+ """Returns the minimum duration for the target lead times."""
299
+ if isinstance(target_lead_times, slice):
300
+ # A slice of lead times. xarray already accepts timedelta-like values for
301
+ # the begin/end/step of the slice.
302
+ if target_lead_times.start is None:
303
+ # If the start isn't specified, we assume it starts at the next timestep
304
+ # after lead time 0 (lead time 0 is the final input timestep):
305
+ target_lead_times = slice(
306
+ pd.Timedelta(1, "ns"), target_lead_times.stop, target_lead_times.step
307
+ )
308
+ target_duration = pd.Timedelta(target_lead_times.stop)
309
+ else:
310
+ if not isinstance(target_lead_times, (list, tuple, set)):
311
+ # A single lead time, which we wrap as a length-1 array to ensure there
312
+ # still remains a time dimension (here of length 1) for consistency.
313
+ target_lead_times = [target_lead_times]
314
+
315
+ # A list of multiple (not necessarily contiguous) lead times:
316
+ target_lead_times = [pd.Timedelta(x) for x in target_lead_times]
317
+ target_lead_times.sort()
318
+ target_duration = target_lead_times[-1]
319
+ return target_lead_times, target_duration
320
+
321
+
322
+ def extract_inputs_targets_forcings(
323
+ dataset: xarray.Dataset,
324
+ *,
325
+ input_variables: Tuple[str, ...],
326
+ target_variables: Tuple[str, ...],
327
+ forcing_variables: Tuple[str, ...],
328
+ pressure_levels: Tuple[int, ...],
329
+ input_duration: TimedeltaLike,
330
+ target_lead_times: TargetLeadTimes,
331
+ ) -> Tuple[xarray.Dataset, xarray.Dataset, xarray.Dataset]:
332
+ """Extracts inputs, targets and forcings according to requirements."""
333
+ dataset = dataset.sel(level=list(pressure_levels))
334
+
335
+ # "Forcings" include derived variables that do not exist in the original ERA5
336
+ # or HRES datasets, as well as other variables (e.g. tisr) that need to be
337
+ # computed manually for the target lead times. Compute the requested ones.
338
+ if set(forcing_variables) & _DERIVED_VARS:
339
+ add_derived_vars(dataset)
340
+ if set(forcing_variables) & {TISR}:
341
+ add_tisr_var(dataset)
342
+
343
+ # `datetime` is needed by add_derived_vars but breaks autoregressive rollouts.
344
+ dataset = dataset.drop_vars("datetime")
345
+
346
+ inputs, targets = extract_input_target_times(
347
+ dataset,
348
+ input_duration=input_duration,
349
+ target_lead_times=target_lead_times)
350
+
351
+ if set(forcing_variables) & set(target_variables):
352
+ raise ValueError(
353
+ f"Forcing variables {forcing_variables} should not "
354
+ f"overlap with target variables {target_variables}."
355
+ )
356
+
357
+ inputs = inputs[list(input_variables)]
358
+ # The forcing uses the same time coordinates as the target.
359
+ forcings = targets[list(forcing_variables)]
360
+ targets = targets[list(target_variables)]
361
+
362
+ return inputs, targets, forcings
model/graphcast/deep_typed_graph_net.py ADDED
@@ -0,0 +1,461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2023 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """JAX implementation of Graph Networks Simulator.
15
+
16
+ Generalization to TypedGraphs of the deep Graph Neural Network from:
17
+
18
+ @inproceedings{pfaff2021learning,
19
+ title={Learning Mesh-Based Simulation with Graph Networks},
20
+ author={Pfaff, Tobias and Fortunato, Meire and Sanchez-Gonzalez, Alvaro and
21
+ Battaglia, Peter},
22
+ booktitle={International Conference on Learning Representations},
23
+ year={2021}
24
+ }
25
+
26
+ @inproceedings{sanchez2020learning,
27
+ title={Learning to simulate complex physics with graph networks},
28
+ author={Sanchez-Gonzalez, Alvaro and Godwin, Jonathan and Pfaff, Tobias and
29
+ Ying, Rex and Leskovec, Jure and Battaglia, Peter},
30
+ booktitle={International conference on machine learning},
31
+ pages={8459--8468},
32
+ year={2020},
33
+ organization={PMLR}
34
+ }
35
+ """
36
+
37
+ import functools
38
+ from typing import Callable, List, Mapping, Optional, Tuple
39
+
40
+ import chex
41
+ from . import mlp as mlp_builder
42
+ from . import typed_graph
43
+ from . import typed_graph_net
44
+ import haiku as hk
45
+ import jax
46
+ import jax.numpy as jnp
47
+ import jraph
48
+
49
+
50
+ GraphToGraphNetwork = Callable[[typed_graph.TypedGraph], typed_graph.TypedGraph]
51
+
52
+
53
+ class DeepTypedGraphNet(hk.Module):
54
+ """Deep Graph Neural Network.
55
+
56
+ It works with TypedGraphs with typed nodes and edges. It runs message
57
+ passing on all of the node sets and all of the edge sets in the graph. For
58
+ each message passing step a `typed_graph_net.InteractionNetwork` is used to
59
+ update the full TypedGraph by using different MLPs for each of the node sets
60
+ and each of the edge sets.
61
+
62
+ If embed_{nodes,edges} is specified the node/edge features will be embedded
63
+ into a fixed dimensionality before running the first step of message passing.
64
+
65
+ If {node,edge}_output_size the final node/edge features will be embedded into
66
+ the specified output size.
67
+
68
+ This class may be used for shared or unshared message passing:
69
+ * num_message_passing_steps = N, num_processor_repetitions = 1, gives
70
+ N layers of message passing with fully unshared weights:
71
+ [W_1, W_2, ... , W_M] (default)
72
+ * num_message_passing_steps = 1, num_processor_repetitions = M, gives
73
+ N layers of message passing with fully shared weights:
74
+ [W_1] * M
75
+ * num_message_passing_steps = N, num_processor_repetitions = M, gives
76
+ M*N layers of message passing with both shared and unshared message passing
77
+ such that the weights used at each iteration are:
78
+ [W_1, W_2, ... , W_N] * M
79
+
80
+ """
81
+
82
+ def __init__(self,
83
+ *,
84
+ node_latent_size: Mapping[str, int],
85
+ edge_latent_size: Mapping[str, int],
86
+ mlp_hidden_size: int,
87
+ mlp_num_hidden_layers: int,
88
+ num_message_passing_steps: int,
89
+ num_processor_repetitions: int = 1,
90
+ embed_nodes: bool = True,
91
+ embed_edges: bool = True,
92
+ node_output_size: Optional[Mapping[str, int]] = None,
93
+ edge_output_size: Optional[Mapping[str, int]] = None,
94
+ include_sent_messages_in_node_update: bool = False,
95
+ use_layer_norm: bool = True,
96
+ use_norm_conditioning: bool = False,
97
+ activation: str = "relu",
98
+ f32_aggregation: bool = False,
99
+ aggregate_edges_for_nodes_fn: str = "segment_sum",
100
+ aggregate_normalization: Optional[float] = None,
101
+ name: str = "DeepTypedGraphNet"):
102
+ """Inits the model.
103
+
104
+ Args:
105
+ node_latent_size: Size of the node latent representations.
106
+ edge_latent_size: Size of the edge latent representations.
107
+ mlp_hidden_size: Hidden layer size for all MLPs.
108
+ mlp_num_hidden_layers: Number of hidden layers in all MLPs.
109
+ num_message_passing_steps: Number of unshared message passing steps
110
+ in the processor steps.
111
+ num_processor_repetitions: Number of times that the same processor is
112
+ applied sequencially.
113
+ embed_nodes: If False, the node embedder will be omitted.
114
+ embed_edges: If False, the edge embedder will be omitted.
115
+ node_output_size: Size of the output node representations for
116
+ each node type. For node types not specified here, the latent node
117
+ representation from the output of the processor will be returned.
118
+ edge_output_size: Size of the output edge representations for
119
+ each edge type. For edge types not specified here, the latent edge
120
+ representation from the output of the processor will be returned.
121
+ include_sent_messages_in_node_update: Whether to include pooled sent
122
+ messages from each node in the node update.
123
+ use_layer_norm: Whether it uses layer norm or not.
124
+ use_norm_conditioning: If True, the latent feaures outputted by the
125
+ activation normalization that follows the MLPs (e.g. LayerNorm), rather
126
+ than being scaled/offset by learned parameters of the normalization
127
+ module, will be scaled/offset by offsets/biases produced by a linear
128
+ layer (with different weights for each MLP), which takes an extra
129
+ argument "global_norm_conditioning". This argument is used to condition
130
+ the normalization of all nodes and all edges (hence global), and would
131
+ usually only have a batch and feature axis. This is typically used to
132
+ condition diffusion models on the "diffusion time". Will raise an error
133
+ if this is set to True but the "global_norm_conditioning" is not passed
134
+ to the __call__ method, as well as if this is set to False, but
135
+ "global_norm_conditioning" is passed to the call method.
136
+ activation: name of activation function.
137
+ f32_aggregation: Use float32 in the edge aggregation.
138
+ aggregate_edges_for_nodes_fn: function used to aggregate messages to each
139
+ node.
140
+ aggregate_normalization: An optional constant that normalizes the output
141
+ of aggregate_edges_for_nodes_fn. For context, this can be used to
142
+ reduce the shock the model undergoes when switching resolution, which
143
+ increase the number of edges connected to a node. In particular, this is
144
+ useful when using segment_sum, but should not be combined with
145
+ segment_mean.
146
+ name: Name of the model.
147
+ """
148
+
149
+ super().__init__(name=name)
150
+
151
+ self._node_latent_size = node_latent_size
152
+ self._edge_latent_size = edge_latent_size
153
+ self._mlp_hidden_size = mlp_hidden_size
154
+ self._mlp_num_hidden_layers = mlp_num_hidden_layers
155
+ self._num_message_passing_steps = num_message_passing_steps
156
+ self._num_processor_repetitions = num_processor_repetitions
157
+ self._embed_nodes = embed_nodes
158
+ self._embed_edges = embed_edges
159
+ self._node_output_size = node_output_size
160
+ self._edge_output_size = edge_output_size
161
+ self._include_sent_messages_in_node_update = (
162
+ include_sent_messages_in_node_update)
163
+ if use_norm_conditioning and not use_layer_norm:
164
+ raise ValueError(
165
+ "`norm_conditioning` can only be used when "
166
+ "`use_layer_norm` is true."
167
+ )
168
+ self._use_layer_norm = use_layer_norm
169
+ self._use_norm_conditioning = use_norm_conditioning
170
+ self._activation = _get_activation_fn(activation)
171
+ self._f32_aggregation = f32_aggregation
172
+ self._aggregate_edges_for_nodes_fn = _get_aggregate_edges_for_nodes_fn(
173
+ aggregate_edges_for_nodes_fn)
174
+ self._aggregate_normalization = aggregate_normalization
175
+
176
+ if aggregate_normalization:
177
+ # using aggregate_normalization only makes sense with segment_sum.
178
+ assert aggregate_edges_for_nodes_fn == "segment_sum"
179
+
180
+ def __call__(self,
181
+ input_graph: typed_graph.TypedGraph,
182
+ global_norm_conditioning: Optional[chex.Array] = None
183
+ ) -> typed_graph.TypedGraph:
184
+ """Forward pass of the learnable dynamics model."""
185
+ embedder_network, processor_networks, decoder_network = (
186
+ self._networks_builder(input_graph, global_norm_conditioning)
187
+ )
188
+
189
+ # Embed input features (if applicable).
190
+ latent_graph_0 = self._embed(input_graph, embedder_network)
191
+
192
+ # Do `m` message passing steps in the latent graphs.
193
+ latent_graph_m = self._process(latent_graph_0, processor_networks)
194
+
195
+ # Compute outputs from the last latent graph (if applicable).
196
+ return self._output(latent_graph_m, decoder_network)
197
+
198
+ def _networks_builder(
199
+ self,
200
+ graph_template: typed_graph.TypedGraph,
201
+ global_norm_conditioning: Optional[chex.Array] = None,
202
+ ) -> Tuple[
203
+ GraphToGraphNetwork, List[GraphToGraphNetwork], GraphToGraphNetwork
204
+ ]:
205
+ # TODO(aelkadi): move to mlp_builder.
206
+ def build_mlp(name, output_size):
207
+ mlp = hk.nets.MLP(
208
+ output_sizes=[self._mlp_hidden_size] * self._mlp_num_hidden_layers + [
209
+ output_size], name=name + "_mlp", activation=self._activation)
210
+ return jraph.concatenated_args(mlp)
211
+
212
+ def build_mlp_with_maybe_layer_norm(name, output_size):
213
+ network = build_mlp(name, output_size)
214
+ stages = [network]
215
+ if self._use_norm_conditioning:
216
+ if global_norm_conditioning is None:
217
+ raise ValueError(
218
+ "When using norm conditioning, `global_norm_conditioning` must"
219
+ "be passed to the call method.")
220
+ # If using norm conditioning, it is no longer the responsibility of the
221
+ # LayerNorm module itself to learn its scale and offset. These will be
222
+ # learned for the module by the norm conditioning layer instead.
223
+ create_scale = create_offset = False
224
+ else:
225
+ if global_norm_conditioning is not None:
226
+ raise ValueError(
227
+ "`globa_norm_conditioning` was passed, but `norm_conditioning`"
228
+ " is not enabled.")
229
+ create_scale = create_offset = True
230
+
231
+ if self._use_layer_norm:
232
+ layer_norm = hk.LayerNorm(
233
+ axis=-1, create_scale=create_scale, create_offset=create_offset,
234
+ name=name + "_layer_norm")
235
+ stages.append(layer_norm)
236
+
237
+ if self._use_norm_conditioning:
238
+ norm_conditioning_layer = mlp_builder.LinearNormConditioning(
239
+ name=name + "_norm_conditioning")
240
+ norm_conditioning_layer = functools.partial(
241
+ norm_conditioning_layer,
242
+ # Broadcast to the node/edge axis.
243
+ norm_conditioning=global_norm_conditioning[None],
244
+ )
245
+ stages.append(norm_conditioning_layer)
246
+
247
+ network = hk.Sequential(stages)
248
+ return jraph.concatenated_args(network)
249
+
250
+ # The embedder graph network independently embeds edge and node features.
251
+ if self._embed_edges:
252
+ embed_edge_fn = _build_update_fns_for_edge_types(
253
+ build_mlp_with_maybe_layer_norm,
254
+ graph_template,
255
+ "encoder_edges_",
256
+ output_sizes=self._edge_latent_size)
257
+ else:
258
+ embed_edge_fn = None
259
+ if self._embed_nodes:
260
+ embed_node_fn = _build_update_fns_for_node_types(
261
+ build_mlp_with_maybe_layer_norm,
262
+ graph_template,
263
+ "encoder_nodes_",
264
+ output_sizes=self._node_latent_size)
265
+ else:
266
+ embed_node_fn = None
267
+ embedder_kwargs = dict(
268
+ embed_edge_fn=embed_edge_fn,
269
+ embed_node_fn=embed_node_fn,
270
+ )
271
+ embedder_network = typed_graph_net.GraphMapFeatures(
272
+ **embedder_kwargs)
273
+
274
+ if self._f32_aggregation:
275
+ def aggregate_fn(data, *args, **kwargs):
276
+ dtype = data.dtype
277
+ data = data.astype(jnp.float32)
278
+ output = self._aggregate_edges_for_nodes_fn(data, *args, **kwargs)
279
+ if self._aggregate_normalization:
280
+ output = output / self._aggregate_normalization
281
+ output = output.astype(dtype)
282
+ return output
283
+
284
+ else:
285
+ def aggregate_fn(data, *args, **kwargs):
286
+ output = self._aggregate_edges_for_nodes_fn(data, *args, **kwargs)
287
+ if self._aggregate_normalization:
288
+ output = output / self._aggregate_normalization
289
+ return output
290
+
291
+ # Create `num_message_passing_steps` graph networks with unshared parameters
292
+ # that update the node and edge latent features.
293
+ # Note that we can use `modules.InteractionNetwork` because
294
+ # it also outputs the messages as updated edge latent features.
295
+ processor_networks = []
296
+ for step_i in range(self._num_message_passing_steps):
297
+ processor_networks.append(
298
+ typed_graph_net.InteractionNetwork(
299
+ update_edge_fn=_build_update_fns_for_edge_types(
300
+ build_mlp_with_maybe_layer_norm,
301
+ graph_template,
302
+ f"processor_edges_{step_i}_",
303
+ output_sizes=self._edge_latent_size),
304
+ update_node_fn=_build_update_fns_for_node_types(
305
+ build_mlp_with_maybe_layer_norm,
306
+ graph_template,
307
+ f"processor_nodes_{step_i}_",
308
+ output_sizes=self._node_latent_size),
309
+ aggregate_edges_for_nodes_fn=aggregate_fn,
310
+ include_sent_messages_in_node_update=(
311
+ self._include_sent_messages_in_node_update),
312
+ ))
313
+
314
+ # The output MLPs converts edge/node latent features into the output sizes.
315
+ output_kwargs = dict(
316
+ embed_edge_fn=_build_update_fns_for_edge_types(
317
+ build_mlp, graph_template, "decoder_edges_", self._edge_output_size)
318
+ if self._edge_output_size else None,
319
+ embed_node_fn=_build_update_fns_for_node_types(
320
+ build_mlp, graph_template, "decoder_nodes_", self._node_output_size)
321
+ if self._node_output_size else None,)
322
+ output_network = typed_graph_net.GraphMapFeatures(
323
+ **output_kwargs)
324
+ return embedder_network, processor_networks, output_network
325
+
326
+ def _embed(
327
+ self,
328
+ input_graph: typed_graph.TypedGraph,
329
+ embedder_network: GraphToGraphNetwork,
330
+ ) -> typed_graph.TypedGraph:
331
+ """Embeds the input graph features into a latent graph."""
332
+
333
+ # Copy the context to all of the node types, if applicable.
334
+ context_features = input_graph.context.features
335
+ if jax.tree_util.tree_leaves(context_features):
336
+ # This code assumes a single input feature array for the context and for
337
+ # each node type.
338
+ assert len(jax.tree_util.tree_leaves(context_features)) == 1
339
+ new_nodes = {}
340
+ for node_set_name, node_set in input_graph.nodes.items():
341
+ node_features = node_set.features
342
+ broadcasted_context = jnp.repeat(
343
+ context_features, node_set.n_node, axis=0,
344
+ total_repeat_length=node_features.shape[0])
345
+ new_nodes[node_set_name] = node_set._replace(
346
+ features=jnp.concatenate(
347
+ [node_features, broadcasted_context], axis=-1))
348
+ input_graph = input_graph._replace(
349
+ nodes=new_nodes,
350
+ context=input_graph.context._replace(features=()))
351
+
352
+ # Embeds the node and edge features.
353
+ latent_graph_0 = embedder_network(input_graph)
354
+ return latent_graph_0
355
+
356
+ def _process(
357
+ self,
358
+ latent_graph_0: typed_graph.TypedGraph,
359
+ processor_networks: List[GraphToGraphNetwork],
360
+ ) -> typed_graph.TypedGraph:
361
+ """Processes the latent graph with several steps of message passing."""
362
+
363
+ # Do `num_message_passing_steps` with each of the `self._processor_networks`
364
+ # with unshared weights, and repeat that `self._num_processor_repetitions`
365
+ # times.
366
+ latent_graph = latent_graph_0
367
+ for unused_repetition_i in range(self._num_processor_repetitions):
368
+ for processor_network in processor_networks:
369
+ latent_graph = self._process_step(processor_network, latent_graph)
370
+
371
+ return latent_graph
372
+
373
+ def _process_step(
374
+ self, processor_network_k,
375
+ latent_graph_prev_k: typed_graph.TypedGraph) -> typed_graph.TypedGraph:
376
+ """Single step of message passing with node/edge residual connections."""
377
+
378
+ # One step of message passing.
379
+ latent_graph_k = processor_network_k(latent_graph_prev_k)
380
+
381
+ # Add residuals.
382
+ nodes_with_residuals = {}
383
+ for k, prev_set in latent_graph_prev_k.nodes.items():
384
+ nodes_with_residuals[k] = prev_set._replace(
385
+ features=prev_set.features + latent_graph_k.nodes[k].features)
386
+
387
+ edges_with_residuals = {}
388
+ for k, prev_set in latent_graph_prev_k.edges.items():
389
+ edges_with_residuals[k] = prev_set._replace(
390
+ features=prev_set.features + latent_graph_k.edges[k].features)
391
+
392
+ latent_graph_k = latent_graph_k._replace(
393
+ nodes=nodes_with_residuals, edges=edges_with_residuals)
394
+ return latent_graph_k
395
+
396
+ def _output(
397
+ self,
398
+ latent_graph: typed_graph.TypedGraph,
399
+ output_network: GraphToGraphNetwork,
400
+ ) -> typed_graph.TypedGraph:
401
+ """Produces the output from the latent graph."""
402
+ return output_network(latent_graph)
403
+
404
+
405
+ def _build_update_fns_for_node_types(
406
+ builder_fn, graph_template, prefix, output_sizes=None):
407
+ """Builds an update function for all node types or a subset of them."""
408
+
409
+ output_fns = {}
410
+ for node_set_name in graph_template.nodes.keys():
411
+ if output_sizes is None:
412
+ # Use the default output size for all types.
413
+ output_size = None
414
+ else:
415
+ # Otherwise, ignore any type that does not have an explicit output size.
416
+ if node_set_name in output_sizes:
417
+ output_size = output_sizes[node_set_name]
418
+ else:
419
+ continue
420
+ output_fns[node_set_name] = builder_fn(
421
+ f"{prefix}{node_set_name}", output_size)
422
+ return output_fns
423
+
424
+
425
+ def _build_update_fns_for_edge_types(
426
+ builder_fn, graph_template, prefix, output_sizes=None):
427
+ """Builds an edge function for all node types or a subset of them."""
428
+ output_fns = {}
429
+ for edge_set_key in graph_template.edges.keys():
430
+ edge_set_name = edge_set_key.name
431
+ if output_sizes is None:
432
+ # Use the default output size for all types.
433
+ output_size = None
434
+ else:
435
+ # Otherwise, ignore any type that does not have an explicit output size.
436
+ if edge_set_name in output_sizes:
437
+ output_size = output_sizes[edge_set_name]
438
+ else:
439
+ continue
440
+ output_fns[edge_set_name] = builder_fn(
441
+ f"{prefix}{edge_set_name}", output_size)
442
+ return output_fns
443
+
444
+
445
+ def _get_activation_fn(name):
446
+ """Return activation function corresponding to function_name."""
447
+ if name == "identity":
448
+ return lambda x: x
449
+ if hasattr(jax.nn, name):
450
+ return getattr(jax.nn, name)
451
+ if hasattr(jnp, name):
452
+ return getattr(jnp, name)
453
+ raise ValueError(f"Unknown activation function {name} specified.")
454
+
455
+
456
+ def _get_aggregate_edges_for_nodes_fn(name):
457
+ """Return aggregate_edges_for_nodes_fn corresponding to function_name."""
458
+ if hasattr(jraph, name):
459
+ return getattr(jraph, name)
460
+ raise ValueError(
461
+ f"Unknown aggregate_edges_for_nodes_fn function {name} specified.")
model/graphcast/denoiser.py ADDED
@@ -0,0 +1,852 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2024 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Support for wrapping a general Predictor to act as a Denoiser."""
15
+
16
+ import dataclasses
17
+ from typing import Any, Callable, Mapping, Optional, Sequence, Tuple
18
+
19
+ import chex
20
+ from . import deep_typed_graph_net
21
+ from . import denoisers_base as base
22
+ from . import grid_mesh_connectivity
23
+ from . import icosahedral_mesh
24
+ from . import model_utils
25
+ from . import sparse_transformer
26
+ from . import transformer
27
+ from . import typed_graph
28
+ from . import xarray_jax
29
+ import haiku as hk
30
+ import jax
31
+ import jax.numpy as jnp
32
+ import numpy as np
33
+ from scipy import sparse
34
+ import xarray
35
+
36
+
37
+ Kwargs = Mapping[str, Any]
38
+ NoiseLevelEncoder = Callable[[jnp.ndarray], jnp.ndarray]
39
+
40
+
41
+ class FourierFeaturesMLP(hk.Module):
42
+ """A simple MLP applied to Fourier features of values or their logarithms."""
43
+
44
+ def __init__(
45
+ self,
46
+ base_period: float,
47
+ num_frequencies: int,
48
+ output_sizes: Sequence[int],
49
+ apply_log_first: bool = False,
50
+ w_init=None,
51
+ activation=jax.nn.gelu,
52
+ **mlp_kwargs
53
+ ):
54
+ """Initializes the module.
55
+
56
+ Args:
57
+ base_period:
58
+ See model_utils.fourier_features. Note this would apply to log inputs if
59
+ apply_log_first is used.
60
+ num_frequencies:
61
+ See model_utils.fourier_features.
62
+ output_sizes:
63
+ Layer sizes for the MLP.
64
+ apply_log_first:
65
+ Whether to take the log of the inputs before computing Fourier features.
66
+ w_init:
67
+ Weights initializer for the MLP, default setting aims to produce
68
+ approx unit-variance outputs given the input sin/cos features.
69
+ activation:
70
+ **mlp_kwargs:
71
+ Further settings for the MLP.
72
+ """
73
+ super().__init__()
74
+ self._base_period = base_period
75
+ self._num_frequencies = num_frequencies
76
+ self._apply_log_first = apply_log_first
77
+ if w_init is None:
78
+ # Scale of 2 is appropriate for input layer as sin/cos fourier features
79
+ # have variance 0.5 for random inputs. Also reasonable to use for later
80
+ # layers as relu activation cuts variance in half for inputs to later
81
+ # layers and gelu something close enough too.
82
+ w_init = hk.initializers.VarianceScaling(
83
+ 2.0, mode="fan_in", distribution="uniform"
84
+ )
85
+ self._mlp = hk.nets.MLP(
86
+ output_sizes=output_sizes,
87
+ w_init=w_init,
88
+ activation=activation,
89
+ **mlp_kwargs)
90
+
91
+ def __call__(self, values: jnp.ndarray) -> jnp.ndarray:
92
+ if self._apply_log_first:
93
+ values = jnp.log(values)
94
+
95
+ features = model_utils.fourier_features(
96
+ values, self._base_period, self._num_frequencies)
97
+
98
+ return self._mlp(features)
99
+
100
+
101
+ @chex.dataclass(frozen=True, eq=True)
102
+ class NoiseEncoderConfig:
103
+ """Configures the noise level encoding.
104
+
105
+ Properties:
106
+ apply_log_first: Whether to take the log of the inputs before computing
107
+ Fourier features.
108
+ base_period: The base period to use. This should be greater or equal to the
109
+ range of the values, or to the period if the values have periodic
110
+ semantics (e.g. 2pi if they represent angles). Frequencies used will be
111
+ integer multiples of 1/base_period.
112
+ num_frequencies: The number of frequencies to use, we will use integer
113
+ multiples of 1/base_period from 1 up to num_frequencies inclusive. (We
114
+ don't include a zero frequency as this would just give constant features
115
+ which are redundant if a bias term is present).
116
+ output_sizes: Layer sizes for the MLP.
117
+ """
118
+ apply_log_first: bool = True
119
+ base_period: float = 16.0
120
+ num_frequencies: int = 32
121
+ # 2-layer MLP applied to Fourier features
122
+ output_sizes: tuple[int, int] = (32, 16)
123
+
124
+
125
+ @chex.dataclass(eq=True)
126
+ class SparseTransformerConfig:
127
+ """Sparse Transformer config."""
128
+ # Neighbours to attend to.
129
+ attention_k_hop: int
130
+ # Primary width, the number of channels on the carrier path.
131
+ d_model: int
132
+ # Depth, or num transformer blocks. One 'layer' is attn + ffw.
133
+ num_layers: int = 16
134
+ # Number of heads for self-attention.
135
+ num_heads: int = 4
136
+ # Attention type.
137
+ attention_type: str = "splash_mha"
138
+ # mask type if splash attention being used.
139
+ mask_type: str = "lazy"
140
+ block_q: int = 1024
141
+ block_kv: int = 512
142
+ block_kv_compute: int = 256
143
+ block_q_dkv: int = 512
144
+ block_kv_dkv: int = 1024
145
+ block_kv_dkv_compute: int = 1024
146
+ # Init scale for final ffw layer (divided by depth)
147
+ ffw_winit_final_mult: float = 0.0
148
+ # Init scale for mha w (divided by depth).
149
+ attn_winit_final_mult: float = 0.0
150
+ # Number of hidden units in the MLP blocks. Defaults to 4 * d_model.
151
+ ffw_hidden: int = 2048
152
+ # Name for haiku module.
153
+ name: Optional[str] = None
154
+
155
+
156
+ @chex.dataclass(eq=True)
157
+ class DenoiserArchitectureConfig:
158
+ """Defines the GenCast architecture.
159
+
160
+ Properties:
161
+ sparse_transformer_config: Config for the mesh transformer.
162
+ mesh_size: How many refinements to do on the multi-mesh.
163
+ latent_size: How many latent features to include in the various MLPs.
164
+ hidden_layers: How many hidden layers for each MLP.
165
+ radius_query_fraction_edge_length: Scalar that will be multiplied by the
166
+ length of the longest edge of the finest mesh to define the radius of
167
+ connectivity to use in the Grid2Mesh graph. Reasonable values are
168
+ between 0.6 and 1. 0.6 reduces the number of grid points feeding into
169
+ multiple mesh nodes and therefore reduces edge count and memory use, but
170
+ 1 gives better predictions.
171
+ norm_conditioning_features: List of feature names which will be used to
172
+ condition the GNN via norm_conditioning, rather than as regular
173
+ features. If this is provided, the GNN has to support the
174
+ `global_norm_conditioning` argument. For now it only supports global
175
+ norm conditioning (e.g. the same vector conditions all edges and nodes
176
+ normalization), which means features passed here must not have "lat" or
177
+ "lon" axes. In the future it may support node level norm conditioning
178
+ too.
179
+ grid2mesh_aggregate_normalization: Optional constant to normalize the output
180
+ of aggregate_edges_for_nodes_fn in the mesh2grid GNN. This can be used to
181
+ reduce the shock the model undergoes when switching resolution, which
182
+ increases the number of edges connected to a node.
183
+ node_output_size: Size of the output node representations for
184
+ each node type. For node types not specified here, the latent node
185
+ representation from the output of the processor will be returned.
186
+ """
187
+
188
+ sparse_transformer_config: SparseTransformerConfig
189
+ mesh_size: int
190
+ latent_size: int = 512
191
+ hidden_layers: int = 1
192
+ radius_query_fraction_edge_length: float = 0.6
193
+ norm_conditioning_features: tuple[str, ...] = ("noise_level_encodings",)
194
+ grid2mesh_aggregate_normalization: Optional[float] = None
195
+ node_output_size: Optional[int] = None
196
+
197
+
198
+ class Denoiser(base.Denoiser):
199
+ """Wraps a general deterministic Predictor to act as a Denoiser.
200
+
201
+ This passes an encoding of the noise level as an additional input to the
202
+ Predictor as an additional input 'noise_level_encodings' with shape
203
+ ('batch', 'noise_level_encoding_channels'). It passes the noisy_targets as
204
+ additional forcings (since they are also per-target-timestep data that the
205
+ predictor needs to condition on) with the same names as the original target
206
+ variables.
207
+ """
208
+
209
+ def __init__(
210
+ self,
211
+ noise_encoder_config: Optional[NoiseEncoderConfig],
212
+ denoiser_architecture_config: DenoiserArchitectureConfig,
213
+ ):
214
+ self._predictor = _DenoiserArchitecture(
215
+ denoiser_architecture_config=denoiser_architecture_config,
216
+ )
217
+ # Use default values if not specified.
218
+ if noise_encoder_config is None:
219
+ noise_encoder_config = NoiseEncoderConfig()
220
+ self._noise_level_encoder = FourierFeaturesMLP(**noise_encoder_config)
221
+
222
+ def __call__(
223
+ self,
224
+ inputs: xarray.Dataset,
225
+ noisy_targets: xarray.Dataset,
226
+ noise_levels: xarray.DataArray,
227
+ forcings: Optional[xarray.Dataset] = None,
228
+ **kwargs) -> xarray.Dataset:
229
+ if forcings is None: forcings = xarray.Dataset()
230
+ forcings = forcings.assign(noisy_targets)
231
+
232
+ if noise_levels.dims != ("batch",):
233
+ raise ValueError("noise_levels expected to be shape (batch,).")
234
+ noise_level_encodings = self._noise_level_encoder(
235
+ xarray_jax.unwrap_data(noise_levels)
236
+ )
237
+ noise_level_encodings = xarray_jax.Variable(
238
+ ("batch", "noise_level_encoding_channels"), noise_level_encodings
239
+ )
240
+ inputs = inputs.assign(noise_level_encodings=noise_level_encodings)
241
+
242
+ return self._predictor(
243
+ inputs=inputs,
244
+ targets_template=noisy_targets,
245
+ forcings=forcings,
246
+ **kwargs)
247
+
248
+
249
+ class _DenoiserArchitecture:
250
+ """GenCast Predictor.
251
+
252
+ The model works on graphs that take into account:
253
+ * Mesh nodes: nodes for the vertices of the mesh.
254
+ * Grid nodes: nodes for the points of the grid.
255
+ * Nodes: When referring to just "nodes", this means the joint set of
256
+ both mesh nodes, concatenated with grid nodes.
257
+
258
+ The model works with 3 graphs:
259
+ * Grid2Mesh graph: Graph that contains all nodes. This graph is strictly
260
+ bipartite with edges going from grid nodes to mesh nodes using a
261
+ fixed radius query. The grid2mesh_gnn will operate in this graph. The output
262
+ of this stage will be a latent representation for the mesh nodes, and a
263
+ latent representation for the grid nodes.
264
+ * Mesh graph: Graph that contains mesh nodes only. The mesh_gnn will
265
+ operate in this graph. It will update the latent state of the mesh nodes
266
+ only.
267
+ * Mesh2Grid graph: Graph that contains all nodes. This graph is strictly
268
+ bipartite with edges going from mesh nodes to grid nodes such that each grid
269
+ node is connected to 3 nodes of the mesh triangular face that contains
270
+ the grid points. The mesh2grid_gnn will operate in this graph. It will
271
+ process the updated latent state of the mesh nodes, and the latent state
272
+ of the grid nodes, to produce the final output for the grid nodes.
273
+
274
+ The model is built on top of `TypedGraph`s so the different types of nodes and
275
+ edges can be stored and treated separately.
276
+ """
277
+
278
+ def __init__(
279
+ self,
280
+ denoiser_architecture_config: DenoiserArchitectureConfig,
281
+ ):
282
+ """Initializes the predictor."""
283
+ self._spatial_features_kwargs = dict(
284
+ add_node_positions=False,
285
+ add_node_latitude=True,
286
+ add_node_longitude=True,
287
+ add_relative_positions=True,
288
+ relative_longitude_local_coordinates=True,
289
+ relative_latitude_local_coordinates=True,
290
+ )
291
+
292
+ # Construct the mesh.
293
+ mesh = icosahedral_mesh.get_last_triangular_mesh_for_sphere(
294
+ splits=denoiser_architecture_config.mesh_size
295
+ )
296
+ # Permute the mesh to a banded structure so we can run sparse attention
297
+ # operations.
298
+ self._mesh = _permute_mesh_to_banded(mesh=mesh)
299
+
300
+ # Encoder, which moves data from the grid to the mesh with a single message
301
+ # passing step.
302
+ self._grid2mesh_gnn = (
303
+ deep_typed_graph_net.DeepTypedGraphNet(
304
+ activation="swish",
305
+ aggregate_normalization=(
306
+ denoiser_architecture_config.grid2mesh_aggregate_normalization
307
+ ),
308
+ edge_latent_size=dict(
309
+ grid2mesh=denoiser_architecture_config.latent_size
310
+ ),
311
+ embed_edges=True,
312
+ embed_nodes=True,
313
+ f32_aggregation=True,
314
+ include_sent_messages_in_node_update=False,
315
+ mlp_hidden_size=denoiser_architecture_config.latent_size,
316
+ mlp_num_hidden_layers=denoiser_architecture_config.hidden_layers,
317
+ name="grid2mesh_gnn",
318
+ node_latent_size=dict(
319
+ grid_nodes=denoiser_architecture_config.latent_size,
320
+ mesh_nodes=denoiser_architecture_config.latent_size
321
+ ),
322
+ node_output_size=None,
323
+ num_message_passing_steps=1,
324
+ use_layer_norm=True,
325
+ use_norm_conditioning=True,
326
+ )
327
+ )
328
+
329
+ # Processor - performs multiple rounds of message passing on the mesh.
330
+ self._mesh_gnn = transformer.MeshTransformer(
331
+ name="mesh_transformer",
332
+ transformer_ctor=sparse_transformer.Transformer,
333
+ transformer_kwargs=dataclasses.asdict(
334
+ denoiser_architecture_config.sparse_transformer_config
335
+ ),
336
+ )
337
+
338
+ # Decoder, which moves data from the mesh back into the grid with a single
339
+ # message passing step.
340
+ self._mesh2grid_gnn = (
341
+ deep_typed_graph_net.DeepTypedGraphNet(
342
+ activation="swish",
343
+ edge_latent_size=dict(
344
+ mesh2grid=denoiser_architecture_config.latent_size
345
+ ),
346
+ embed_nodes=False,
347
+ f32_aggregation=False,
348
+ include_sent_messages_in_node_update=False,
349
+ mlp_hidden_size=denoiser_architecture_config.latent_size,
350
+ mlp_num_hidden_layers=denoiser_architecture_config.hidden_layers,
351
+ name="mesh2grid_gnn",
352
+ node_latent_size=dict(
353
+ grid_nodes=denoiser_architecture_config.latent_size,
354
+ mesh_nodes=denoiser_architecture_config.latent_size,
355
+ ),
356
+ node_output_size={
357
+ "grid_nodes": denoiser_architecture_config.node_output_size
358
+ },
359
+ num_message_passing_steps=1,
360
+ use_layer_norm=True,
361
+ use_norm_conditioning=True,
362
+ )
363
+ )
364
+
365
+ self._norm_conditioning_features = (
366
+ denoiser_architecture_config.norm_conditioning_features
367
+ )
368
+ # Obtain the query radius in absolute units for the unit-sphere for the
369
+ # grid2mesh model, by rescaling the `radius_query_fraction_edge_length`.
370
+ self._query_radius = (
371
+ _get_max_edge_distance(self._mesh)
372
+ * denoiser_architecture_config.radius_query_fraction_edge_length
373
+ )
374
+
375
+ # Other initialization is delayed until the first call (`_maybe_init`)
376
+ # when we get some sample data so we know the lat/lon values.
377
+ self._initialized = False
378
+
379
+ # A "_init_mesh_properties":
380
+ # This one could be initialized at init but we delay it for consistency too.
381
+ self._num_mesh_nodes = None # num_mesh_nodes
382
+ self._mesh_nodes_lat = None # [num_mesh_nodes]
383
+ self._mesh_nodes_lon = None # [num_mesh_nodes]
384
+
385
+ # A "_init_grid_properties":
386
+ self._grid_lat = None # [num_lat_points]
387
+ self._grid_lon = None # [num_lon_points]
388
+ self._num_grid_nodes = None # num_lat_points * num_lon_points
389
+ self._grid_nodes_lat = None # [num_grid_nodes]
390
+ self._grid_nodes_lon = None # [num_grid_nodes]
391
+
392
+ # A "_init_{grid2mesh,processor,mesh2grid}_graph"
393
+ self._grid2mesh_graph_structure = None
394
+ self._mesh_graph_structure = None
395
+ self._mesh2grid_graph_structure = None
396
+
397
+ def __call__(self,
398
+ inputs: xarray.Dataset,
399
+ targets_template: xarray.Dataset,
400
+ forcings: xarray.Dataset,
401
+ ) -> xarray.Dataset:
402
+ self._maybe_init(inputs)
403
+
404
+ # Convert all input data into flat vectors for each of the grid nodes.
405
+ # xarray (batch, time, lat, lon, level, multiple vars, forcings)
406
+ # -> [num_grid_nodes, batch, num_channels]
407
+ grid_node_features, global_norm_conditioning = (
408
+ self._inputs_to_grid_node_features_and_norm_conditioning(
409
+ inputs, forcings
410
+ )
411
+ )
412
+
413
+ # [num_mesh_nodes, batch, latent_size], [num_grid_nodes, batch, latent_size]
414
+ (latent_mesh_nodes, latent_grid_nodes) = self._run_grid2mesh_gnn(
415
+ grid_node_features, global_norm_conditioning
416
+ )
417
+
418
+ # Run message passing in the multimesh.
419
+ # [num_mesh_nodes, batch, latent_size]
420
+ updated_latent_mesh_nodes = self._run_mesh_gnn(
421
+ latent_mesh_nodes, global_norm_conditioning
422
+ )
423
+
424
+ # Transfer data from the mesh to the grid.
425
+ # [num_grid_nodes, batch, output_size]
426
+ output_grid_nodes = self._run_mesh2grid_gnn(
427
+ updated_latent_mesh_nodes, latent_grid_nodes, global_norm_conditioning
428
+ )
429
+
430
+ # Convert output flat vectors for the grid nodes to the format of the
431
+ # output. [num_grid_nodes, batch, output_size] -> xarray (batch, one time
432
+ # step, lat, lon, level, multiple vars)
433
+ return self._grid_node_outputs_to_prediction(
434
+ output_grid_nodes, targets_template
435
+ )
436
+
437
+ def _maybe_init(self, sample_inputs: xarray.Dataset):
438
+ """Inits everything that has a dependency on the input coordinates."""
439
+ if not self._initialized:
440
+ self._init_mesh_properties()
441
+ self._init_grid_properties(
442
+ grid_lat=sample_inputs.lat, grid_lon=sample_inputs.lon)
443
+ self._grid2mesh_graph_structure = self._init_grid2mesh_graph()
444
+ self._mesh_graph_structure = self._init_mesh_graph()
445
+ self._mesh2grid_graph_structure = self._init_mesh2grid_graph()
446
+
447
+ self._initialized = True
448
+
449
+ def _init_mesh_properties(self):
450
+ """Inits static properties that have to do with mesh nodes."""
451
+ self._num_mesh_nodes = self._mesh.vertices.shape[0]
452
+ mesh_phi, mesh_theta = model_utils.cartesian_to_spherical(
453
+ self._mesh.vertices[:, 0],
454
+ self._mesh.vertices[:, 1],
455
+ self._mesh.vertices[:, 2])
456
+ (
457
+ mesh_nodes_lat,
458
+ mesh_nodes_lon,
459
+ ) = model_utils.spherical_to_lat_lon(
460
+ phi=mesh_phi, theta=mesh_theta)
461
+ # Convert to f32 to ensure the lat/lon features aren't in f64.
462
+ self._mesh_nodes_lat = mesh_nodes_lat.astype(np.float32)
463
+ self._mesh_nodes_lon = mesh_nodes_lon.astype(np.float32)
464
+
465
+ def _init_grid_properties(self, grid_lat: np.ndarray, grid_lon: np.ndarray):
466
+ """Inits static properties that have to do with grid nodes."""
467
+ self._grid_lat = grid_lat.astype(np.float32)
468
+ self._grid_lon = grid_lon.astype(np.float32)
469
+ # Initialized the counters.
470
+ self._num_grid_nodes = grid_lat.shape[0] * grid_lon.shape[0]
471
+
472
+ # Initialize lat and lon for the grid.
473
+ grid_nodes_lon, grid_nodes_lat = np.meshgrid(grid_lon, grid_lat)
474
+ self._grid_nodes_lon = grid_nodes_lon.reshape([-1]).astype(np.float32)
475
+ self._grid_nodes_lat = grid_nodes_lat.reshape([-1]).astype(np.float32)
476
+
477
+ def _init_grid2mesh_graph(self) -> typed_graph.TypedGraph:
478
+ """Build Grid2Mesh graph."""
479
+
480
+ # Create some edges according to distance between mesh and grid nodes.
481
+ assert self._grid_lat is not None and self._grid_lon is not None
482
+ (grid_indices, mesh_indices) = grid_mesh_connectivity.radius_query_indices(
483
+ grid_latitude=self._grid_lat,
484
+ grid_longitude=self._grid_lon,
485
+ mesh=self._mesh,
486
+ radius=self._query_radius)
487
+
488
+ # Edges sending info from grid to mesh.
489
+ senders = grid_indices
490
+ receivers = mesh_indices
491
+
492
+ # Precompute structural node and edge features according to config options.
493
+ # Structural features are those that depend on the fixed values of the
494
+ # latitude and longitudes of the nodes.
495
+ (senders_node_features, receivers_node_features,
496
+ edge_features) = model_utils.get_bipartite_graph_spatial_features(
497
+ senders_node_lat=self._grid_nodes_lat,
498
+ senders_node_lon=self._grid_nodes_lon,
499
+ receivers_node_lat=self._mesh_nodes_lat,
500
+ receivers_node_lon=self._mesh_nodes_lon,
501
+ senders=senders,
502
+ receivers=receivers,
503
+ edge_normalization_factor=None,
504
+ **self._spatial_features_kwargs,
505
+ )
506
+
507
+ n_grid_node = np.array([self._num_grid_nodes])
508
+ n_mesh_node = np.array([self._num_mesh_nodes])
509
+ n_edge = np.array([mesh_indices.shape[0]])
510
+ grid_node_set = typed_graph.NodeSet(
511
+ n_node=n_grid_node, features=senders_node_features)
512
+ mesh_node_set = typed_graph.NodeSet(
513
+ n_node=n_mesh_node, features=receivers_node_features)
514
+ edge_set = typed_graph.EdgeSet(
515
+ n_edge=n_edge,
516
+ indices=typed_graph.EdgesIndices(senders=senders, receivers=receivers),
517
+ features=edge_features)
518
+ nodes = {"grid_nodes": grid_node_set, "mesh_nodes": mesh_node_set}
519
+ edges = {
520
+ typed_graph.EdgeSetKey("grid2mesh", ("grid_nodes", "mesh_nodes")):
521
+ edge_set
522
+ }
523
+ grid2mesh_graph = typed_graph.TypedGraph(
524
+ context=typed_graph.Context(n_graph=np.array([1]), features=()),
525
+ nodes=nodes,
526
+ edges=edges)
527
+ return grid2mesh_graph
528
+
529
+ def _init_mesh_graph(self) -> typed_graph.TypedGraph:
530
+ """Build Mesh graph."""
531
+ # Work simply on the mesh edges.
532
+ # N.B.To make sure ordering is preserved, any changes to faces_to_edges here
533
+ # should be reflected in the other 2 calls to faces_to_edges in this file.
534
+ senders, receivers = icosahedral_mesh.faces_to_edges(self._mesh.faces)
535
+
536
+ # Precompute structural node and edge features according to config options.
537
+ # Structural features are those that depend on the fixed values of the
538
+ # latitude and longitudes of the nodes.
539
+ assert self._mesh_nodes_lat is not None and self._mesh_nodes_lon is not None
540
+ node_features, edge_features = model_utils.get_graph_spatial_features(
541
+ node_lat=self._mesh_nodes_lat,
542
+ node_lon=self._mesh_nodes_lon,
543
+ senders=senders,
544
+ receivers=receivers,
545
+ **self._spatial_features_kwargs,
546
+ )
547
+
548
+ n_mesh_node = np.array([self._num_mesh_nodes])
549
+ n_edge = np.array([senders.shape[0]])
550
+ assert n_mesh_node == len(node_features)
551
+ mesh_node_set = typed_graph.NodeSet(
552
+ n_node=n_mesh_node, features=node_features)
553
+ edge_set = typed_graph.EdgeSet(
554
+ n_edge=n_edge,
555
+ indices=typed_graph.EdgesIndices(senders=senders, receivers=receivers),
556
+ features=edge_features)
557
+ nodes = {"mesh_nodes": mesh_node_set}
558
+ edges = {
559
+ typed_graph.EdgeSetKey("mesh", ("mesh_nodes", "mesh_nodes")): edge_set
560
+ }
561
+ mesh_graph = typed_graph.TypedGraph(
562
+ context=typed_graph.Context(n_graph=np.array([1]), features=()),
563
+ nodes=nodes,
564
+ edges=edges)
565
+
566
+ return mesh_graph
567
+
568
+ def _init_mesh2grid_graph(self) -> typed_graph.TypedGraph:
569
+ """Build Mesh2Grid graph."""
570
+
571
+ # Create some edges according to how the grid nodes are contained by
572
+ # mesh triangles.
573
+ (grid_indices,
574
+ mesh_indices) = grid_mesh_connectivity.in_mesh_triangle_indices(
575
+ grid_latitude=self._grid_lat,
576
+ grid_longitude=self._grid_lon,
577
+ mesh=self._mesh)
578
+
579
+ # Edges sending info from mesh to grid.
580
+ senders = mesh_indices
581
+ receivers = grid_indices
582
+
583
+ # Precompute structural node and edge features according to config options.
584
+ assert self._mesh_nodes_lat is not None and self._mesh_nodes_lon is not None
585
+ (senders_node_features, receivers_node_features,
586
+ edge_features) = model_utils.get_bipartite_graph_spatial_features(
587
+ senders_node_lat=self._mesh_nodes_lat,
588
+ senders_node_lon=self._mesh_nodes_lon,
589
+ receivers_node_lat=self._grid_nodes_lat,
590
+ receivers_node_lon=self._grid_nodes_lon,
591
+ senders=senders,
592
+ receivers=receivers,
593
+ edge_normalization_factor=None,
594
+ **self._spatial_features_kwargs,
595
+ )
596
+
597
+ n_grid_node = np.array([self._num_grid_nodes])
598
+ n_mesh_node = np.array([self._num_mesh_nodes])
599
+ n_edge = np.array([senders.shape[0]])
600
+ grid_node_set = typed_graph.NodeSet(
601
+ n_node=n_grid_node, features=receivers_node_features)
602
+ mesh_node_set = typed_graph.NodeSet(
603
+ n_node=n_mesh_node, features=senders_node_features)
604
+ edge_set = typed_graph.EdgeSet(
605
+ n_edge=n_edge,
606
+ indices=typed_graph.EdgesIndices(senders=senders, receivers=receivers),
607
+ features=edge_features)
608
+ nodes = {"grid_nodes": grid_node_set, "mesh_nodes": mesh_node_set}
609
+ edges = {
610
+ typed_graph.EdgeSetKey("mesh2grid", ("mesh_nodes", "grid_nodes")):
611
+ edge_set
612
+ }
613
+ mesh2grid_graph = typed_graph.TypedGraph(
614
+ context=typed_graph.Context(n_graph=np.array([1]), features=()),
615
+ nodes=nodes,
616
+ edges=edges)
617
+ return mesh2grid_graph
618
+
619
+ def _run_grid2mesh_gnn(self, grid_node_features: chex.Array,
620
+ global_norm_conditioning: Optional[chex.Array] = None,
621
+ ) -> tuple[chex.Array, chex.Array]:
622
+ """Runs the grid2mesh_gnn, extracting latent mesh and grid nodes."""
623
+
624
+ # Concatenate node structural features with input features.
625
+ batch_size = grid_node_features.shape[1]
626
+
627
+ grid2mesh_graph = self._grid2mesh_graph_structure
628
+ assert grid2mesh_graph is not None
629
+ grid_nodes = grid2mesh_graph.nodes["grid_nodes"]
630
+ mesh_nodes = grid2mesh_graph.nodes["mesh_nodes"]
631
+ new_grid_nodes = grid_nodes._replace(
632
+ features=jnp.concatenate([
633
+ grid_node_features,
634
+ _add_batch_second_axis(
635
+ grid_nodes.features.astype(grid_node_features.dtype),
636
+ batch_size)
637
+ ],
638
+ axis=-1))
639
+
640
+ # To make sure capacity of the embedded is identical for the grid nodes and
641
+ # the mesh nodes, we also append some dummy zero input features for the
642
+ # mesh nodes.
643
+ dummy_mesh_node_features = jnp.zeros(
644
+ (self._num_mesh_nodes,) + grid_node_features.shape[1:],
645
+ dtype=grid_node_features.dtype)
646
+ new_mesh_nodes = mesh_nodes._replace(
647
+ features=jnp.concatenate([
648
+ dummy_mesh_node_features,
649
+ _add_batch_second_axis(
650
+ mesh_nodes.features.astype(dummy_mesh_node_features.dtype),
651
+ batch_size)
652
+ ],
653
+ axis=-1))
654
+
655
+ # Broadcast edge structural features to the required batch size.
656
+ grid2mesh_edges_key = grid2mesh_graph.edge_key_by_name("grid2mesh")
657
+ edges = grid2mesh_graph.edges[grid2mesh_edges_key]
658
+
659
+ new_edges = edges._replace(
660
+ features=_add_batch_second_axis(
661
+ edges.features.astype(dummy_mesh_node_features.dtype), batch_size))
662
+
663
+ input_graph = self._grid2mesh_graph_structure._replace(
664
+ edges={grid2mesh_edges_key: new_edges},
665
+ nodes={
666
+ "grid_nodes": new_grid_nodes,
667
+ "mesh_nodes": new_mesh_nodes
668
+ })
669
+
670
+ # Run the GNN.
671
+ grid2mesh_out = self._grid2mesh_gnn(input_graph, global_norm_conditioning)
672
+ latent_mesh_nodes = grid2mesh_out.nodes["mesh_nodes"].features
673
+ latent_grid_nodes = grid2mesh_out.nodes["grid_nodes"].features
674
+ return latent_mesh_nodes, latent_grid_nodes
675
+
676
+ def _run_mesh_gnn(self, latent_mesh_nodes: chex.Array,
677
+ global_norm_conditioning: Optional[chex.Array] = None
678
+ ) -> chex.Array:
679
+ """Runs the mesh_gnn, extracting updated latent mesh nodes."""
680
+
681
+ # Add the structural edge features of this graph. Note we don't need
682
+ # to add the structural node features, because these are already part of
683
+ # the latent state, via the original Grid2Mesh gnn, however, we need
684
+ # the edge ones, because it is the first time we are seeing this particular
685
+ # set of edges.
686
+ batch_size = latent_mesh_nodes.shape[1]
687
+
688
+ mesh_graph = self._mesh_graph_structure
689
+ assert mesh_graph is not None
690
+ mesh_edges_key = mesh_graph.edge_key_by_name("mesh")
691
+ edges = mesh_graph.edges[mesh_edges_key]
692
+
693
+ # We are assuming here that the mesh gnn uses a single set of edge keys
694
+ # named "mesh" for the edges and that it uses a single set of nodes named
695
+ # "mesh_nodes"
696
+ msg = ("The setup currently requires to only have one kind of edge in the"
697
+ " mesh GNN.")
698
+ assert len(mesh_graph.edges) == 1, msg
699
+
700
+ new_edges = edges._replace(
701
+ features=_add_batch_second_axis(
702
+ edges.features.astype(latent_mesh_nodes.dtype), batch_size))
703
+
704
+ nodes = mesh_graph.nodes["mesh_nodes"]
705
+ nodes = nodes._replace(features=latent_mesh_nodes)
706
+
707
+ input_graph = mesh_graph._replace(
708
+ edges={mesh_edges_key: new_edges}, nodes={"mesh_nodes": nodes})
709
+
710
+ # Run the GNN.
711
+ return self._mesh_gnn(input_graph,
712
+ global_norm_conditioning=global_norm_conditioning
713
+ ).nodes["mesh_nodes"].features
714
+
715
+ def _run_mesh2grid_gnn(self,
716
+ updated_latent_mesh_nodes: chex.Array,
717
+ latent_grid_nodes: chex.Array,
718
+ global_norm_conditioning: Optional[chex.Array] = None,
719
+ ) -> chex.Array:
720
+ """Runs the mesh2grid_gnn, extracting the output grid nodes."""
721
+
722
+ # Add the structural edge features of this graph. Note we don't need
723
+ # to add the structural node features, because these are already part of
724
+ # the latent state, via the original Grid2Mesh gnn, however, we need
725
+ # the edge ones, because it is the first time we are seeing this particular
726
+ # set of edges.
727
+ batch_size = updated_latent_mesh_nodes.shape[1]
728
+
729
+ mesh2grid_graph = self._mesh2grid_graph_structure
730
+ assert mesh2grid_graph is not None
731
+ mesh_nodes = mesh2grid_graph.nodes["mesh_nodes"]
732
+ grid_nodes = mesh2grid_graph.nodes["grid_nodes"]
733
+ new_mesh_nodes = mesh_nodes._replace(features=updated_latent_mesh_nodes)
734
+ new_grid_nodes = grid_nodes._replace(features=latent_grid_nodes)
735
+ mesh2grid_key = mesh2grid_graph.edge_key_by_name("mesh2grid")
736
+ edges = mesh2grid_graph.edges[mesh2grid_key]
737
+
738
+ new_edges = edges._replace(
739
+ features=_add_batch_second_axis(
740
+ edges.features.astype(latent_grid_nodes.dtype), batch_size))
741
+
742
+ input_graph = mesh2grid_graph._replace(
743
+ edges={mesh2grid_key: new_edges},
744
+ nodes={
745
+ "mesh_nodes": new_mesh_nodes,
746
+ "grid_nodes": new_grid_nodes
747
+ })
748
+
749
+ # Run the GNN.
750
+ output_graph = self._mesh2grid_gnn(input_graph, global_norm_conditioning)
751
+ output_grid_nodes = output_graph.nodes["grid_nodes"].features
752
+
753
+ return output_grid_nodes
754
+
755
+ def _inputs_to_grid_node_features_and_norm_conditioning(
756
+ self,
757
+ inputs: xarray.Dataset,
758
+ forcings: xarray.Dataset,
759
+ ) -> Tuple[chex.Array, Optional[chex.Array]]:
760
+ """xarray ->[n_grid_nodes, batch, n_channels], [batch, n_cond channels]."""
761
+
762
+ if self._norm_conditioning_features:
763
+ norm_conditioning_inputs = inputs[list(self._norm_conditioning_features)]
764
+ inputs = inputs.drop_vars(list(self._norm_conditioning_features))
765
+
766
+ if "lat" in norm_conditioning_inputs or "lon" in norm_conditioning_inputs:
767
+ raise ValueError("Features with lat or lon dims are not currently "
768
+ "supported for norm conditioning.")
769
+ global_norm_conditioning = xarray_jax.unwrap_data(
770
+ model_utils.dataset_to_stacked(norm_conditioning_inputs,
771
+ preserved_dims=("batch",),
772
+ ).transpose("batch", ...))
773
+
774
+ else:
775
+ global_norm_conditioning = None
776
+
777
+ # xarray `Dataset` (batch, time, lat, lon, level, multiple vars)
778
+ # to xarray `DataArray` (batch, lat, lon, channels)
779
+ stacked_inputs = model_utils.dataset_to_stacked(inputs)
780
+ stacked_forcings = model_utils.dataset_to_stacked(forcings)
781
+ stacked_inputs = xarray.concat(
782
+ [stacked_inputs, stacked_forcings], dim="channels")
783
+
784
+ # xarray `DataArray` (batch, lat, lon, channels)
785
+ # to single numpy array with shape [lat_lon_node, batch, channels]
786
+ grid_xarray_lat_lon_leading = model_utils.lat_lon_to_leading_axes(
787
+ stacked_inputs)
788
+ # ["node", "batch", "features"]
789
+ grid_node_features = xarray_jax.unwrap(
790
+ grid_xarray_lat_lon_leading.data
791
+ ).reshape((-1,) + grid_xarray_lat_lon_leading.data.shape[2:])
792
+ return grid_node_features, global_norm_conditioning
793
+
794
+ def _grid_node_outputs_to_prediction(
795
+ self,
796
+ grid_node_outputs: chex.Array,
797
+ targets_template: xarray.Dataset,
798
+ ) -> xarray.Dataset:
799
+ """[num_grid_nodes, batch, num_outputs] -> xarray."""
800
+
801
+ # numpy array with shape [lat_lon_node, batch, channels]
802
+ assert self._grid_lat is not None and self._grid_lon is not None
803
+ grid_shape = (self._grid_lat.shape[0], self._grid_lon.shape[0])
804
+ grid_outputs_lat_lon_leading = grid_node_outputs.reshape(
805
+ grid_shape + grid_node_outputs.shape[1:])
806
+ dims = ("lat", "lon", "batch", "channels")
807
+ grid_xarray_lat_lon_leading = xarray_jax.DataArray(
808
+ data=grid_outputs_lat_lon_leading,
809
+ dims=dims)
810
+ grid_xarray = model_utils.restore_leading_axes(grid_xarray_lat_lon_leading)
811
+
812
+ # xarray `DataArray` (batch, lat, lon, channels)
813
+ # to xarray `Dataset` (batch, one time step, lat, lon, level, multiple vars)
814
+ return model_utils.stacked_to_dataset(
815
+ grid_xarray.variable, targets_template)
816
+
817
+
818
+ def _add_batch_second_axis(data, batch_size):
819
+ # data [leading_dim, trailing_dim]
820
+ assert data.ndim == 2
821
+ ones = jnp.ones([batch_size, 1], dtype=data.dtype)
822
+ return data[:, None] * ones # [leading_dim, batch, trailing_dim]
823
+
824
+
825
+ def _get_max_edge_distance(mesh):
826
+ # N.B.To make sure ordering is preserved, any changes to faces_to_edges here
827
+ # should be reflected in the other 2 calls to faces_to_edges in this file.
828
+ senders, receivers = icosahedral_mesh.faces_to_edges(mesh.faces)
829
+ edge_distances = np.linalg.norm(
830
+ mesh.vertices[senders] - mesh.vertices[receivers], axis=-1)
831
+ return edge_distances.max()
832
+
833
+
834
+ def _permute_mesh_to_banded(mesh):
835
+ """Permutes the mesh nodes such that adjacency matrix has banded structure."""
836
+ # Build adjacency matrix.
837
+ # N.B.To make sure ordering is preserved, any changes to faces_to_edges here
838
+ # should be reflected in the other 2 calls to faces_to_edges in this file.
839
+ senders, receivers = icosahedral_mesh.faces_to_edges(mesh.faces)
840
+ num_mesh_nodes = mesh.vertices.shape[0]
841
+ adj_mat = sparse.csr_matrix((num_mesh_nodes, num_mesh_nodes))
842
+ adj_mat[senders, receivers] = 1
843
+ # Permutation to banded (this algorithm is deterministic, a given sparse
844
+ # adjacency matrix will yield the same permutation every time this is run).
845
+ mesh_permutation = sparse.csgraph.reverse_cuthill_mckee(
846
+ adj_mat, symmetric_mode=True
847
+ )
848
+ vertex_permutation_map = {j: i for i, j in enumerate(mesh_permutation)}
849
+ permute_func = np.vectorize(lambda x: vertex_permutation_map[x])
850
+ return icosahedral_mesh.TriangularMesh(
851
+ vertices=mesh.vertices[mesh_permutation], faces=permute_func(mesh.faces)
852
+ )
model/graphcast/denoisers_base.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2024 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Base class for Denoisers used in diffusion Predictors.
15
+
16
+ Denoisers are a bit like deterministic Predictors, except:
17
+ * Their __call__ method also conditions on noisy_targets and the noise_levels
18
+ of those noisy targets
19
+ * They don't have an overrideable loss function (the loss is assumed to be some
20
+ form of MSE and is implemented outside the Denoiser itself)
21
+ """
22
+
23
+ from typing import Optional, Protocol
24
+
25
+ import xarray
26
+
27
+
28
+ class Denoiser(Protocol):
29
+ """A denoising model that conditions on inputs as well as noise level."""
30
+
31
+ def __call__(
32
+ self,
33
+ inputs: xarray.Dataset,
34
+ noisy_targets: xarray.Dataset,
35
+ noise_levels: xarray.DataArray,
36
+ forcings: Optional[xarray.Dataset] = None,
37
+ **kwargs) -> xarray.Dataset:
38
+ """Computes denoised targets from noisy targets.
39
+
40
+ Args:
41
+ inputs: Inputs to condition on, as for Predictor.__call__.
42
+ noisy_targets: Targets which have had i.i.d. zero-mean Gaussian noise
43
+ added to them (where the noise level used may vary along the 'batch'
44
+ dimension).
45
+ noise_levels: A DataArray with dimensions ('batch',) specifying the noise
46
+ levels that were used for each example in the batch.
47
+ forcings: Optional additional per-target-timestep forcings to condition
48
+ on, as for Predictor.__call__.
49
+ **kwargs: Any additional custom kwargs.
50
+
51
+ Returns:
52
+ Denoised predictions with the same shape as noisy_targets.
53
+ """
model/graphcast/dpm_solver_plus_plus_2s.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2024 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """DPM-Solver++ 2S sampler from https://arxiv.org/abs/2211.01095."""
15
+
16
+ from typing import Optional
17
+
18
+ from . import casting
19
+ from . import denoisers_base
20
+ from . import samplers_base as base
21
+ from . import samplers_utils as utils
22
+ from . import xarray_jax
23
+ import haiku as hk
24
+ import jax.numpy as jnp
25
+ import xarray
26
+
27
+
28
+ class Sampler(base.Sampler):
29
+ """Sampling using DPM-Solver++ 2S from [1].
30
+
31
+ This is combined with optional stochastic churn as described in [2].
32
+
33
+ The '2S' terminology from [1] means that this is a second-order (2),
34
+ single-step (S) solver. Here 'single-step' here distinguishes it from
35
+ 'multi-step' methods where the results of function evaluations from previous
36
+ steps are reused in computing updates for subsequent steps. The solver still
37
+ uses multiple steps though.
38
+
39
+ [1] DPM-Solver++: Fast Solver for Guided Sampling of Diffusion Probabilistic
40
+ Models, https://arxiv.org/abs/2211.01095
41
+ [2] Elucidating the Design Space of Diffusion-Based Generative Models,
42
+ https://arxiv.org/abs/2206.00364
43
+ """
44
+
45
+ def __init__(self,
46
+ denoiser: denoisers_base.Denoiser,
47
+ max_noise_level: float,
48
+ min_noise_level: float,
49
+ num_noise_levels: int,
50
+ rho: float,
51
+ stochastic_churn_rate: float,
52
+ churn_min_noise_level: float,
53
+ churn_max_noise_level: float,
54
+ noise_level_inflation_factor: float
55
+ ):
56
+ """Initializes the sampler.
57
+
58
+ Args:
59
+ denoiser: A Denoiser which predicts noise-free targets.
60
+ max_noise_level: The highest noise level used at the start of the
61
+ sequence of reverse diffusion steps.
62
+ min_noise_level: The lowest noise level used at the end of the sequence of
63
+ reverse diffusion steps.
64
+ num_noise_levels: Determines the number of noise levels used and hence the
65
+ number of reverse diffusion steps performed.
66
+ rho: Parameter affecting the spacing of noise steps. Higher values will
67
+ concentrate noise steps more around zero.
68
+ stochastic_churn_rate: S_churn from the paper. This controls the rate
69
+ at which noise is re-injected/'churned' during the sampling algorithm.
70
+ If this is set to zero then we are performing deterministic sampling
71
+ as described in Algorithm 1.
72
+ churn_min_noise_level: Minimum noise level at which stochastic churn
73
+ occurs. S_min from the paper. Only used if stochastic_churn_rate > 0.
74
+ churn_max_noise_level: Maximum noise level at which stochastic churn
75
+ occurs. S_min from the paper. Only used if stochastic_churn_rate > 0.
76
+ noise_level_inflation_factor: This can be used to set the actual amount of
77
+ noise injected higher than what the denoiser is told has been added.
78
+ The motivation is to compensate for a tendency of L2-trained denoisers
79
+ to remove slightly too much noise / blur too much. S_noise from the
80
+ paper. Only used if stochastic_churn_rate > 0.
81
+ """
82
+ super().__init__(denoiser)
83
+ self._noise_levels = utils.noise_schedule(
84
+ max_noise_level, min_noise_level, num_noise_levels, rho)
85
+ self._stochastic_churn = stochastic_churn_rate > 0
86
+ self._per_step_churn_rates = utils.stochastic_churn_rate_schedule(
87
+ self._noise_levels, stochastic_churn_rate, churn_min_noise_level,
88
+ churn_max_noise_level)
89
+ self._noise_level_inflation_factor = noise_level_inflation_factor
90
+
91
+ def __call__(
92
+ self,
93
+ inputs: xarray.Dataset,
94
+ targets_template: xarray.Dataset,
95
+ forcings: Optional[xarray.Dataset] = None,
96
+ **kwargs) -> xarray.Dataset:
97
+
98
+ dtype = casting.infer_floating_dtype(targets_template) # pytype: disable=wrong-arg-types
99
+ noise_levels = jnp.array(self._noise_levels).astype(dtype)
100
+ per_step_churn_rates = jnp.array(self._per_step_churn_rates).astype(dtype)
101
+
102
+ def denoiser(noise_level: jnp.ndarray, x: xarray.Dataset) -> xarray.Dataset:
103
+ """Computes D(x, sigma, y)."""
104
+ bcast_noise_level = xarray_jax.DataArray(
105
+ jnp.tile(noise_level, x.sizes['batch']), dims=('batch',))
106
+ # Estimate the expectation of the fully-denoised target x0, conditional on
107
+ # inputs/forcings, noisy targets and their noise level:
108
+ return self._denoiser(
109
+ inputs=inputs,
110
+ noisy_targets=x,
111
+ noise_levels=bcast_noise_level,
112
+ forcings=forcings)
113
+
114
+ def body_fn(i: jnp.ndarray, x: xarray.Dataset) -> xarray.Dataset:
115
+ """One iteration of the sampling algorithm.
116
+
117
+ Args:
118
+ i: Sampling iteration.
119
+ x: Noisy targets at iteration i, these will have noise level
120
+ self._noise_levels[i].
121
+
122
+ Returns:
123
+ Noisy targets at the next lowest noise level self._noise_levels[i+1].
124
+ """
125
+ def init_noise(template):
126
+ return noise_levels[0] * utils.spherical_white_noise_like(template)
127
+
128
+ # Initialise the inputs if i == 0.
129
+ # This is done here to ensure both noise sampler calls can use the same
130
+ # spherical harmonic basis functions. While there may be a small compute
131
+ # cost the memory savings can be significant.
132
+ # TODO(dominicmasters): Figure out if we can merge the two noise sampler
133
+ # calls into one to avoid this hack.
134
+ maybe_init_noise = (i == 0).astype(noise_levels[0].dtype)
135
+ x = x + init_noise(x) * maybe_init_noise
136
+
137
+ noise_level = noise_levels[i]
138
+
139
+ if self._stochastic_churn:
140
+ # We increase the noise level of x a bit before taking it down again:
141
+ x, noise_level = utils.apply_stochastic_churn(
142
+ x, noise_level,
143
+ stochastic_churn_rate=per_step_churn_rates[i],
144
+ noise_level_inflation_factor=self._noise_level_inflation_factor)
145
+
146
+ # Apply one step of the ODE solver to take x down to the next lowest
147
+ # noise level.
148
+
149
+ # Note that the Elucidating paper's choice of sigma(t)=t and s(t)=1
150
+ # (corresponding to alpha(t)=1 in the DPM paper) as well as the standard
151
+ # choice of r=1/2 (corresponding to a geometric mean for the s_i
152
+ # midpoints) greatly simplifies the update from the DPM-Solver++ paper.
153
+ # You need to do a bit of algebraic fiddling to arrive at the below after
154
+ # substituting these choices into DPMSolver++'s Algorithm 1. The simpler
155
+ # update we arrive at helps with intuition too.
156
+
157
+ next_noise_level = noise_levels[i + 1]
158
+ # This is s_{i+1} from the paper. They don't explain how the s_i are
159
+ # chosen, but the default choice seems to be a geometric mean, which is
160
+ # equivalent to setting all the r_i = 1/2.
161
+ mid_noise_level = jnp.sqrt(noise_level * next_noise_level)
162
+
163
+ mid_over_current = mid_noise_level / noise_level
164
+ x_denoised = denoiser(noise_level, x)
165
+ # This turns out to be a convex combination of current and denoised x,
166
+ # which isn't entirely apparent from the paper formulae:
167
+ x_mid = mid_over_current * x + (1 - mid_over_current) * x_denoised
168
+
169
+ next_over_current = next_noise_level / noise_level
170
+ x_mid_denoised = denoiser(mid_noise_level, x_mid) # pytype: disable=wrong-arg-types
171
+ x_next = next_over_current * x + (1 - next_over_current) * x_mid_denoised
172
+
173
+ # For the final step to noise level 0, we do an Euler update which
174
+ # corresponds to just returning the denoiser's prediction directly.
175
+ #
176
+ # In fact the behaviour above when next_noise_level == 0 is almost
177
+ # equivalent, except that it runs the denoiser a second time to denoise
178
+ # from noise level 0. The denoiser should just be the identity function in
179
+ # this case, but it hasn't necessarily been trained at noise level 0 so
180
+ # we avoid relying on this.
181
+ return utils.tree_where(next_noise_level == 0, x_denoised, x_next)
182
+
183
+ # Init with zeros but apply additional noise at step 0 to initialise the
184
+ # state.
185
+ noise_init = xarray.zeros_like(targets_template)
186
+ return hk.fori_loop(
187
+ 0, len(noise_levels) - 1, body_fun=body_fn, init_val=noise_init)
model/graphcast/gencast.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2024 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Denoising diffusion models based on the framework of [1].
15
+
16
+ Throughout we will refer to notation and equations from [1].
17
+
18
+ [1] Elucidating the Design Space of Diffusion-Based Generative Models
19
+ Karras, Aittala, Aila and Laine, 2022
20
+ https://arxiv.org/abs/2206.00364
21
+ """
22
+
23
+ from typing import Any, Optional, Tuple
24
+
25
+ import chex
26
+ from . import casting
27
+ from . import denoiser
28
+ from . import dpm_solver_plus_plus_2s
29
+ from . import graphcast
30
+ from . import losses
31
+ from . import predictor_base
32
+ from . import samplers_utils
33
+ from . import xarray_jax
34
+ import haiku as hk
35
+ import jax
36
+ import xarray
37
+
38
+
39
+ TARGET_SURFACE_VARS = (
40
+ '2m_temperature',
41
+ 'mean_sea_level_pressure',
42
+ '10m_v_component_of_wind',
43
+ '10m_u_component_of_wind', # GenCast predicts in 12hr timesteps.
44
+ 'total_precipitation_12hr',
45
+ 'sea_surface_temperature',
46
+ )
47
+
48
+ TARGET_SURFACE_NO_PRECIP_VARS = (
49
+ '2m_temperature',
50
+ 'mean_sea_level_pressure',
51
+ '10m_v_component_of_wind',
52
+ '10m_u_component_of_wind',
53
+ 'sea_surface_temperature',
54
+ )
55
+
56
+
57
+ TASK = graphcast.TaskConfig(
58
+ input_variables=(
59
+ # GenCast doesn't take precipitation as input.
60
+ TARGET_SURFACE_NO_PRECIP_VARS
61
+ + graphcast.TARGET_ATMOSPHERIC_VARS
62
+ + graphcast.GENERATED_FORCING_VARS
63
+ + graphcast.STATIC_VARS
64
+ ),
65
+ target_variables=TARGET_SURFACE_VARS + graphcast.TARGET_ATMOSPHERIC_VARS,
66
+ # GenCast doesn't take incident solar radiation as a forcing.
67
+ forcing_variables=graphcast.GENERATED_FORCING_VARS,
68
+ pressure_levels=graphcast.PRESSURE_LEVELS_WEATHERBENCH_13,
69
+ # GenCast takes the current frame and the frame 12 hours prior.
70
+ input_duration='24h',
71
+ )
72
+
73
+
74
+ @chex.dataclass(frozen=True, eq=True)
75
+ class SamplerConfig:
76
+ """Configures the sampler used to draw samples from GenCast.
77
+
78
+ max_noise_level: The highest noise level used at the start of the
79
+ sequence of reverse diffusion steps.
80
+ min_noise_level: The lowest noise level used at the end of the sequence of
81
+ reverse diffusion steps.
82
+ num_noise_levels: Determines the number of noise levels used and hence the
83
+ number of reverse diffusion steps performed.
84
+ rho: Parameter affecting the spacing of noise steps. Higher values will
85
+ concentrate noise steps more around zero.
86
+ stochastic_churn_rate: S_churn from the paper. This controls the rate
87
+ at which noise is re-injected/'churned' during the sampling algorithm.
88
+ If this is set to zero then we are performing deterministic sampling
89
+ as described in Algorithm 1.
90
+ churn_max_noise_level: Maximum noise level at which stochastic churn
91
+ occurs. S_min from the paper. Only used if stochastic_churn_rate > 0.
92
+ churn_min_noise_level: Minimum noise level at which stochastic churn
93
+ occurs. S_min from the paper. Only used if stochastic_churn_rate > 0.
94
+ noise_level_inflation_factor: This can be used to set the actual amount of
95
+ noise injected higher than what the denoiser is told has been added.
96
+ The motivation is to compensate for a tendency of L2-trained denoisers
97
+ to remove slightly too much noise / blur too much. S_noise from the
98
+ paper. Only used if stochastic_churn_rate > 0.
99
+ """
100
+ max_noise_level: float = 80.
101
+ min_noise_level: float = 0.03
102
+ num_noise_levels: int = 20
103
+ rho: float = 7.
104
+ # Stochastic sampler settings.
105
+ stochastic_churn_rate: float = 2.5
106
+ churn_min_noise_level: float = 0.75
107
+ churn_max_noise_level: float = float('inf')
108
+ noise_level_inflation_factor: float = 1.05
109
+
110
+
111
+ @chex.dataclass(frozen=True, eq=True)
112
+ class NoiseConfig:
113
+ training_noise_level_rho: float = 7.0
114
+ training_max_noise_level: float = 88.0
115
+ training_min_noise_level: float = 0.02
116
+
117
+
118
+ @chex.dataclass(frozen=True, eq=True)
119
+ class CheckPoint:
120
+ description: str
121
+ license: str
122
+ params: dict[str, Any]
123
+ task_config: graphcast.TaskConfig
124
+ denoiser_architecture_config: denoiser.DenoiserArchitectureConfig
125
+ sampler_config: SamplerConfig
126
+ noise_config: NoiseConfig
127
+ noise_encoder_config: denoiser.NoiseEncoderConfig
128
+
129
+
130
+ class GenCast(predictor_base.Predictor):
131
+ """Predictor for a denoising diffusion model following the framework of [1].
132
+
133
+ [1] Elucidating the Design Space of Diffusion-Based Generative Models
134
+ Karras, Aittala, Aila and Laine, 2022
135
+ https://arxiv.org/abs/2206.00364
136
+
137
+ Unlike the paper, we have a conditional model and our denoising function
138
+ conditions on previous timesteps.
139
+
140
+ As the paper demonstrates, the sampling algorithm can be varied independently
141
+ of the denoising model and its training procedure, and it is separately
142
+ configurable here.
143
+ """
144
+
145
+ def __init__(
146
+ self,
147
+ task_config: graphcast.TaskConfig,
148
+ denoiser_architecture_config: denoiser.DenoiserArchitectureConfig,
149
+ sampler_config: Optional[SamplerConfig] = None,
150
+ noise_config: Optional[NoiseConfig] = None,
151
+ noise_encoder_config: Optional[denoiser.NoiseEncoderConfig] = None,
152
+ ):
153
+ """Constructs GenCast."""
154
+ # Output size depends on number of variables being predicted.
155
+ num_surface_vars = len(
156
+ set(task_config.target_variables)
157
+ - set(graphcast.ALL_ATMOSPHERIC_VARS)
158
+ )
159
+ num_atmospheric_vars = len(
160
+ set(task_config.target_variables)
161
+ & set(graphcast.ALL_ATMOSPHERIC_VARS)
162
+ )
163
+ num_outputs = (
164
+ num_surface_vars
165
+ + len(task_config.pressure_levels) * num_atmospheric_vars
166
+ )
167
+ denoiser_architecture_config.node_output_size = num_outputs
168
+ self._denoiser = denoiser.Denoiser(
169
+ noise_encoder_config,
170
+ denoiser_architecture_config,
171
+ )
172
+ self._sampler_config = sampler_config
173
+ # Singleton to avoid re-initializing the sampler for each inference call.
174
+ self._sampler = None
175
+ self._noise_config = noise_config
176
+
177
+ def _c_in(self, noise_scale: xarray.DataArray) -> xarray.DataArray:
178
+ """Scaling applied to the noisy targets input to the underlying network."""
179
+ return (noise_scale**2 + 1)**-0.5
180
+
181
+ def _c_out(self, noise_scale: xarray.DataArray) -> xarray.DataArray:
182
+ """Scaling applied to the underlying network's raw outputs."""
183
+ return noise_scale * (noise_scale**2 + 1)**-0.5
184
+
185
+ def _c_skip(self, noise_scale: xarray.DataArray) -> xarray.DataArray:
186
+ """Scaling applied to the skip connection."""
187
+ return 1 / (noise_scale**2 + 1)
188
+
189
+ def _loss_weighting(self, noise_scale: xarray.DataArray) -> xarray.DataArray:
190
+ r"""The loss weighting \lambda(\sigma) from the paper."""
191
+ return self._c_out(noise_scale) ** -2
192
+
193
+ def _preconditioned_denoiser(
194
+ self,
195
+ inputs: xarray.Dataset,
196
+ noisy_targets: xarray.Dataset,
197
+ noise_levels: xarray.DataArray,
198
+ forcings: Optional[xarray.Dataset] = None,
199
+ **kwargs) -> xarray.Dataset:
200
+ """The preconditioned denoising function D from the paper (Eqn 7)."""
201
+ raw_predictions = self._denoiser(
202
+ inputs=inputs,
203
+ noisy_targets=noisy_targets * self._c_in(noise_levels),
204
+ noise_levels=noise_levels,
205
+ forcings=forcings,
206
+ **kwargs)
207
+ return (raw_predictions * self._c_out(noise_levels) +
208
+ noisy_targets * self._c_skip(noise_levels))
209
+
210
+ def loss_and_predictions(
211
+ self,
212
+ inputs: xarray.Dataset,
213
+ targets: xarray.Dataset,
214
+ forcings: Optional[xarray.Dataset] = None,
215
+ ) -> Tuple[predictor_base.LossAndDiagnostics, xarray.Dataset]:
216
+ return self.loss(inputs, targets, forcings), self(inputs, targets, forcings)
217
+
218
+ def loss(self,
219
+ inputs: xarray.Dataset,
220
+ targets: xarray.Dataset,
221
+ forcings: Optional[xarray.Dataset] = None,
222
+ ) -> predictor_base.LossAndDiagnostics:
223
+
224
+ if self._noise_config is None:
225
+ raise ValueError('Noise config must be specified to train GenCast.')
226
+
227
+ # Sample noise levels:
228
+ dtype = casting.infer_floating_dtype(targets) # pytype: disable=wrong-arg-types
229
+ key = hk.next_rng_key()
230
+ batch_size = inputs.sizes['batch']
231
+ noise_levels = xarray_jax.DataArray(
232
+ data=samplers_utils.rho_inverse_cdf(
233
+ min_value=self._noise_config.training_min_noise_level,
234
+ max_value=self._noise_config.training_max_noise_level,
235
+ rho=self._noise_config.training_noise_level_rho,
236
+ cdf=jax.random.uniform(key, shape=(batch_size,), dtype=dtype)),
237
+ dims=('batch',))
238
+
239
+ # Sample noise and apply it to targets:
240
+ noise = (
241
+ samplers_utils.spherical_white_noise_like(targets) * noise_levels
242
+ )
243
+ noisy_targets = targets + noise
244
+
245
+ denoised_predictions = self._preconditioned_denoiser(
246
+ inputs, noisy_targets, noise_levels, forcings)
247
+
248
+ loss, diagnostics = losses.weighted_mse_per_level(
249
+ denoised_predictions,
250
+ targets,
251
+ # Weights are same as we used for GraphCast.
252
+ per_variable_weights={
253
+ # Any variables not specified here are weighted as 1.0.
254
+ # A single-level variable, but an important headline variable
255
+ # and also one which we have struggled to get good performance
256
+ # on at short lead times, so leaving it weighted at 1.0, equal
257
+ # to the multi-level variables:
258
+ '2m_temperature': 1.0,
259
+ # New single-level variables, which we don't weight too highly
260
+ # to avoid hurting performance on other variables.
261
+ '10m_u_component_of_wind': 0.1,
262
+ '10m_v_component_of_wind': 0.1,
263
+ 'mean_sea_level_pressure': 0.1,
264
+ 'sea_surface_temperature': 0.1,
265
+ 'total_precipitation_12hr': 0.1
266
+ },
267
+ )
268
+ loss *= self._loss_weighting(noise_levels)
269
+ return loss, diagnostics
270
+
271
+ def __call__(self,
272
+ inputs: xarray.Dataset,
273
+ targets_template: xarray.Dataset,
274
+ forcings: Optional[xarray.Dataset] = None,
275
+ **kwargs) -> xarray.Dataset:
276
+ if self._sampler_config is None:
277
+ raise ValueError(
278
+ 'Sampler config must be specified to run inference on GenCast.'
279
+ )
280
+ if self._sampler is None:
281
+ self._sampler = dpm_solver_plus_plus_2s.Sampler(
282
+ self._preconditioned_denoiser, **self._sampler_config
283
+ )
284
+ return self._sampler(inputs, targets_template, forcings, **kwargs)
model/graphcast/graphcast.py ADDED
@@ -0,0 +1,796 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2023 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """A predictor that runs multiple graph neural networks on mesh data.
15
+
16
+ It learns to interpolate between the grid and the mesh nodes, with the loss
17
+ and the rollouts ultimately computed at the grid level.
18
+
19
+ It uses ideas similar to those in Keisler (2022):
20
+
21
+ Reference:
22
+ https://arxiv.org/pdf/2202.07575.pdf
23
+
24
+ It assumes data across time and level is stacked, and operates only operates in
25
+ a 2D mesh over latitudes and longitudes.
26
+ """
27
+
28
+ from typing import Any, Callable, Mapping, Optional
29
+
30
+ import chex
31
+ from . import deep_typed_graph_net
32
+ from . import grid_mesh_connectivity
33
+ from . import icosahedral_mesh
34
+ from . import losses
35
+ from . import model_utils
36
+ from . import predictor_base
37
+ from . import typed_graph
38
+ from . import xarray_jax
39
+ import jax.numpy as jnp
40
+ import jraph
41
+ import numpy as np
42
+ import xarray
43
+
44
+ Kwargs = Mapping[str, Any]
45
+
46
+ GNN = Callable[[jraph.GraphsTuple], jraph.GraphsTuple]
47
+
48
+
49
+ # https://www.ecmwf.int/en/forecasts/dataset/ecmwf-reanalysis-v5
50
+ PRESSURE_LEVELS_ERA5_37 = (
51
+ 1, 2, 3, 5, 7, 10, 20, 30, 50, 70, 100, 125, 150, 175, 200, 225, 250, 300,
52
+ 350, 400, 450, 500, 550, 600, 650, 700, 750, 775, 800, 825, 850, 875, 900,
53
+ 925, 950, 975, 1000)
54
+
55
+ # https://www.ecmwf.int/en/forecasts/datasets/set-i
56
+ PRESSURE_LEVELS_HRES_25 = (
57
+ 1, 2, 3, 5, 7, 10, 20, 30, 50, 70, 100, 150, 200, 250, 300, 400, 500, 600,
58
+ 700, 800, 850, 900, 925, 950, 1000)
59
+
60
+ # https://agupubs.onlinelibrary.wiley.com/doi/full/10.1029/2020MS002203
61
+ PRESSURE_LEVELS_WEATHERBENCH_13 = (
62
+ 50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)
63
+
64
+ PRESSURE_LEVELS = {
65
+ 13: PRESSURE_LEVELS_WEATHERBENCH_13,
66
+ 25: PRESSURE_LEVELS_HRES_25,
67
+ 37: PRESSURE_LEVELS_ERA5_37,
68
+ }
69
+
70
+ # The list of all possible atmospheric variables. Taken from:
71
+ # https://confluence.ecmwf.int/display/CKB/ERA5%3A+data+documentation#ERA5:datadocumentation-Table9
72
+ ALL_ATMOSPHERIC_VARS = (
73
+ "potential_vorticity",
74
+ "specific_rain_water_content",
75
+ "specific_snow_water_content",
76
+ "geopotential",
77
+ "temperature",
78
+ "u_component_of_wind",
79
+ "v_component_of_wind",
80
+ "specific_humidity",
81
+ "vertical_velocity",
82
+ "vorticity",
83
+ "divergence",
84
+ "relative_humidity",
85
+ "ozone_mass_mixing_ratio",
86
+ "specific_cloud_liquid_water_content",
87
+ "specific_cloud_ice_water_content",
88
+ "fraction_of_cloud_cover",
89
+ )
90
+
91
+ TARGET_SURFACE_VARS = (
92
+ "2m_temperature",
93
+ "mean_sea_level_pressure",
94
+ "10m_v_component_of_wind",
95
+ "10m_u_component_of_wind",
96
+ "total_precipitation_6hr",
97
+ )
98
+ TARGET_SURFACE_NO_PRECIP_VARS = (
99
+ "2m_temperature",
100
+ "mean_sea_level_pressure",
101
+ "10m_v_component_of_wind",
102
+ "10m_u_component_of_wind",
103
+ )
104
+ TARGET_ATMOSPHERIC_VARS = (
105
+ "temperature",
106
+ "geopotential",
107
+ "u_component_of_wind",
108
+ "v_component_of_wind",
109
+ "vertical_velocity",
110
+ "specific_humidity",
111
+ )
112
+ TARGET_ATMOSPHERIC_NO_W_VARS = (
113
+ "temperature",
114
+ "geopotential",
115
+ "u_component_of_wind",
116
+ "v_component_of_wind",
117
+ "specific_humidity",
118
+ )
119
+ EXTERNAL_FORCING_VARS = (
120
+ "toa_incident_solar_radiation",
121
+ )
122
+ GENERATED_FORCING_VARS = (
123
+ "year_progress_sin",
124
+ "year_progress_cos",
125
+ "day_progress_sin",
126
+ "day_progress_cos",
127
+ )
128
+ FORCING_VARS = EXTERNAL_FORCING_VARS + GENERATED_FORCING_VARS
129
+ STATIC_VARS = (
130
+ "geopotential_at_surface",
131
+ "land_sea_mask",
132
+ )
133
+
134
+
135
+ @chex.dataclass(frozen=True, eq=True)
136
+ class TaskConfig:
137
+ """Defines inputs and targets on which a model is trained and/or evaluated."""
138
+ input_variables: tuple[str, ...]
139
+ # Target variables which the model is expected to predict.
140
+ target_variables: tuple[str, ...]
141
+ forcing_variables: tuple[str, ...]
142
+ pressure_levels: tuple[int, ...]
143
+ input_duration: str
144
+
145
+ TASK = TaskConfig(
146
+ input_variables=(
147
+ TARGET_SURFACE_VARS + TARGET_ATMOSPHERIC_VARS + FORCING_VARS +
148
+ STATIC_VARS),
149
+ target_variables=TARGET_SURFACE_VARS + TARGET_ATMOSPHERIC_VARS,
150
+ forcing_variables=FORCING_VARS,
151
+ pressure_levels=PRESSURE_LEVELS_ERA5_37,
152
+ input_duration="12h",
153
+ )
154
+ TASK_13 = TaskConfig(
155
+ input_variables=(
156
+ TARGET_SURFACE_VARS + TARGET_ATMOSPHERIC_VARS + FORCING_VARS +
157
+ STATIC_VARS),
158
+ target_variables=TARGET_SURFACE_VARS + TARGET_ATMOSPHERIC_VARS,
159
+ forcing_variables=FORCING_VARS,
160
+ pressure_levels=PRESSURE_LEVELS_WEATHERBENCH_13,
161
+ input_duration="12h",
162
+ )
163
+ TASK_13_PRECIP_OUT = TaskConfig(
164
+ input_variables=(
165
+ TARGET_SURFACE_NO_PRECIP_VARS + TARGET_ATMOSPHERIC_VARS + FORCING_VARS +
166
+ STATIC_VARS),
167
+ target_variables=TARGET_SURFACE_VARS + TARGET_ATMOSPHERIC_VARS,
168
+ forcing_variables=FORCING_VARS,
169
+ pressure_levels=PRESSURE_LEVELS_WEATHERBENCH_13,
170
+ input_duration="12h",
171
+ )
172
+
173
+
174
+ @chex.dataclass(frozen=True, eq=True)
175
+ class ModelConfig:
176
+ """Defines the architecture of the GraphCast neural network architecture.
177
+
178
+ Properties:
179
+ resolution: The resolution of the data, in degrees (e.g. 0.25 or 1.0).
180
+ mesh_size: How many refinements to do on the multi-mesh.
181
+ gnn_msg_steps: How many Graph Network message passing steps to do.
182
+ latent_size: How many latent features to include in the various MLPs.
183
+ hidden_layers: How many hidden layers for each MLP.
184
+ radius_query_fraction_edge_length: Scalar that will be multiplied by the
185
+ length of the longest edge of the finest mesh to define the radius of
186
+ connectivity to use in the Grid2Mesh graph. Reasonable values are
187
+ between 0.6 and 1. 0.6 reduces the number of grid points feeding into
188
+ multiple mesh nodes and therefore reduces edge count and memory use, but
189
+ 1 gives better predictions.
190
+ mesh2grid_edge_normalization_factor: Allows explicitly controlling edge
191
+ normalization for mesh2grid edges. If None, defaults to max edge length.
192
+ This supports using pre-trained model weights with a different graph
193
+ structure to what it was trained on.
194
+ """
195
+ resolution: float
196
+ mesh_size: int
197
+ latent_size: int
198
+ gnn_msg_steps: int
199
+ hidden_layers: int
200
+ radius_query_fraction_edge_length: float
201
+ mesh2grid_edge_normalization_factor: Optional[float] = None
202
+
203
+
204
+ @chex.dataclass(frozen=True, eq=True)
205
+ class CheckPoint:
206
+ params: dict[str, Any]
207
+ model_config: ModelConfig
208
+ task_config: TaskConfig
209
+ description: str
210
+ license: str
211
+
212
+
213
+ class GraphCast(predictor_base.Predictor):
214
+ """GraphCast Predictor.
215
+
216
+ The model works on graphs that take into account:
217
+ * Mesh nodes: nodes for the vertices of the mesh.
218
+ * Grid nodes: nodes for the points of the grid.
219
+ * Nodes: When referring to just "nodes", this means the joint set of
220
+ both mesh nodes, concatenated with grid nodes.
221
+
222
+ The model works with 3 graphs:
223
+ * Grid2Mesh graph: Graph that contains all nodes. This graph is strictly
224
+ bipartite with edges going from grid nodes to mesh nodes using a
225
+ fixed radius query. The grid2mesh_gnn will operate in this graph. The output
226
+ of this stage will be a latent representation for the mesh nodes, and a
227
+ latent representation for the grid nodes.
228
+ * Mesh graph: Graph that contains mesh nodes only. The mesh_gnn will
229
+ operate in this graph. It will update the latent state of the mesh nodes
230
+ only.
231
+ * Mesh2Grid graph: Graph that contains all nodes. This graph is strictly
232
+ bipartite with edges going from mesh nodes to grid nodes such that each grid
233
+ nodes is connected to 3 nodes of the mesh triangular face that contains
234
+ the grid points. The mesh2grid_gnn will operate in this graph. It will
235
+ process the updated latent state of the mesh nodes, and the latent state
236
+ of the grid nodes, to produce the final output for the grid nodes.
237
+
238
+ The model is built on top of `TypedGraph`s so the different types of nodes and
239
+ edges can be stored and treated separately.
240
+
241
+ """
242
+
243
+ def __init__(self, model_config: ModelConfig, task_config: TaskConfig):
244
+ """Initializes the predictor."""
245
+ self._spatial_features_kwargs = dict(
246
+ add_node_positions=False,
247
+ add_node_latitude=True,
248
+ add_node_longitude=True,
249
+ add_relative_positions=True,
250
+ relative_longitude_local_coordinates=True,
251
+ relative_latitude_local_coordinates=True,
252
+ )
253
+
254
+ # Specification of the multimesh.
255
+ self._meshes = (
256
+ icosahedral_mesh.get_hierarchy_of_triangular_meshes_for_sphere(
257
+ splits=model_config.mesh_size))
258
+
259
+ # Encoder, which moves data from the grid to the mesh with a single message
260
+ # passing step.
261
+ self._grid2mesh_gnn = deep_typed_graph_net.DeepTypedGraphNet(
262
+ embed_nodes=True, # Embed raw features of the grid and mesh nodes.
263
+ embed_edges=True, # Embed raw features of the grid2mesh edges.
264
+ edge_latent_size=dict(grid2mesh=model_config.latent_size),
265
+ node_latent_size=dict(
266
+ mesh_nodes=model_config.latent_size,
267
+ grid_nodes=model_config.latent_size),
268
+ mlp_hidden_size=model_config.latent_size,
269
+ mlp_num_hidden_layers=model_config.hidden_layers,
270
+ num_message_passing_steps=1,
271
+ use_layer_norm=True,
272
+ include_sent_messages_in_node_update=False,
273
+ activation="swish",
274
+ f32_aggregation=True,
275
+ aggregate_normalization=None,
276
+ name="grid2mesh_gnn",
277
+ )
278
+
279
+ # Processor, which performs message passing on the multi-mesh.
280
+ self._mesh_gnn = deep_typed_graph_net.DeepTypedGraphNet(
281
+ embed_nodes=False, # Node features already embdded by previous layers.
282
+ embed_edges=True, # Embed raw features of the multi-mesh edges.
283
+ node_latent_size=dict(mesh_nodes=model_config.latent_size),
284
+ edge_latent_size=dict(mesh=model_config.latent_size),
285
+ mlp_hidden_size=model_config.latent_size,
286
+ mlp_num_hidden_layers=model_config.hidden_layers,
287
+ num_message_passing_steps=model_config.gnn_msg_steps,
288
+ use_layer_norm=True,
289
+ include_sent_messages_in_node_update=False,
290
+ activation="swish",
291
+ f32_aggregation=False,
292
+ name="mesh_gnn",
293
+ )
294
+
295
+ num_surface_vars = len(
296
+ set(task_config.target_variables) - set(ALL_ATMOSPHERIC_VARS))
297
+ num_atmospheric_vars = len(
298
+ set(task_config.target_variables) & set(ALL_ATMOSPHERIC_VARS))
299
+ num_outputs = (num_surface_vars +
300
+ len(task_config.pressure_levels) * num_atmospheric_vars)
301
+
302
+ # Decoder, which moves data from the mesh back into the grid with a single
303
+ # message passing step.
304
+ self._mesh2grid_gnn = deep_typed_graph_net.DeepTypedGraphNet(
305
+ # Require a specific node dimensionaly for the grid node outputs.
306
+ node_output_size=dict(grid_nodes=num_outputs),
307
+ embed_nodes=False, # Node features already embdded by previous layers.
308
+ embed_edges=True, # Embed raw features of the mesh2grid edges.
309
+ edge_latent_size=dict(mesh2grid=model_config.latent_size),
310
+ node_latent_size=dict(
311
+ mesh_nodes=model_config.latent_size,
312
+ grid_nodes=model_config.latent_size),
313
+ mlp_hidden_size=model_config.latent_size,
314
+ mlp_num_hidden_layers=model_config.hidden_layers,
315
+ num_message_passing_steps=1,
316
+ use_layer_norm=True,
317
+ include_sent_messages_in_node_update=False,
318
+ activation="swish",
319
+ f32_aggregation=False,
320
+ name="mesh2grid_gnn",
321
+ )
322
+
323
+ # Obtain the query radius in absolute units for the unit-sphere for the
324
+ # grid2mesh model, by rescaling the `radius_query_fraction_edge_length`.
325
+ self._query_radius = (_get_max_edge_distance(self._finest_mesh)
326
+ * model_config.radius_query_fraction_edge_length)
327
+ self._mesh2grid_edge_normalization_factor = (
328
+ model_config.mesh2grid_edge_normalization_factor
329
+ )
330
+
331
+ # Other initialization is delayed until the first call (`_maybe_init`)
332
+ # when we get some sample data so we know the lat/lon values.
333
+ self._initialized = False
334
+
335
+ # A "_init_mesh_properties":
336
+ # This one could be initialized at init but we delay it for consistency too.
337
+ self._num_mesh_nodes = None # num_mesh_nodes
338
+ self._mesh_nodes_lat = None # [num_mesh_nodes]
339
+ self._mesh_nodes_lon = None # [num_mesh_nodes]
340
+
341
+ # A "_init_grid_properties":
342
+ self._grid_lat = None # [num_lat_points]
343
+ self._grid_lon = None # [num_lon_points]
344
+ self._num_grid_nodes = None # num_lat_points * num_lon_points
345
+ self._grid_nodes_lat = None # [num_grid_nodes]
346
+ self._grid_nodes_lon = None # [num_grid_nodes]
347
+
348
+ # A "_init_{grid2mesh,processor,mesh2grid}_graph"
349
+ self._grid2mesh_graph_structure = None
350
+ self._mesh_graph_structure = None
351
+ self._mesh2grid_graph_structure = None
352
+
353
+ @property
354
+ def _finest_mesh(self):
355
+ return self._meshes[-1]
356
+
357
+ def __call__(self,
358
+ inputs: xarray.Dataset,
359
+ targets_template: xarray.Dataset,
360
+ forcings: xarray.Dataset,
361
+ is_training: bool = False,
362
+ ) -> xarray.Dataset:
363
+ self._maybe_init(inputs)
364
+
365
+ # Convert all input data into flat vectors for each of the grid nodes.
366
+ # xarray (batch, time, lat, lon, level, multiple vars, forcings)
367
+ # -> [num_grid_nodes, batch, num_channels]
368
+ grid_node_features = self._inputs_to_grid_node_features(inputs, forcings)
369
+
370
+ # Transfer data for the grid to the mesh,
371
+ # [num_mesh_nodes, batch, latent_size], [num_grid_nodes, batch, latent_size]
372
+ (latent_mesh_nodes, latent_grid_nodes
373
+ ) = self._run_grid2mesh_gnn(grid_node_features)
374
+
375
+ # Run message passing in the multimesh.
376
+ # [num_mesh_nodes, batch, latent_size]
377
+ updated_latent_mesh_nodes = self._run_mesh_gnn(latent_mesh_nodes)
378
+
379
+ # Transfer data frome the mesh to the grid.
380
+ # [num_grid_nodes, batch, output_size]
381
+ output_grid_nodes = self._run_mesh2grid_gnn(
382
+ updated_latent_mesh_nodes, latent_grid_nodes)
383
+
384
+ # Conver output flat vectors for the grid nodes to the format of the output.
385
+ # [num_grid_nodes, batch, output_size] ->
386
+ # xarray (batch, one time step, lat, lon, level, multiple vars)
387
+ return self._grid_node_outputs_to_prediction(
388
+ output_grid_nodes, targets_template)
389
+
390
+ def loss_and_predictions( # pytype: disable=signature-mismatch # jax-ndarray
391
+ self,
392
+ inputs: xarray.Dataset,
393
+ targets: xarray.Dataset,
394
+ forcings: xarray.Dataset,
395
+ ) -> tuple[predictor_base.LossAndDiagnostics, xarray.Dataset]:
396
+ # Forward pass.
397
+ predictions = self(
398
+ inputs, targets_template=targets, forcings=forcings, is_training=True)
399
+ # Compute loss.
400
+ loss = losses.weighted_mse_per_level(
401
+ predictions, targets,
402
+ per_variable_weights={
403
+ # Any variables not specified here are weighted as 1.0.
404
+ # A single-level variable, but an important headline variable
405
+ # and also one which we have struggled to get good performance
406
+ # on at short lead times, so leaving it weighted at 1.0, equal
407
+ # to the multi-level variables:
408
+ "2m_temperature": 1.0,
409
+ # New single-level variables, which we don't weight too highly
410
+ # to avoid hurting performance on other variables.
411
+ "10m_u_component_of_wind": 0.1,
412
+ "10m_v_component_of_wind": 0.1,
413
+ "mean_sea_level_pressure": 0.1,
414
+ "total_precipitation_6hr": 0.1,
415
+ })
416
+ return loss, predictions # pytype: disable=bad-return-type # jax-ndarray
417
+
418
+ def loss( # pytype: disable=signature-mismatch # jax-ndarray
419
+ self,
420
+ inputs: xarray.Dataset,
421
+ targets: xarray.Dataset,
422
+ forcings: xarray.Dataset,
423
+ ) -> predictor_base.LossAndDiagnostics:
424
+ loss, _ = self.loss_and_predictions(inputs, targets, forcings)
425
+ return loss # pytype: disable=bad-return-type # jax-ndarray
426
+
427
+ def _maybe_init(self, sample_inputs: xarray.Dataset):
428
+ """Inits everything that has a dependency on the input coordinates."""
429
+ if not self._initialized:
430
+ self._init_mesh_properties()
431
+ self._init_grid_properties(
432
+ grid_lat=sample_inputs.lat, grid_lon=sample_inputs.lon)
433
+ self._grid2mesh_graph_structure = self._init_grid2mesh_graph()
434
+ self._mesh_graph_structure = self._init_mesh_graph()
435
+ self._mesh2grid_graph_structure = self._init_mesh2grid_graph()
436
+
437
+ self._initialized = True
438
+
439
+ def _init_mesh_properties(self):
440
+ """Inits static properties that have to do with mesh nodes."""
441
+ self._num_mesh_nodes = self._finest_mesh.vertices.shape[0]
442
+ mesh_phi, mesh_theta = model_utils.cartesian_to_spherical(
443
+ self._finest_mesh.vertices[:, 0],
444
+ self._finest_mesh.vertices[:, 1],
445
+ self._finest_mesh.vertices[:, 2])
446
+ (
447
+ mesh_nodes_lat,
448
+ mesh_nodes_lon,
449
+ ) = model_utils.spherical_to_lat_lon(
450
+ phi=mesh_phi, theta=mesh_theta)
451
+ # Convert to f32 to ensure the lat/lon features aren't in f64.
452
+ self._mesh_nodes_lat = mesh_nodes_lat.astype(np.float32)
453
+ self._mesh_nodes_lon = mesh_nodes_lon.astype(np.float32)
454
+
455
+ def _init_grid_properties(self, grid_lat: np.ndarray, grid_lon: np.ndarray):
456
+ """Inits static properties that have to do with grid nodes."""
457
+ self._grid_lat = grid_lat.astype(np.float32)
458
+ self._grid_lon = grid_lon.astype(np.float32)
459
+ # Initialized the counters.
460
+ self._num_grid_nodes = grid_lat.shape[0] * grid_lon.shape[0]
461
+
462
+ # Initialize lat and lon for the grid.
463
+ grid_nodes_lon, grid_nodes_lat = np.meshgrid(grid_lon, grid_lat)
464
+ self._grid_nodes_lon = grid_nodes_lon.reshape([-1]).astype(np.float32)
465
+ self._grid_nodes_lat = grid_nodes_lat.reshape([-1]).astype(np.float32)
466
+
467
+ def _init_grid2mesh_graph(self) -> typed_graph.TypedGraph:
468
+ """Build Grid2Mesh graph."""
469
+
470
+ # Create some edges according to distance between mesh and grid nodes.
471
+ assert self._grid_lat is not None and self._grid_lon is not None
472
+ (grid_indices, mesh_indices) = grid_mesh_connectivity.radius_query_indices(
473
+ grid_latitude=self._grid_lat,
474
+ grid_longitude=self._grid_lon,
475
+ mesh=self._finest_mesh,
476
+ radius=self._query_radius)
477
+
478
+ # Edges sending info from grid to mesh.
479
+ senders = grid_indices
480
+ receivers = mesh_indices
481
+
482
+ # Precompute structural node and edge features according to config options.
483
+ # Structural features are those that depend on the fixed values of the
484
+ # latitude and longitudes of the nodes.
485
+ (senders_node_features, receivers_node_features,
486
+ edge_features) = model_utils.get_bipartite_graph_spatial_features(
487
+ senders_node_lat=self._grid_nodes_lat,
488
+ senders_node_lon=self._grid_nodes_lon,
489
+ receivers_node_lat=self._mesh_nodes_lat,
490
+ receivers_node_lon=self._mesh_nodes_lon,
491
+ senders=senders,
492
+ receivers=receivers,
493
+ edge_normalization_factor=None,
494
+ **self._spatial_features_kwargs,
495
+ )
496
+
497
+ n_grid_node = np.array([self._num_grid_nodes])
498
+ n_mesh_node = np.array([self._num_mesh_nodes])
499
+ n_edge = np.array([mesh_indices.shape[0]])
500
+ grid_node_set = typed_graph.NodeSet(
501
+ n_node=n_grid_node, features=senders_node_features)
502
+ mesh_node_set = typed_graph.NodeSet(
503
+ n_node=n_mesh_node, features=receivers_node_features)
504
+ edge_set = typed_graph.EdgeSet(
505
+ n_edge=n_edge,
506
+ indices=typed_graph.EdgesIndices(senders=senders, receivers=receivers),
507
+ features=edge_features)
508
+ nodes = {"grid_nodes": grid_node_set, "mesh_nodes": mesh_node_set}
509
+ edges = {
510
+ typed_graph.EdgeSetKey("grid2mesh", ("grid_nodes", "mesh_nodes")):
511
+ edge_set
512
+ }
513
+ grid2mesh_graph = typed_graph.TypedGraph(
514
+ context=typed_graph.Context(n_graph=np.array([1]), features=()),
515
+ nodes=nodes,
516
+ edges=edges)
517
+ return grid2mesh_graph
518
+
519
+ def _init_mesh_graph(self) -> typed_graph.TypedGraph:
520
+ """Build Mesh graph."""
521
+ merged_mesh = icosahedral_mesh.merge_meshes(self._meshes)
522
+
523
+ # Work simply on the mesh edges.
524
+ senders, receivers = icosahedral_mesh.faces_to_edges(merged_mesh.faces)
525
+
526
+ # Precompute structural node and edge features according to config options.
527
+ # Structural features are those that depend on the fixed values of the
528
+ # latitude and longitudes of the nodes.
529
+ assert self._mesh_nodes_lat is not None and self._mesh_nodes_lon is not None
530
+ node_features, edge_features = model_utils.get_graph_spatial_features(
531
+ node_lat=self._mesh_nodes_lat,
532
+ node_lon=self._mesh_nodes_lon,
533
+ senders=senders,
534
+ receivers=receivers,
535
+ **self._spatial_features_kwargs,
536
+ )
537
+
538
+ n_mesh_node = np.array([self._num_mesh_nodes])
539
+ n_edge = np.array([senders.shape[0]])
540
+ assert n_mesh_node == len(node_features)
541
+ mesh_node_set = typed_graph.NodeSet(
542
+ n_node=n_mesh_node, features=node_features)
543
+ edge_set = typed_graph.EdgeSet(
544
+ n_edge=n_edge,
545
+ indices=typed_graph.EdgesIndices(senders=senders, receivers=receivers),
546
+ features=edge_features)
547
+ nodes = {"mesh_nodes": mesh_node_set}
548
+ edges = {
549
+ typed_graph.EdgeSetKey("mesh", ("mesh_nodes", "mesh_nodes")): edge_set
550
+ }
551
+ mesh_graph = typed_graph.TypedGraph(
552
+ context=typed_graph.Context(n_graph=np.array([1]), features=()),
553
+ nodes=nodes,
554
+ edges=edges)
555
+
556
+ return mesh_graph
557
+
558
+ def _init_mesh2grid_graph(self) -> typed_graph.TypedGraph:
559
+ """Build Mesh2Grid graph."""
560
+
561
+ # Create some edges according to how the grid nodes are contained by
562
+ # mesh triangles.
563
+ (grid_indices,
564
+ mesh_indices) = grid_mesh_connectivity.in_mesh_triangle_indices(
565
+ grid_latitude=self._grid_lat,
566
+ grid_longitude=self._grid_lon,
567
+ mesh=self._finest_mesh)
568
+
569
+ # Edges sending info from mesh to grid.
570
+ senders = mesh_indices
571
+ receivers = grid_indices
572
+
573
+ # Precompute structural node and edge features according to config options.
574
+ assert self._mesh_nodes_lat is not None and self._mesh_nodes_lon is not None
575
+ (senders_node_features, receivers_node_features,
576
+ edge_features) = model_utils.get_bipartite_graph_spatial_features(
577
+ senders_node_lat=self._mesh_nodes_lat,
578
+ senders_node_lon=self._mesh_nodes_lon,
579
+ receivers_node_lat=self._grid_nodes_lat,
580
+ receivers_node_lon=self._grid_nodes_lon,
581
+ senders=senders,
582
+ receivers=receivers,
583
+ edge_normalization_factor=self._mesh2grid_edge_normalization_factor,
584
+ **self._spatial_features_kwargs,
585
+ )
586
+
587
+ n_grid_node = np.array([self._num_grid_nodes])
588
+ n_mesh_node = np.array([self._num_mesh_nodes])
589
+ n_edge = np.array([senders.shape[0]])
590
+ grid_node_set = typed_graph.NodeSet(
591
+ n_node=n_grid_node, features=receivers_node_features)
592
+ mesh_node_set = typed_graph.NodeSet(
593
+ n_node=n_mesh_node, features=senders_node_features)
594
+ edge_set = typed_graph.EdgeSet(
595
+ n_edge=n_edge,
596
+ indices=typed_graph.EdgesIndices(senders=senders, receivers=receivers),
597
+ features=edge_features)
598
+ nodes = {"grid_nodes": grid_node_set, "mesh_nodes": mesh_node_set}
599
+ edges = {
600
+ typed_graph.EdgeSetKey("mesh2grid", ("mesh_nodes", "grid_nodes")):
601
+ edge_set
602
+ }
603
+ mesh2grid_graph = typed_graph.TypedGraph(
604
+ context=typed_graph.Context(n_graph=np.array([1]), features=()),
605
+ nodes=nodes,
606
+ edges=edges)
607
+ return mesh2grid_graph
608
+
609
+ def _run_grid2mesh_gnn(self, grid_node_features: chex.Array,
610
+ ) -> tuple[chex.Array, chex.Array]:
611
+ """Runs the grid2mesh_gnn, extracting latent mesh and grid nodes."""
612
+
613
+ # Concatenate node structural features with input features.
614
+ batch_size = grid_node_features.shape[1]
615
+
616
+ grid2mesh_graph = self._grid2mesh_graph_structure
617
+ assert grid2mesh_graph is not None
618
+ grid_nodes = grid2mesh_graph.nodes["grid_nodes"]
619
+ mesh_nodes = grid2mesh_graph.nodes["mesh_nodes"]
620
+ new_grid_nodes = grid_nodes._replace(
621
+ features=jnp.concatenate([
622
+ grid_node_features,
623
+ _add_batch_second_axis(
624
+ grid_nodes.features.astype(grid_node_features.dtype),
625
+ batch_size)
626
+ ],
627
+ axis=-1))
628
+
629
+ # To make sure capacity of the embedded is identical for the grid nodes and
630
+ # the mesh nodes, we also append some dummy zero input features for the
631
+ # mesh nodes.
632
+ dummy_mesh_node_features = jnp.zeros(
633
+ (self._num_mesh_nodes,) + grid_node_features.shape[1:],
634
+ dtype=grid_node_features.dtype)
635
+ new_mesh_nodes = mesh_nodes._replace(
636
+ features=jnp.concatenate([
637
+ dummy_mesh_node_features,
638
+ _add_batch_second_axis(
639
+ mesh_nodes.features.astype(dummy_mesh_node_features.dtype),
640
+ batch_size)
641
+ ],
642
+ axis=-1))
643
+
644
+ # Broadcast edge structural features to the required batch size.
645
+ grid2mesh_edges_key = grid2mesh_graph.edge_key_by_name("grid2mesh")
646
+ edges = grid2mesh_graph.edges[grid2mesh_edges_key]
647
+
648
+ new_edges = edges._replace(
649
+ features=_add_batch_second_axis(
650
+ edges.features.astype(dummy_mesh_node_features.dtype), batch_size))
651
+
652
+ input_graph = self._grid2mesh_graph_structure._replace(
653
+ edges={grid2mesh_edges_key: new_edges},
654
+ nodes={
655
+ "grid_nodes": new_grid_nodes,
656
+ "mesh_nodes": new_mesh_nodes
657
+ })
658
+
659
+ # Run the GNN.
660
+ grid2mesh_out = self._grid2mesh_gnn(input_graph)
661
+ latent_mesh_nodes = grid2mesh_out.nodes["mesh_nodes"].features
662
+ latent_grid_nodes = grid2mesh_out.nodes["grid_nodes"].features
663
+ return latent_mesh_nodes, latent_grid_nodes
664
+
665
+ def _run_mesh_gnn(self, latent_mesh_nodes: chex.Array) -> chex.Array:
666
+ """Runs the mesh_gnn, extracting updated latent mesh nodes."""
667
+
668
+ # Add the structural edge features of this graph. Note we don't need
669
+ # to add the structural node features, because these are already part of
670
+ # the latent state, via the original Grid2Mesh gnn, however, we need
671
+ # the edge ones, because it is the first time we are seeing this particular
672
+ # set of edges.
673
+ batch_size = latent_mesh_nodes.shape[1]
674
+
675
+ mesh_graph = self._mesh_graph_structure
676
+ assert mesh_graph is not None
677
+ mesh_edges_key = mesh_graph.edge_key_by_name("mesh")
678
+ edges = mesh_graph.edges[mesh_edges_key]
679
+
680
+ # We are assuming here that the mesh gnn uses a single set of edge keys
681
+ # named "mesh" for the edges and that it uses a single set of nodes named
682
+ # "mesh_nodes"
683
+ msg = ("The setup currently requires to only have one kind of edge in the"
684
+ " mesh GNN.")
685
+ assert len(mesh_graph.edges) == 1, msg
686
+
687
+ new_edges = edges._replace(
688
+ features=_add_batch_second_axis(
689
+ edges.features.astype(latent_mesh_nodes.dtype), batch_size))
690
+
691
+ nodes = mesh_graph.nodes["mesh_nodes"]
692
+ nodes = nodes._replace(features=latent_mesh_nodes)
693
+
694
+ input_graph = mesh_graph._replace(
695
+ edges={mesh_edges_key: new_edges}, nodes={"mesh_nodes": nodes})
696
+
697
+ # Run the GNN.
698
+ return self._mesh_gnn(input_graph).nodes["mesh_nodes"].features
699
+
700
+ def _run_mesh2grid_gnn(self,
701
+ updated_latent_mesh_nodes: chex.Array,
702
+ latent_grid_nodes: chex.Array,
703
+ ) -> chex.Array:
704
+ """Runs the mesh2grid_gnn, extracting the output grid nodes."""
705
+
706
+ # Add the structural edge features of this graph. Note we don't need
707
+ # to add the structural node features, because these are already part of
708
+ # the latent state, via the original Grid2Mesh gnn, however, we need
709
+ # the edge ones, because it is the first time we are seeing this particular
710
+ # set of edges.
711
+ batch_size = updated_latent_mesh_nodes.shape[1]
712
+
713
+ mesh2grid_graph = self._mesh2grid_graph_structure
714
+ assert mesh2grid_graph is not None
715
+ mesh_nodes = mesh2grid_graph.nodes["mesh_nodes"]
716
+ grid_nodes = mesh2grid_graph.nodes["grid_nodes"]
717
+ new_mesh_nodes = mesh_nodes._replace(features=updated_latent_mesh_nodes)
718
+ new_grid_nodes = grid_nodes._replace(features=latent_grid_nodes)
719
+ mesh2grid_key = mesh2grid_graph.edge_key_by_name("mesh2grid")
720
+ edges = mesh2grid_graph.edges[mesh2grid_key]
721
+
722
+ new_edges = edges._replace(
723
+ features=_add_batch_second_axis(
724
+ edges.features.astype(latent_grid_nodes.dtype), batch_size))
725
+
726
+ input_graph = mesh2grid_graph._replace(
727
+ edges={mesh2grid_key: new_edges},
728
+ nodes={
729
+ "mesh_nodes": new_mesh_nodes,
730
+ "grid_nodes": new_grid_nodes
731
+ })
732
+
733
+ # Run the GNN.
734
+ output_graph = self._mesh2grid_gnn(input_graph)
735
+ output_grid_nodes = output_graph.nodes["grid_nodes"].features
736
+
737
+ return output_grid_nodes
738
+
739
+ def _inputs_to_grid_node_features(
740
+ self,
741
+ inputs: xarray.Dataset,
742
+ forcings: xarray.Dataset,
743
+ ) -> chex.Array:
744
+ """xarrays -> [num_grid_nodes, batch, num_channels]."""
745
+
746
+ # xarray `Dataset` (batch, time, lat, lon, level, multiple vars)
747
+ # to xarray `DataArray` (batch, lat, lon, channels)
748
+ stacked_inputs = model_utils.dataset_to_stacked(inputs)
749
+ stacked_forcings = model_utils.dataset_to_stacked(forcings)
750
+ stacked_inputs = xarray.concat(
751
+ [stacked_inputs, stacked_forcings], dim="channels")
752
+
753
+ # xarray `DataArray` (batch, lat, lon, channels)
754
+ # to single numpy array with shape [lat_lon_node, batch, channels]
755
+ grid_xarray_lat_lon_leading = model_utils.lat_lon_to_leading_axes(
756
+ stacked_inputs)
757
+ return xarray_jax.unwrap(grid_xarray_lat_lon_leading.data).reshape(
758
+ (-1,) + grid_xarray_lat_lon_leading.data.shape[2:])
759
+
760
+ def _grid_node_outputs_to_prediction(
761
+ self,
762
+ grid_node_outputs: chex.Array,
763
+ targets_template: xarray.Dataset,
764
+ ) -> xarray.Dataset:
765
+ """[num_grid_nodes, batch, num_outputs] -> xarray."""
766
+
767
+ # numpy array with shape [lat_lon_node, batch, channels]
768
+ # to xarray `DataArray` (batch, lat, lon, channels)
769
+ assert self._grid_lat is not None and self._grid_lon is not None
770
+ grid_shape = (self._grid_lat.shape[0], self._grid_lon.shape[0])
771
+ grid_outputs_lat_lon_leading = grid_node_outputs.reshape(
772
+ grid_shape + grid_node_outputs.shape[1:])
773
+ dims = ("lat", "lon", "batch", "channels")
774
+ grid_xarray_lat_lon_leading = xarray_jax.DataArray(
775
+ data=grid_outputs_lat_lon_leading,
776
+ dims=dims)
777
+ grid_xarray = model_utils.restore_leading_axes(grid_xarray_lat_lon_leading)
778
+
779
+ # xarray `DataArray` (batch, lat, lon, channels)
780
+ # to xarray `Dataset` (batch, one time step, lat, lon, level, multiple vars)
781
+ return model_utils.stacked_to_dataset(
782
+ grid_xarray.variable, targets_template)
783
+
784
+
785
+ def _add_batch_second_axis(data, batch_size):
786
+ # data [leading_dim, trailing_dim]
787
+ assert data.ndim == 2
788
+ ones = jnp.ones([batch_size, 1], dtype=data.dtype)
789
+ return data[:, None] * ones # [leading_dim, batch, trailing_dim]
790
+
791
+
792
+ def _get_max_edge_distance(mesh):
793
+ senders, receivers = icosahedral_mesh.faces_to_edges(mesh.faces)
794
+ edge_distances = np.linalg.norm(
795
+ mesh.vertices[senders] - mesh.vertices[receivers], axis=-1)
796
+ return edge_distances.max()
model/graphcast/grid_mesh_connectivity.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2023 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Tools for converting from regular grids on a sphere, to triangular meshes."""
15
+
16
+ from . import icosahedral_mesh
17
+ import numpy as np
18
+ import scipy
19
+ import trimesh
20
+
21
+
22
+ def _grid_lat_lon_to_coordinates(
23
+ grid_latitude: np.ndarray, grid_longitude: np.ndarray) -> np.ndarray:
24
+ """Lat [num_lat] lon [num_lon] to 3d coordinates [num_lat, num_lon, 3]."""
25
+ # Convert to spherical coordinates phi and theta defined in the grid.
26
+ # Each [num_latitude_points, num_longitude_points]
27
+ phi_grid, theta_grid = np.meshgrid(
28
+ np.deg2rad(grid_longitude),
29
+ np.deg2rad(90 - grid_latitude))
30
+
31
+ # [num_latitude_points, num_longitude_points, 3]
32
+ # Note this assumes unit radius, since for now we model the earth as a
33
+ # sphere of unit radius, and keep any vertical dimension as a regular grid.
34
+ return np.stack(
35
+ [np.cos(phi_grid)*np.sin(theta_grid),
36
+ np.sin(phi_grid)*np.sin(theta_grid),
37
+ np.cos(theta_grid)], axis=-1)
38
+
39
+
40
+ def radius_query_indices(
41
+ *,
42
+ grid_latitude: np.ndarray,
43
+ grid_longitude: np.ndarray,
44
+ mesh: icosahedral_mesh.TriangularMesh,
45
+ radius: float) -> tuple[np.ndarray, np.ndarray]:
46
+ """Returns mesh-grid edge indices for radius query.
47
+
48
+ Args:
49
+ grid_latitude: Latitude values for the grid [num_lat_points]
50
+ grid_longitude: Longitude values for the grid [num_lon_points]
51
+ mesh: Mesh object.
52
+ radius: Radius of connectivity in R3. for a sphere of unit radius.
53
+
54
+ Returns:
55
+ tuple with `grid_indices` and `mesh_indices` indicating edges between the
56
+ grid and the mesh such that the distances in a straight line (not geodesic)
57
+ are smaller than or equal to `radius`.
58
+ * grid_indices: Indices of shape [num_edges], that index into a
59
+ [num_lat_points, num_lon_points] grid, after flattening the leading axes.
60
+ * mesh_indices: Indices of shape [num_edges], that index into mesh.vertices.
61
+ """
62
+
63
+ # [num_grid_points=num_lat_points * num_lon_points, 3]
64
+ grid_positions = _grid_lat_lon_to_coordinates(
65
+ grid_latitude, grid_longitude).reshape([-1, 3])
66
+
67
+ # [num_mesh_points, 3]
68
+ mesh_positions = mesh.vertices
69
+ kd_tree = scipy.spatial.cKDTree(mesh_positions)
70
+
71
+ # [num_grid_points, num_mesh_points_per_grid_point]
72
+ # Note `num_mesh_points_per_grid_point` is not constant, so this is a list
73
+ # of arrays, rather than a 2d array.
74
+ query_indices = kd_tree.query_ball_point(x=grid_positions, r=radius)
75
+
76
+ grid_edge_indices = []
77
+ mesh_edge_indices = []
78
+ for grid_index, mesh_neighbors in enumerate(query_indices):
79
+ grid_edge_indices.append(np.repeat(grid_index, len(mesh_neighbors)))
80
+ mesh_edge_indices.append(mesh_neighbors)
81
+
82
+ # [num_edges]
83
+ grid_edge_indices = np.concatenate(grid_edge_indices, axis=0).astype(int)
84
+ mesh_edge_indices = np.concatenate(mesh_edge_indices, axis=0).astype(int)
85
+
86
+ return grid_edge_indices, mesh_edge_indices
87
+
88
+
89
+ def in_mesh_triangle_indices(
90
+ *,
91
+ grid_latitude: np.ndarray,
92
+ grid_longitude: np.ndarray,
93
+ mesh: icosahedral_mesh.TriangularMesh) -> tuple[np.ndarray, np.ndarray]:
94
+ """Returns mesh-grid edge indices for grid points contained in mesh triangles.
95
+
96
+ Args:
97
+ grid_latitude: Latitude values for the grid [num_lat_points]
98
+ grid_longitude: Longitude values for the grid [num_lon_points]
99
+ mesh: Mesh object.
100
+
101
+ Returns:
102
+ tuple with `grid_indices` and `mesh_indices` indicating edges between the
103
+ grid and the mesh vertices of the triangle that contain each grid point.
104
+ The number of edges is always num_lat_points * num_lon_points * 3
105
+ * grid_indices: Indices of shape [num_edges], that index into a
106
+ [num_lat_points, num_lon_points] grid, after flattening the leading axes.
107
+ * mesh_indices: Indices of shape [num_edges], that index into mesh.vertices.
108
+ """
109
+
110
+ # [num_grid_points=num_lat_points * num_lon_points, 3]
111
+ grid_positions = _grid_lat_lon_to_coordinates(
112
+ grid_latitude, grid_longitude).reshape([-1, 3])
113
+
114
+ mesh_trimesh = trimesh.Trimesh(vertices=mesh.vertices, faces=mesh.faces)
115
+
116
+ # [num_grid_points] with mesh face indices for each grid point.
117
+ _, _, query_face_indices = trimesh.proximity.closest_point(
118
+ mesh_trimesh, grid_positions)
119
+
120
+ # [num_grid_points, 3] with mesh node indices for each grid point.
121
+ mesh_edge_indices = mesh.faces[query_face_indices]
122
+
123
+ # [num_grid_points, 3] with grid node indices, where every row simply contains
124
+ # the row (grid_point) index.
125
+ grid_indices = np.arange(grid_positions.shape[0])
126
+ grid_edge_indices = np.tile(grid_indices.reshape([-1, 1]), [1, 3])
127
+
128
+ # Flatten to get a regular list.
129
+ # [num_edges=num_grid_points*3]
130
+ mesh_edge_indices = mesh_edge_indices.reshape([-1])
131
+ grid_edge_indices = grid_edge_indices.reshape([-1])
132
+
133
+ return grid_edge_indices, mesh_edge_indices
model/graphcast/icosahedral_mesh.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2023 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Utils for creating icosahedral meshes."""
15
+
16
+ import itertools
17
+ from typing import List, NamedTuple, Sequence, Tuple
18
+
19
+ import numpy as np
20
+ from scipy.spatial import transform
21
+
22
+
23
+ class TriangularMesh(NamedTuple):
24
+ """Data structure for triangular meshes.
25
+
26
+ Attributes:
27
+ vertices: spatial positions of the vertices of the mesh of shape
28
+ [num_vertices, num_dims].
29
+ faces: triangular faces of the mesh of shape [num_faces, 3]. Contains
30
+ integer indices into `vertices`.
31
+
32
+ """
33
+ vertices: np.ndarray
34
+ faces: np.ndarray
35
+
36
+
37
+ def merge_meshes(
38
+ mesh_list: Sequence[TriangularMesh]) -> TriangularMesh:
39
+ """Merges all meshes into one. Assumes the last mesh is the finest.
40
+
41
+ Args:
42
+ mesh_list: Sequence of meshes, from coarse to fine refinement levels. The
43
+ vertices and faces may contain those from preceding, coarser levels.
44
+
45
+ Returns:
46
+ `TriangularMesh` for which the vertices correspond to the highest
47
+ resolution mesh in the hierarchy, and the faces are the join set of the
48
+ faces at all levels of the hierarchy.
49
+ """
50
+ for mesh_i, mesh_ip1 in itertools.pairwise(mesh_list):
51
+ num_nodes_mesh_i = mesh_i.vertices.shape[0]
52
+ assert np.allclose(mesh_i.vertices, mesh_ip1.vertices[:num_nodes_mesh_i])
53
+
54
+ return TriangularMesh(
55
+ vertices=mesh_list[-1].vertices,
56
+ faces=np.concatenate([mesh.faces for mesh in mesh_list], axis=0))
57
+
58
+
59
+ def get_hierarchy_of_triangular_meshes_for_sphere(
60
+ splits: int) -> List[TriangularMesh]:
61
+ """Returns a sequence of meshes, each with triangularization sphere.
62
+
63
+ Starting with a regular icosahedron (12 vertices, 20 faces, 30 edges) with
64
+ circumscribed unit sphere. Then, each triangular face is iteratively
65
+ subdivided into 4 triangular faces `splits` times. The new vertices are then
66
+ projected back onto the unit sphere. All resulting meshes are returned in a
67
+ list, from lowest to highest resolution.
68
+
69
+ The vertices in each face are specified in counter-clockwise order as
70
+ observed from the outside the icosahedron.
71
+
72
+ Args:
73
+ splits: How many times to split each triangle.
74
+ Returns:
75
+ Sequence of `TriangularMesh`s of length `splits + 1` each with:
76
+
77
+ vertices: [num_vertices, 3] vertex positions in 3D, all with unit norm.
78
+ faces: [num_faces, 3] with triangular faces joining sets of 3 vertices.
79
+ Each row contains three indices into the vertices array, indicating
80
+ the vertices adjacent to the face. Always with positive orientation
81
+ (counterclock-wise when looking from the outside).
82
+ """
83
+ current_mesh = get_icosahedron()
84
+ output_meshes = [current_mesh]
85
+ for _ in range(splits):
86
+ current_mesh = _two_split_unit_sphere_triangle_faces(current_mesh)
87
+ output_meshes.append(current_mesh)
88
+ return output_meshes
89
+
90
+
91
+ def get_icosahedron() -> TriangularMesh:
92
+ """Returns a regular icosahedral mesh with circumscribed unit sphere.
93
+
94
+ See https://en.wikipedia.org/wiki/Regular_icosahedron#Cartesian_coordinates
95
+ for details on the construction of the regular icosahedron.
96
+
97
+ The vertices in each face are specified in counter-clockwise order as observed
98
+ from the outside of the icosahedron.
99
+
100
+ Returns:
101
+ TriangularMesh with:
102
+
103
+ vertices: [num_vertices=12, 3] vertex positions in 3D, all with unit norm.
104
+ faces: [num_faces=20, 3] with triangular faces joining sets of 3 vertices.
105
+ Each row contains three indices into the vertices array, indicating
106
+ the vertices adjacent to the face. Always with positive orientation (
107
+ counterclock-wise when looking from the outside).
108
+
109
+ """
110
+ phi = (1 + np.sqrt(5)) / 2
111
+ vertices = []
112
+ for c1 in [1., -1.]:
113
+ for c2 in [phi, -phi]:
114
+ vertices.append((c1, c2, 0.))
115
+ vertices.append((0., c1, c2))
116
+ vertices.append((c2, 0., c1))
117
+
118
+ vertices = np.array(vertices, dtype=np.float32)
119
+ vertices /= np.linalg.norm([1., phi])
120
+
121
+ # I did this manually, checking the orientation one by one.
122
+ faces = [(0, 1, 2),
123
+ (0, 6, 1),
124
+ (8, 0, 2),
125
+ (8, 4, 0),
126
+ (3, 8, 2),
127
+ (3, 2, 7),
128
+ (7, 2, 1),
129
+ (0, 4, 6),
130
+ (4, 11, 6),
131
+ (6, 11, 5),
132
+ (1, 5, 7),
133
+ (4, 10, 11),
134
+ (4, 8, 10),
135
+ (10, 8, 3),
136
+ (10, 3, 9),
137
+ (11, 10, 9),
138
+ (11, 9, 5),
139
+ (5, 9, 7),
140
+ (9, 3, 7),
141
+ (1, 6, 5),
142
+ ]
143
+
144
+ # By default the top is an aris parallel to the Y axis.
145
+ # Need to rotate around the y axis by half the supplementary to the
146
+ # angle between faces divided by two to get the desired orientation.
147
+ # /O\ (top arist)
148
+ # / \ Z
149
+ # (adjacent face)/ \ (adjacent face) ^
150
+ # / angle_between_faces \ |
151
+ # / \ |
152
+ # / \ YO-----> X
153
+ # This results in:
154
+ # (adjacent faceis now top plane)
155
+ # ----------------------O\ (top arist)
156
+ # \
157
+ # \
158
+ # \ (adjacent face)
159
+ # \
160
+ # \
161
+ # \
162
+
163
+ angle_between_faces = 2 * np.arcsin(phi / np.sqrt(3))
164
+ rotation_angle = (np.pi - angle_between_faces) / 2
165
+ rotation = transform.Rotation.from_euler(seq="y", angles=rotation_angle)
166
+ rotation_matrix = rotation.as_matrix()
167
+ vertices = np.dot(vertices, rotation_matrix)
168
+
169
+ return TriangularMesh(vertices=vertices.astype(np.float32),
170
+ faces=np.array(faces, dtype=np.int32))
171
+
172
+
173
+ def _two_split_unit_sphere_triangle_faces(
174
+ triangular_mesh: TriangularMesh) -> TriangularMesh:
175
+ """Splits each triangular face into 4 triangles keeping the orientation."""
176
+
177
+ # Every time we split a triangle into 4 we will be adding 3 extra vertices,
178
+ # located at the edge centres.
179
+ # This class handles the positioning of the new vertices, and avoids creating
180
+ # duplicates.
181
+ new_vertices_builder = _ChildVerticesBuilder(triangular_mesh.vertices)
182
+
183
+ new_faces = []
184
+ for ind1, ind2, ind3 in triangular_mesh.faces:
185
+ # Transform each triangular face into 4 triangles,
186
+ # preserving the orientation.
187
+ # ind3
188
+ # / \
189
+ # / \
190
+ # / #3 \
191
+ # / \
192
+ # ind31 -------------- ind23
193
+ # / \ / \
194
+ # / \ #4 / \
195
+ # / #1 \ / #2 \
196
+ # / \ / \
197
+ # ind1 ------------ ind12 ------------ ind2
198
+ ind12 = new_vertices_builder.get_new_child_vertex_index((ind1, ind2))
199
+ ind23 = new_vertices_builder.get_new_child_vertex_index((ind2, ind3))
200
+ ind31 = new_vertices_builder.get_new_child_vertex_index((ind3, ind1))
201
+ # Note how each of the 4 triangular new faces specifies the order of the
202
+ # vertices to preserve the orientation of the original face. As the input
203
+ # face should always be counter-clockwise as specified in the diagram,
204
+ # this means child faces should also be counter-clockwise.
205
+ new_faces.extend([[ind1, ind12, ind31], # 1
206
+ [ind12, ind2, ind23], # 2
207
+ [ind31, ind23, ind3], # 3
208
+ [ind12, ind23, ind31], # 4
209
+ ])
210
+ return TriangularMesh(vertices=new_vertices_builder.get_all_vertices(),
211
+ faces=np.array(new_faces, dtype=np.int32))
212
+
213
+
214
+ class _ChildVerticesBuilder(object):
215
+ """Bookkeeping of new child vertices added to an existing set of vertices."""
216
+
217
+ def __init__(self, parent_vertices):
218
+
219
+ # Because the same new vertex will be required when splitting adjacent
220
+ # triangles (which share an edge) we keep them in a hash table indexed by
221
+ # sorted indices of the vertices adjacent to the edge, to avoid creating
222
+ # duplicated child vertices.
223
+ self._child_vertices_index_mapping = {}
224
+ self._parent_vertices = parent_vertices
225
+ # We start with all previous vertices.
226
+ self._all_vertices_list = list(parent_vertices)
227
+
228
+ def _get_child_vertex_key(self, parent_vertex_indices):
229
+ return tuple(sorted(parent_vertex_indices))
230
+
231
+ def _create_child_vertex(self, parent_vertex_indices):
232
+ """Creates a new vertex."""
233
+ # Position for new vertex is the middle point, between the parent points,
234
+ # projected to unit sphere.
235
+ child_vertex_position = self._parent_vertices[
236
+ list(parent_vertex_indices)].mean(0)
237
+ child_vertex_position /= np.linalg.norm(child_vertex_position)
238
+
239
+ # Add the vertex to the output list. The index for this new vertex will
240
+ # match the length of the list before adding it.
241
+ child_vertex_key = self._get_child_vertex_key(parent_vertex_indices)
242
+ self._child_vertices_index_mapping[child_vertex_key] = len(
243
+ self._all_vertices_list)
244
+ self._all_vertices_list.append(child_vertex_position)
245
+
246
+ def get_new_child_vertex_index(self, parent_vertex_indices):
247
+ """Returns index for a child vertex, creating it if necessary."""
248
+ # Get the key to see if we already have a new vertex in the middle.
249
+ child_vertex_key = self._get_child_vertex_key(parent_vertex_indices)
250
+ if child_vertex_key not in self._child_vertices_index_mapping:
251
+ self._create_child_vertex(parent_vertex_indices)
252
+ return self._child_vertices_index_mapping[child_vertex_key]
253
+
254
+ def get_all_vertices(self):
255
+ """Returns an array with old vertices."""
256
+ return np.array(self._all_vertices_list)
257
+
258
+
259
+ def faces_to_edges(faces: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
260
+ """Transforms polygonal faces to sender and receiver indices.
261
+
262
+ It does so by transforming every face into N_i edges. Such if the triangular
263
+ face has indices [0, 1, 2], three edges are added 0->1, 1->2, and 2->0.
264
+
265
+ If all faces have consistent orientation, and the surface represented by the
266
+ faces is closed, then every edge in a polygon with a certain orientation
267
+ is also part of another polygon with the opposite orientation. In this
268
+ situation, the edges returned by the method are always bidirectional.
269
+
270
+ Args:
271
+ faces: Integer array of shape [num_faces, 3]. Contains node indices
272
+ adjacent to each face.
273
+ Returns:
274
+ Tuple with sender/receiver indices, each of shape [num_edges=num_faces*3].
275
+
276
+ """
277
+ assert faces.ndim == 2
278
+ assert faces.shape[-1] == 3
279
+ senders = np.concatenate([faces[:, 0], faces[:, 1], faces[:, 2]])
280
+ receivers = np.concatenate([faces[:, 1], faces[:, 2], faces[:, 0]])
281
+ return senders, receivers
282
+
283
+
284
+ def get_last_triangular_mesh_for_sphere(splits: int) -> TriangularMesh:
285
+ return get_hierarchy_of_triangular_meshes_for_sphere(splits=splits)[-1]
model/graphcast/losses.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2023 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Loss functions (and terms for use in loss functions) used for weather."""
15
+
16
+ from typing import Mapping
17
+
18
+ from . import xarray_tree
19
+ import numpy as np
20
+ from typing_extensions import Protocol
21
+ import xarray
22
+
23
+
24
+ LossAndDiagnostics = tuple[xarray.DataArray, xarray.Dataset]
25
+
26
+
27
+ class LossFunction(Protocol):
28
+ """A loss function.
29
+
30
+ This is a protocol so it's fine to use a plain function which 'quacks like'
31
+ this. This is just to document the interface.
32
+ """
33
+
34
+ def __call__(self,
35
+ predictions: xarray.Dataset,
36
+ targets: xarray.Dataset,
37
+ **optional_kwargs) -> LossAndDiagnostics:
38
+ """Computes a loss function.
39
+
40
+ Args:
41
+ predictions: Dataset of predictions.
42
+ targets: Dataset of targets.
43
+ **optional_kwargs: Implementations may support extra optional kwargs.
44
+
45
+ Returns:
46
+ loss: A DataArray with dimensions ('batch',) containing losses for each
47
+ element of the batch. These will be averaged to give the final
48
+ loss, locally and across replicas.
49
+ diagnostics: Mapping of additional quantities to log by name alongside the
50
+ loss. These will will typically correspond to terms in the loss. They
51
+ should also have dimensions ('batch',) and will be averaged over the
52
+ batch before logging.
53
+ """
54
+
55
+
56
+ def weighted_mse_per_level(
57
+ predictions: xarray.Dataset,
58
+ targets: xarray.Dataset,
59
+ per_variable_weights: Mapping[str, float],
60
+ ) -> LossAndDiagnostics:
61
+ """Latitude- and pressure-level-weighted MSE loss."""
62
+ def loss(prediction, target):
63
+ loss = (prediction - target)**2
64
+ loss *= normalized_latitude_weights(target).astype(loss.dtype)
65
+ if 'level' in target.dims:
66
+ loss *= normalized_level_weights(target).astype(loss.dtype)
67
+ return _mean_preserving_batch(loss)
68
+
69
+ losses = xarray_tree.map_structure(loss, predictions, targets)
70
+ return sum_per_variable_losses(losses, per_variable_weights)
71
+
72
+
73
+ def _mean_preserving_batch(x: xarray.DataArray) -> xarray.DataArray:
74
+ return x.mean([d for d in x.dims if d != 'batch'], skipna=False)
75
+
76
+
77
+ def sum_per_variable_losses(
78
+ per_variable_losses: Mapping[str, xarray.DataArray],
79
+ weights: Mapping[str, float],
80
+ ) -> LossAndDiagnostics:
81
+ """Weighted sum of per-variable losses."""
82
+ if not set(weights.keys()).issubset(set(per_variable_losses.keys())):
83
+ raise ValueError(
84
+ 'Passing a weight that does not correspond to any variable '
85
+ f'{set(weights.keys())-set(per_variable_losses.keys())}')
86
+
87
+ weighted_per_variable_losses = {
88
+ name: loss * weights.get(name, 1)
89
+ for name, loss in per_variable_losses.items()
90
+ }
91
+ total = xarray.concat(
92
+ weighted_per_variable_losses.values(), dim='variable', join='exact').sum(
93
+ 'variable', skipna=False)
94
+ return total, per_variable_losses # pytype: disable=bad-return-type
95
+
96
+
97
+ def normalized_level_weights(data: xarray.DataArray) -> xarray.DataArray:
98
+ """Weights proportional to pressure at each level."""
99
+ level = data.coords['level']
100
+ return level / level.mean(skipna=False)
101
+
102
+
103
+ def normalized_latitude_weights(data: xarray.DataArray) -> xarray.DataArray:
104
+ """Weights based on latitude, roughly proportional to grid cell area.
105
+
106
+ This method supports two use cases only (both for equispaced values):
107
+ * Latitude values such that the closest value to the pole is at latitude
108
+ (90 - d_lat/2), where d_lat is the difference between contiguous latitudes.
109
+ For example: [-89, -87, -85, ..., 85, 87, 89]) (d_lat = 2)
110
+ In this case each point with `lat` value represents a sphere slice between
111
+ `lat - d_lat/2` and `lat + d_lat/2`, and the area of this slice would be
112
+ proportional to:
113
+ `sin(lat + d_lat/2) - sin(lat - d_lat/2) = 2 * sin(d_lat/2) * cos(lat)`, and
114
+ we can simply omit the term `2 * sin(d_lat/2)` which is just a constant
115
+ that cancels during normalization.
116
+ * Latitude values that fall exactly at the poles.
117
+ For example: [-90, -88, -86, ..., 86, 88, 90]) (d_lat = 2)
118
+ In this case each point with `lat` value also represents
119
+ a sphere slice between `lat - d_lat/2` and `lat + d_lat/2`,
120
+ except for the points at the poles, that represent a slice between
121
+ `90 - d_lat/2` and `90` or, `-90` and `-90 + d_lat/2`.
122
+ The areas of the first type of point are still proportional to:
123
+ * sin(lat + d_lat/2) - sin(lat - d_lat/2) = 2 * sin(d_lat/2) * cos(lat)
124
+ but for the points at the poles now is:
125
+ * sin(90) - sin(90 - d_lat/2) = 2 * sin(d_lat/4) ^ 2
126
+ and we will be using these weights, depending on whether we are looking at
127
+ pole cells, or non-pole cells (omitting the common factor of 2 which will be
128
+ absorbed by the normalization).
129
+
130
+ It can be shown via a limit, or simple geometry, that in the small angles
131
+ regime, the proportion of area per pole-point is equal to 1/8th
132
+ the proportion of area covered by each of the nearest non-pole point, and we
133
+ test for this in the test.
134
+
135
+ Args:
136
+ data: `DataArray` with latitude coordinates.
137
+ Returns:
138
+ Unit mean latitude weights.
139
+ """
140
+ latitude = data.coords['lat']
141
+
142
+ if np.any(np.isclose(np.abs(latitude), 90.)):
143
+ weights = _weight_for_latitude_vector_with_poles(latitude)
144
+ else:
145
+ weights = _weight_for_latitude_vector_without_poles(latitude)
146
+
147
+ return weights / weights.mean(skipna=False)
148
+
149
+
150
+ def _weight_for_latitude_vector_without_poles(latitude):
151
+ """Weights for uniform latitudes of the form [+-90-+d/2, ..., -+90+-d/2]."""
152
+ delta_latitude = np.abs(_check_uniform_spacing_and_get_delta(latitude))
153
+ if (not np.isclose(np.max(latitude), 90 - delta_latitude/2) or
154
+ not np.isclose(np.min(latitude), -90 + delta_latitude/2)):
155
+ raise ValueError(
156
+ f'Latitude vector {latitude} does not start/end at '
157
+ '+- (90 - delta_latitude/2) degrees.')
158
+ return np.cos(np.deg2rad(latitude))
159
+
160
+
161
+ def _weight_for_latitude_vector_with_poles(latitude):
162
+ """Weights for uniform latitudes of the form [+- 90, ..., -+90]."""
163
+ delta_latitude = np.abs(_check_uniform_spacing_and_get_delta(latitude))
164
+ if (not np.isclose(np.max(latitude), 90.) or
165
+ not np.isclose(np.min(latitude), -90.)):
166
+ raise ValueError(
167
+ f'Latitude vector {latitude} does not start/end at +- 90 degrees.')
168
+ weights = np.cos(np.deg2rad(latitude)) * np.sin(np.deg2rad(delta_latitude/2))
169
+ # The two checks above enough to guarantee that latitudes are sorted, so
170
+ # the extremes are the poles
171
+ weights[[0, -1]] = np.sin(np.deg2rad(delta_latitude/4)) ** 2
172
+ return weights
173
+
174
+
175
+ def _check_uniform_spacing_and_get_delta(vector):
176
+ diff = np.diff(vector)
177
+ if not np.all(np.isclose(diff[0], diff)):
178
+ raise ValueError(f'Vector {diff} is not uniformly spaced.')
179
+ return diff[0]
model/graphcast/mlp.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2024 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Constructors for MLPs."""
15
+
16
+ import haiku as hk
17
+ import jax
18
+ import jax.numpy as jnp
19
+
20
+
21
+ # TODO(aelkadi): Move the mlp factory here from `deep_typed_graph_net.py`.
22
+
23
+
24
+ class LinearNormConditioning(hk.Module):
25
+ """Module for norm conditioning.
26
+
27
+ Conditions the normalization of "inputs" by applying a linear layer to the
28
+ "norm_conditioning" which produces the scale and variance which are applied to
29
+ each channel (across the last dim) of "inputs".
30
+ """
31
+
32
+ def __init__(self, name="norm_conditioning"):
33
+ super().__init__(name=name)
34
+
35
+ def __call__(self, inputs: jax.Array, norm_conditioning: jax.Array):
36
+
37
+ feature_size = inputs.shape[-1]
38
+ conditional_linear_layer = hk.Linear(
39
+ output_size=2 * feature_size,
40
+ w_init=hk.initializers.TruncatedNormal(stddev=1e-8),
41
+ )
42
+ conditional_scale_offset = conditional_linear_layer(norm_conditioning)
43
+ scale_minus_one, offset = jnp.split(conditional_scale_offset, 2, axis=-1)
44
+ scale = scale_minus_one + 1.
45
+ return inputs * scale + offset
model/graphcast/model_utils.py ADDED
@@ -0,0 +1,807 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2023 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Utilities for building models."""
15
+
16
+ from typing import Any, Mapping, Optional, Tuple
17
+
18
+ import jax.numpy as jnp
19
+ import numpy as np
20
+ from scipy.spatial import transform
21
+ import xarray
22
+
23
+ NumpyInterface = Any
24
+ TransformInterface = Any
25
+
26
+
27
+ def get_graph_spatial_features(
28
+ *, node_lat: np.ndarray, node_lon: np.ndarray,
29
+ senders: np.ndarray, receivers: np.ndarray,
30
+ add_node_positions: bool,
31
+ add_node_latitude: bool,
32
+ add_node_longitude: bool,
33
+ add_relative_positions: bool,
34
+ edge_normalization_factor: Optional[float] = None,
35
+ relative_longitude_local_coordinates: bool,
36
+ relative_latitude_local_coordinates: bool,
37
+ sine_cosine_encoding: bool = False,
38
+ encoding_num_freqs: int = 10,
39
+ encoding_multiplicative_factor: float = 1.2,
40
+ ) -> Tuple[np.ndarray, np.ndarray]:
41
+ """Computes spatial features for the nodes.
42
+
43
+ Args:
44
+ node_lat: Latitudes in the [-90, 90] interval of shape [num_nodes]
45
+ node_lon: Longitudes in the [0, 360] interval of shape [num_nodes]
46
+ senders: Sender indices of shape [num_edges]
47
+ receivers: Receiver indices of shape [num_edges]
48
+ add_node_positions: Add unit norm absolute positions.
49
+ add_node_latitude: Add a feature for latitude (cos(90 - lat))
50
+ Note even if this is set to False, the model may be able to infer the
51
+ longitude from relative features, unless
52
+ `relative_latitude_local_coordinates` is also True, or if there is any
53
+ bias on the relative edge sizes for different longitudes.
54
+ add_node_longitude: Add features for longitude (cos(lon), sin(lon)).
55
+ Note even if this is set to False, the model may be able to infer the
56
+ longitude from relative features, unless
57
+ `relative_longitude_local_coordinates` is also True, or if there is any
58
+ bias on the relative edge sizes for different longitudes.
59
+ add_relative_positions: Whether to relative positions in R3 to the edges.
60
+ edge_normalization_factor: Allows explicitly controlling edge normalization.
61
+ If None, defaults to max edge length. This supports using pre-trained
62
+ model weights with a different graph structure to what it was trained.
63
+ relative_longitude_local_coordinates: If True, relative positions are
64
+ computed in a local space where the receiver is at 0 longitude.
65
+ relative_latitude_local_coordinates: If True, relative positions are
66
+ computed in a local space where the receiver is at 0 latitude.
67
+ sine_cosine_encoding: If True, we will transform the node/edge features
68
+ with sine and cosine functions, similar to NERF.
69
+ encoding_num_freqs: frequency parameter
70
+ encoding_multiplicative_factor: used for calculating the frequency.
71
+
72
+ Returns:
73
+ Arrays of shape: [num_nodes, num_features] and [num_edges, num_features].
74
+ with node and edge features.
75
+
76
+ """
77
+
78
+ num_nodes = node_lat.shape[0]
79
+ num_edges = senders.shape[0]
80
+ dtype = node_lat.dtype
81
+ node_phi, node_theta = lat_lon_deg_to_spherical(node_lat, node_lon)
82
+
83
+ # Computing some node features.
84
+ node_features = []
85
+ if add_node_positions:
86
+ # Already in [-1, 1.] range.
87
+ node_features.extend(spherical_to_cartesian(node_phi, node_theta))
88
+
89
+ if add_node_latitude:
90
+ # Using the cos of theta.
91
+ # From 1. (north pole) to -1 (south pole).
92
+ node_features.append(np.cos(node_theta))
93
+
94
+ if add_node_longitude:
95
+ # Using the cos and sin, which is already normalized.
96
+ node_features.append(np.cos(node_phi))
97
+ node_features.append(np.sin(node_phi))
98
+
99
+ if not node_features:
100
+ node_features = np.zeros([num_nodes, 0], dtype=dtype)
101
+ else:
102
+ node_features = np.stack(node_features, axis=-1)
103
+
104
+ # Computing some edge features.
105
+ edge_features = []
106
+
107
+ if add_relative_positions:
108
+
109
+ relative_position = get_relative_position_in_receiver_local_coordinates(
110
+ node_phi=node_phi,
111
+ node_theta=node_theta,
112
+ senders=senders,
113
+ receivers=receivers,
114
+ latitude_local_coordinates=relative_latitude_local_coordinates,
115
+ longitude_local_coordinates=relative_longitude_local_coordinates
116
+ )
117
+
118
+ # Note this is L2 distance in 3d space, rather than geodesic distance.
119
+ relative_edge_distances = np.linalg.norm(
120
+ relative_position, axis=-1, keepdims=True)
121
+
122
+ if edge_normalization_factor is None:
123
+ # Normalize to the maximum edge distance. Note that we expect to always
124
+ # have an edge that goes in the opposite direction of any given edge
125
+ # so the distribution of relative positions should be symmetric around
126
+ # zero. So by scaling by the maximum length, we expect all relative
127
+ # positions to fall in the [-1., 1.] interval, and all relative distances
128
+ # to fall in the [0., 1.] interval.
129
+ edge_normalization_factor = relative_edge_distances.max()
130
+ edge_features.append(relative_edge_distances / edge_normalization_factor)
131
+ edge_features.append(relative_position / edge_normalization_factor)
132
+
133
+ if not edge_features:
134
+ edge_features = np.zeros([num_edges, 0], dtype=dtype)
135
+ else:
136
+ edge_features = np.concatenate(edge_features, axis=-1)
137
+
138
+ if sine_cosine_encoding:
139
+ def sine_cosine_transform(x: np.ndarray) -> np.ndarray:
140
+ freqs = encoding_multiplicative_factor**np.arange(encoding_num_freqs)
141
+ phases = freqs * x[..., None]
142
+ x_sin = np.sin(phases)
143
+ x_cos = np.cos(phases)
144
+ x_cat = np.concatenate([x_sin, x_cos], axis=-1)
145
+ return x_cat.reshape([x.shape[0], -1])
146
+
147
+ node_features = sine_cosine_transform(node_features)
148
+ edge_features = sine_cosine_transform(edge_features)
149
+
150
+ return node_features, edge_features
151
+
152
+
153
+ def lat_lon_to_leading_axes(
154
+ grid_xarray: xarray.DataArray) -> xarray.DataArray:
155
+ """Reorders xarray so lat/lon axes come first."""
156
+ # leading + ["lat", "lon"] + trailing
157
+ # to
158
+ # ["lat", "lon"] + leading + trailing
159
+ return grid_xarray.transpose("lat", "lon", ...)
160
+
161
+
162
+ def restore_leading_axes(grid_xarray: xarray.DataArray) -> xarray.DataArray:
163
+ """Reorders xarray so batch/time/level axes come first (if present)."""
164
+
165
+ # ["lat", "lon"] + [(batch,) (time,) (level,)] + trailing
166
+ # to
167
+ # [(batch,) (time,) (level,)] + ["lat", "lon"] + trailing
168
+
169
+ input_dims = list(grid_xarray.dims)
170
+ output_dims = list(input_dims)
171
+ for leading_key in ["level", "time", "batch"]: # reverse order for insert
172
+ if leading_key in input_dims:
173
+ output_dims.remove(leading_key)
174
+ output_dims.insert(0, leading_key)
175
+ return grid_xarray.transpose(*output_dims)
176
+
177
+
178
+ def lat_lon_deg_to_spherical(node_lat: np.ndarray,
179
+ node_lon: np.ndarray,
180
+ np_: NumpyInterface = np,
181
+ ) -> Tuple[np.ndarray, np.ndarray]:
182
+ phi = np_.deg2rad(node_lon)
183
+ theta = np_.deg2rad(90 - node_lat)
184
+ return phi, theta
185
+
186
+
187
+ def spherical_to_lat_lon(phi: np.ndarray,
188
+ theta: np.ndarray,
189
+ np_: NumpyInterface = np,
190
+ ) -> Tuple[np.ndarray, np.ndarray]:
191
+ lon = np_.mod(np_.rad2deg(phi), 360)
192
+ lat = 90 - np_.rad2deg(theta)
193
+ return lat, lon
194
+
195
+
196
+ def cartesian_to_spherical(x: np.ndarray,
197
+ y: np.ndarray,
198
+ z: np.ndarray,
199
+ np_: NumpyInterface = np,
200
+ ) -> Tuple[np.ndarray, np.ndarray]:
201
+ phi = np_.arctan2(y, x)
202
+ with np.errstate(invalid="ignore"): # circumventing b/253179568
203
+ theta = np_.arccos(z) # Assuming unit radius.
204
+ return phi, theta
205
+
206
+
207
+ def spherical_to_cartesian(
208
+ phi: np.ndarray, theta: np.ndarray,
209
+ np_: NumpyInterface = np,
210
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
211
+ # Assuming unit radius.
212
+ return (np_.cos(phi)*np_.sin(theta),
213
+ np_.sin(phi)*np_.sin(theta),
214
+ np_.cos(theta))
215
+
216
+
217
+ def lat_lon_to_cartesian(
218
+ lat: np.ndarray, lon: np.ndarray,
219
+ np_: NumpyInterface = np,
220
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
221
+ return spherical_to_cartesian(
222
+ *lat_lon_deg_to_spherical(lat, lon, np_=np_), np_=np_)
223
+
224
+
225
+ def cartesian_to_lat_lon(
226
+ x: np.ndarray,
227
+ y: np.ndarray,
228
+ z: np.ndarray,
229
+ np_: NumpyInterface = np,
230
+ ) -> Tuple[np.ndarray, np.ndarray]:
231
+ return spherical_to_lat_lon(
232
+ *cartesian_to_spherical(x, y, z, np_=np_), np_=np_)
233
+
234
+
235
+ def get_relative_position_in_receiver_local_coordinates(
236
+ node_phi: np.ndarray,
237
+ node_theta: np.ndarray,
238
+ senders: np.ndarray,
239
+ receivers: np.ndarray,
240
+ latitude_local_coordinates: bool,
241
+ longitude_local_coordinates: bool,
242
+ np_: NumpyInterface = np,
243
+ transform_: TransformInterface = transform,
244
+ ) -> np.ndarray:
245
+ """Returns relative position features for the edges.
246
+
247
+ The relative positions will be computed in a rotated space for a local
248
+ coordinate system as defined by the receiver. The relative positions are
249
+ simply obtained by subtracting sender position minues receiver position in
250
+ that local coordinate system after the rotation in R^3.
251
+
252
+ Args:
253
+ node_phi: [num_nodes] with polar angles.
254
+ node_theta: [num_nodes] with azimuthal angles.
255
+ senders: [num_edges] with indices.
256
+ receivers: [num_edges] with indices.
257
+ latitude_local_coordinates: Whether to rotate edges such that in the
258
+ positions are computed such that the receiver is always at latitude 0.
259
+ longitude_local_coordinates: Whether to rotate edges such that in the
260
+ positions are computed such that the receiver is always at longitude 0.
261
+ np_: Numpy library interface.
262
+ transform_: scipy.transform library interface.
263
+
264
+ Returns:
265
+ Array of relative positions in R3 [num_edges, 3]
266
+ """
267
+
268
+ node_pos = np_.stack(
269
+ spherical_to_cartesian(node_phi, node_theta, np_=np_), axis=-1)
270
+
271
+ # No rotation in this case.
272
+ if not (latitude_local_coordinates or longitude_local_coordinates):
273
+ return node_pos[senders] - node_pos[receivers]
274
+
275
+ # Get rotation matrices for the local space space for every node.
276
+ rotation_matrices = get_rotation_matrices_to_local_coordinates(
277
+ reference_phi=node_phi,
278
+ reference_theta=node_theta,
279
+ rotate_latitude=latitude_local_coordinates,
280
+ rotate_longitude=longitude_local_coordinates,
281
+ np_=np_,
282
+ transform_=transform_)
283
+
284
+ # Each edge will be rotated according to the rotation matrix of its receiver
285
+ # node.
286
+ edge_rotation_matrices = rotation_matrices[receivers]
287
+
288
+ # Rotate all nodes to the rotated space of the corresponding edge.
289
+ # Note for receivers we can also do the matmul first and the gather second:
290
+ # ```
291
+ # receiver_pos_in_rotated_space = rotate_with_matrices(
292
+ # rotation_matrices, node_pos)[receivers]
293
+ # ```
294
+ # which is more efficient, however, we do gather first to keep it more
295
+ # symmetric with the sender computation.
296
+ receiver_pos_in_rotated_space = rotate_with_matrices(
297
+ edge_rotation_matrices, node_pos[receivers], np_=np_)
298
+ sender_pos_in_in_rotated_space = rotate_with_matrices(
299
+ edge_rotation_matrices, node_pos[senders], np_=np_)
300
+ # Note, here, that because the rotated space is chosen according to the
301
+ # receiver, if:
302
+ # * latitude_local_coordinates = True: latitude for the receivers will be
303
+ # 0, that is the z coordinate will always be 0.
304
+ # * longitude_local_coordinates = True: longitude for the receivers will be
305
+ # 0, that is the y coordinate will be 0.
306
+
307
+ # Now we can just subtract.
308
+ # Note we are rotating to a local coordinate system, where the y-z axes are
309
+ # parallel to a tangent plane to the sphere, but still remain in a 3d space.
310
+ # Note that if both `latitude_local_coordinates` and
311
+ # `longitude_local_coordinates` are True, and edges are short,
312
+ # then the difference in x coordinate between sender and receiver
313
+ # should be small, so we could consider dropping the new x coordinate if
314
+ # we wanted to the tangent plane, however in doing so
315
+ # we would lose information about the curvature of the mesh, which may be
316
+ # important for very coarse meshes.
317
+ return sender_pos_in_in_rotated_space - receiver_pos_in_rotated_space
318
+
319
+
320
+ def get_rotation_matrices_to_local_coordinates(
321
+ reference_phi: np.ndarray,
322
+ reference_theta: np.ndarray,
323
+ rotate_latitude: bool,
324
+ rotate_longitude: bool,
325
+ np_: NumpyInterface = np,
326
+ transform_: TransformInterface = transform,
327
+ ) -> np.ndarray:
328
+ """Returns a rotation matrix to rotate to a point based on a reference vector.
329
+
330
+ The rotation matrix is build such that, a vector in the
331
+ same coordinate system at the reference point that points towards the pole
332
+ before the rotation, continues to point towards the pole after the rotation.
333
+
334
+ Args:
335
+ reference_phi: [leading_axis] Polar angles of the reference.
336
+ reference_theta: [leading_axis] Azimuthal angles of the reference.
337
+ rotate_latitude: Whether to produce a rotation matrix that would rotate
338
+ R^3 vectors to zero latitude.
339
+ rotate_longitude: Whether to produce a rotation matrix that would rotate
340
+ R^3 vectors to zero longitude.
341
+ np_: Numpy library interface.
342
+ transform_: scipy.transform library interface.
343
+
344
+ Returns:
345
+ Matrices of shape [leading_axis] such that when applied to the reference
346
+ position with `rotate_with_matrices(rotation_matrices, reference_pos)`
347
+
348
+ * phi goes to 0. if "rotate_longitude" is True.
349
+
350
+ * theta goes to np.pi / 2 if "rotate_latitude" is True.
351
+
352
+ The rotation consists of:
353
+ * rotate_latitude = False, rotate_longitude = True:
354
+ Latitude preserving rotation.
355
+ * rotate_latitude = True, rotate_longitude = True:
356
+ Latitude preserving rotation, followed by longitude preserving
357
+ rotation.
358
+ * rotate_latitude = True, rotate_longitude = False:
359
+ Latitude preserving rotation, followed by longitude preserving
360
+ rotation, and the inverse of the latitude preserving rotation. Note
361
+ this is computationally different from rotating the longitude only
362
+ and is. We do it like this, so the polar geodesic curve, continues
363
+ to be aligned with one of the axis after the rotation.
364
+
365
+ """
366
+
367
+ # Azimuthal angle we need to apply to move to zero longitude.
368
+ azimuthal_rotation = - reference_phi
369
+
370
+ # Polar angle we need to apply to move from "theta" to zero latitude.
371
+ polar_rotation = - reference_theta + np.pi/2
372
+
373
+ if rotate_longitude and rotate_latitude:
374
+ # We first rotate to zero longitude around the z axis, and then, when the
375
+ # point is at x=0 we can simply apply the polar rotation around the y axis.
376
+ return transform_.Rotation.from_euler(
377
+ "zy", np_.stack([azimuthal_rotation, polar_rotation],
378
+ axis=1)).as_matrix()
379
+ elif rotate_longitude:
380
+ # Just like the previous case, but applying only the azimuthal rotation,
381
+ # leaving the latitude unchanged. Even though it is a single rotation, we
382
+ # need a "sequence" axis of size 1 (hence the expand_dims).
383
+ return transform_.Rotation.from_euler(
384
+ "z", np_.expand_dims(azimuthal_rotation, axis=1)).as_matrix()
385
+ elif rotate_latitude:
386
+ # We want to apply the polar rotation only, but we don't know the rotation
387
+ # axis to apply a polar rotation. The simplest way to achieve this is to
388
+ # first rotate all the way to longitude 0, then apply the polar rotation
389
+ # arond the y axis, and then rotate back to the original longitude.
390
+ return transform_.Rotation.from_euler(
391
+ "zyz", np_.stack(
392
+ [azimuthal_rotation, polar_rotation, -azimuthal_rotation]
393
+ , axis=1)).as_matrix()
394
+ else:
395
+ raise ValueError(
396
+ "At least one of longitude and latitude should be rotated.")
397
+
398
+
399
+ def rotate_with_matrices(rotation_matrices: np.ndarray, positions: np.ndarray,
400
+ np_: NumpyInterface = np) -> np.ndarray:
401
+ return np_.einsum("...ji,...i->...j", rotation_matrices, positions)
402
+
403
+
404
+ def get_bipartite_graph_spatial_features(
405
+ *,
406
+ senders_node_lat: np.ndarray,
407
+ senders_node_lon: np.ndarray,
408
+ senders: np.ndarray,
409
+ receivers_node_lat: np.ndarray,
410
+ receivers_node_lon: np.ndarray,
411
+ receivers: np.ndarray,
412
+ add_node_positions: bool,
413
+ add_node_latitude: bool,
414
+ add_node_longitude: bool,
415
+ add_relative_positions: bool,
416
+ edge_normalization_factor: Optional[float] = None,
417
+ relative_longitude_local_coordinates: bool,
418
+ relative_latitude_local_coordinates: bool,
419
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
420
+ """Computes spatial features for the nodes.
421
+
422
+ This function is almost identical to `get_graph_spatial_features`. The only
423
+ difference is that sender nodes and receiver nodes can be in different arrays.
424
+ This is necessary to enable combination with typed Graph.
425
+
426
+ Args:
427
+ senders_node_lat: Latitudes in the [-90, 90] interval of shape
428
+ [num_sender_nodes]
429
+ senders_node_lon: Longitudes in the [0, 360] interval of shape
430
+ [num_sender_nodes]
431
+ senders: Sender indices of shape [num_edges], indices in [0,
432
+ num_sender_nodes)
433
+ receivers_node_lat: Latitudes in the [-90, 90] interval of shape
434
+ [num_receiver_nodes]
435
+ receivers_node_lon: Longitudes in the [0, 360] interval of shape
436
+ [num_receiver_nodes]
437
+ receivers: Receiver indices of shape [num_edges], indices in [0,
438
+ num_receiver_nodes)
439
+ add_node_positions: Add unit norm absolute positions.
440
+ add_node_latitude: Add a feature for latitude (cos(90 - lat)) Note even if
441
+ this is set to False, the model may be able to infer the longitude from
442
+ relative features, unless `relative_latitude_local_coordinates` is also
443
+ True, or if there is any bias on the relative edge sizes for different
444
+ longitudes.
445
+ add_node_longitude: Add features for longitude (cos(lon), sin(lon)). Note
446
+ even if this is set to False, the model may be able to infer the longitude
447
+ from relative features, unless `relative_longitude_local_coordinates` is
448
+ also True, or if there is any bias on the relative edge sizes for
449
+ different longitudes.
450
+ add_relative_positions: Whether to relative positions in R3 to the edges.
451
+ edge_normalization_factor: Allows explicitly controlling edge normalization.
452
+ If None, defaults to max edge length. This supports using pre-trained
453
+ model weights with a different graph structure to what it was trained on.
454
+ relative_longitude_local_coordinates: If True, relative positions are
455
+ computed in a local space where the receiver is at 0 longitude.
456
+ relative_latitude_local_coordinates: If True, relative positions are
457
+ computed in a local space where the receiver is at 0 latitude.
458
+
459
+ Returns:
460
+ Arrays of shape: [num_nodes, num_features] and [num_edges, num_features].
461
+ with node and edge features.
462
+
463
+ """
464
+
465
+ num_senders = senders_node_lat.shape[0]
466
+ num_receivers = receivers_node_lat.shape[0]
467
+ num_edges = senders.shape[0]
468
+ dtype = senders_node_lat.dtype
469
+ assert receivers_node_lat.dtype == dtype
470
+ senders_node_phi, senders_node_theta = lat_lon_deg_to_spherical(
471
+ senders_node_lat, senders_node_lon)
472
+ receivers_node_phi, receivers_node_theta = lat_lon_deg_to_spherical(
473
+ receivers_node_lat, receivers_node_lon)
474
+
475
+ # Computing some node features.
476
+ senders_node_features = []
477
+ receivers_node_features = []
478
+ if add_node_positions:
479
+ # Already in [-1, 1.] range.
480
+ senders_node_features.extend(
481
+ spherical_to_cartesian(senders_node_phi, senders_node_theta))
482
+ receivers_node_features.extend(
483
+ spherical_to_cartesian(receivers_node_phi, receivers_node_theta))
484
+
485
+ if add_node_latitude:
486
+ # Using the cos of theta.
487
+ # From 1. (north pole) to -1 (south pole).
488
+ senders_node_features.append(np.cos(senders_node_theta))
489
+ receivers_node_features.append(np.cos(receivers_node_theta))
490
+
491
+ if add_node_longitude:
492
+ # Using the cos and sin, which is already normalized.
493
+ senders_node_features.append(np.cos(senders_node_phi))
494
+ senders_node_features.append(np.sin(senders_node_phi))
495
+
496
+ receivers_node_features.append(np.cos(receivers_node_phi))
497
+ receivers_node_features.append(np.sin(receivers_node_phi))
498
+
499
+ if not senders_node_features:
500
+ senders_node_features = np.zeros([num_senders, 0], dtype=dtype)
501
+ receivers_node_features = np.zeros([num_receivers, 0], dtype=dtype)
502
+ else:
503
+ senders_node_features = np.stack(senders_node_features, axis=-1)
504
+ receivers_node_features = np.stack(receivers_node_features, axis=-1)
505
+
506
+ # Computing some edge features.
507
+ edge_features = []
508
+
509
+ if add_relative_positions:
510
+
511
+ relative_position = get_bipartite_relative_position_in_receiver_local_coordinates( # pylint: disable=line-too-long
512
+ senders_node_phi=senders_node_phi,
513
+ senders_node_theta=senders_node_theta,
514
+ receivers_node_phi=receivers_node_phi,
515
+ receivers_node_theta=receivers_node_theta,
516
+ senders=senders,
517
+ receivers=receivers,
518
+ latitude_local_coordinates=relative_latitude_local_coordinates,
519
+ longitude_local_coordinates=relative_longitude_local_coordinates)
520
+
521
+ # Note this is L2 distance in 3d space, rather than geodesic distance.
522
+ relative_edge_distances = np.linalg.norm(
523
+ relative_position, axis=-1, keepdims=True)
524
+
525
+ if edge_normalization_factor is None:
526
+ # Normalize to the maximum edge distance. Note that we expect to always
527
+ # have an edge that goes in the opposite direction of any given edge
528
+ # so the distribution of relative positions should be symmetric around
529
+ # zero. So by scaling by the maximum length, we expect all relative
530
+ # positions to fall in the [-1., 1.] interval, and all relative distances
531
+ # to fall in the [0., 1.] interval.
532
+ edge_normalization_factor = relative_edge_distances.max()
533
+
534
+ edge_features.append(relative_edge_distances / edge_normalization_factor)
535
+ edge_features.append(relative_position / edge_normalization_factor)
536
+
537
+ if not edge_features:
538
+ edge_features = np.zeros([num_edges, 0], dtype=dtype)
539
+ else:
540
+ edge_features = np.concatenate(edge_features, axis=-1)
541
+
542
+ return senders_node_features, receivers_node_features, edge_features
543
+
544
+
545
+ def get_bipartite_relative_position_in_receiver_local_coordinates(
546
+ senders_node_phi: np.ndarray,
547
+ senders_node_theta: np.ndarray,
548
+ senders: np.ndarray,
549
+ receivers_node_phi: np.ndarray,
550
+ receivers_node_theta: np.ndarray,
551
+ receivers: np.ndarray,
552
+ latitude_local_coordinates: bool,
553
+ longitude_local_coordinates: bool,
554
+ np_: NumpyInterface = np,
555
+ transform_: TransformInterface = transform,
556
+ ) -> np.ndarray:
557
+ """Returns relative position features for the edges.
558
+
559
+ This function is equivalent to
560
+ `get_relative_position_in_receiver_local_coordinates`, but adapted to work
561
+ with bipartite typed graphs.
562
+
563
+ The relative positions will be computed in a rotated space for a local
564
+ coordinate system as defined by the receiver. The relative positions are
565
+ simply obtained by subtracting sender position minues receiver position in
566
+ that local coordinate system after the rotation in R^3.
567
+
568
+ Args:
569
+ senders_node_phi: [num_sender_nodes] with polar angles.
570
+ senders_node_theta: [num_sender_nodes] with azimuthal angles.
571
+ senders: [num_edges] with indices into sender nodes.
572
+ receivers_node_phi: [num_sender_nodes] with polar angles.
573
+ receivers_node_theta: [num_sender_nodes] with azimuthal angles.
574
+ receivers: [num_edges] with indices into receiver nodes.
575
+ latitude_local_coordinates: Whether to rotate edges such that in the
576
+ positions are computed such that the receiver is always at latitude 0.
577
+ longitude_local_coordinates: Whether to rotate edges such that in the
578
+ positions are computed such that the receiver is always at longitude 0.
579
+ np_: Numpy library interface.
580
+ transform_: scipy.transform library interface.
581
+
582
+ Returns:
583
+ Array of relative positions in R3 [num_edges, 3]
584
+ """
585
+
586
+ senders_node_pos = np_.stack(
587
+ spherical_to_cartesian(
588
+ senders_node_phi, senders_node_theta, np_=np_), axis=-1)
589
+
590
+ receivers_node_pos = np_.stack(
591
+ spherical_to_cartesian(
592
+ receivers_node_phi, receivers_node_theta, np_=np_), axis=-1)
593
+
594
+ # No rotation in this case.
595
+ if not (latitude_local_coordinates or longitude_local_coordinates):
596
+ return senders_node_pos[senders] - receivers_node_pos[receivers]
597
+
598
+ # Get rotation matrices for the local space space for every receiver node.
599
+ receiver_rotation_matrices = get_rotation_matrices_to_local_coordinates(
600
+ reference_phi=receivers_node_phi,
601
+ reference_theta=receivers_node_theta,
602
+ rotate_latitude=latitude_local_coordinates,
603
+ rotate_longitude=longitude_local_coordinates,
604
+ np_=np_,
605
+ transform_=transform_)
606
+
607
+ # Each edge will be rotated according to the rotation matrix of its receiver
608
+ # node.
609
+ edge_rotation_matrices = receiver_rotation_matrices[receivers]
610
+
611
+ # Rotate all nodes to the rotated space of the corresponding edge.
612
+ # Note for receivers we can also do the matmul first and the gather second:
613
+ # ```
614
+ # receiver_pos_in_rotated_space = rotate_with_matrices(
615
+ # rotation_matrices, node_pos)[receivers]
616
+ # ```
617
+ # which is more efficient, however, we do gather first to keep it more
618
+ # symmetric with the sender computation.
619
+ receiver_pos_in_rotated_space = rotate_with_matrices(
620
+ edge_rotation_matrices, receivers_node_pos[receivers], np_=np_)
621
+ sender_pos_in_in_rotated_space = rotate_with_matrices(
622
+ edge_rotation_matrices, senders_node_pos[senders], np_=np_)
623
+ # Note, here, that because the rotated space is chosen according to the
624
+ # receiver, if:
625
+ # * latitude_local_coordinates = True: latitude for the receivers will be
626
+ # 0, that is the z coordinate will always be 0.
627
+ # * longitude_local_coordinates = True: longitude for the receivers will be
628
+ # 0, that is the y coordinate will be 0.
629
+
630
+ # Now we can just subtract.
631
+ # Note we are rotating to a local coordinate system, where the y-z axes are
632
+ # parallel to a tangent plane to the sphere, but still remain in a 3d space.
633
+ # Note that if both `latitude_local_coordinates` and
634
+ # `longitude_local_coordinates` are True, and edges are short,
635
+ # then the difference in x coordinate between sender and receiver
636
+ # should be small, so we could consider dropping the new x coordinate if
637
+ # we wanted to the tangent plane, however in doing so
638
+ # we would lose information about the curvature of the mesh, which may be
639
+ # important for very coarse meshes.
640
+ return sender_pos_in_in_rotated_space - receiver_pos_in_rotated_space
641
+
642
+
643
+ def variable_to_stacked(
644
+ variable: xarray.Variable,
645
+ sizes: Mapping[str, int],
646
+ preserved_dims: Tuple[str, ...] = ("batch", "lat", "lon"),
647
+ ) -> xarray.Variable:
648
+ """Converts an xarray.Variable to preserved_dims + ("channels",).
649
+
650
+ Any dimensions other than those included in preserved_dims get stacked into a
651
+ final "channels" dimension. If any of the preserved_dims are missing then they
652
+ are added, with the data broadcast/tiled to match the sizes specified in
653
+ `sizes`.
654
+
655
+ Args:
656
+ variable: An xarray.Variable.
657
+ sizes: Mapping including sizes for any dimensions which are not present in
658
+ `variable` but are needed for the output. This may be needed for example
659
+ for a static variable with only ("lat", "lon") dims, or if you want to
660
+ encode just the latitude coordinates (a variable with dims ("lat",)).
661
+ preserved_dims: dimensions of variable to not be folded in channels.
662
+
663
+ Returns:
664
+ An xarray.Variable with dimensions preserved_dims + ("channels",).
665
+ """
666
+ stack_to_channels_dims = [
667
+ d for d in variable.dims if d not in preserved_dims]
668
+ if stack_to_channels_dims:
669
+ variable = variable.stack(channels=stack_to_channels_dims)
670
+ dims = {dim: variable.sizes.get(dim) or sizes[dim] for dim in preserved_dims}
671
+ dims["channels"] = variable.sizes.get("channels", 1)
672
+ return variable.set_dims(dims)
673
+
674
+
675
+ def dataset_to_stacked(
676
+ dataset: xarray.Dataset,
677
+ sizes: Optional[Mapping[str, int]] = None,
678
+ preserved_dims: Tuple[str, ...] = ("batch", "lat", "lon"),
679
+ ) -> xarray.DataArray:
680
+ """Converts an xarray.Dataset to a single stacked array.
681
+
682
+ This takes each consistuent data_var, converts it into BHWC layout
683
+ using `variable_to_stacked`, then concats them all along the channels axis.
684
+
685
+ Args:
686
+ dataset: An xarray.Dataset.
687
+ sizes: Mapping including sizes for any dimensions which are not present in
688
+ the `dataset` but are needed for the output. See variable_to_stacked.
689
+ preserved_dims: dimensions from the dataset that should not be folded in
690
+ the predictions channels.
691
+
692
+ Returns:
693
+ An xarray.DataArray with dimensions preserved_dims + ("channels",).
694
+ Existing coordinates for preserved_dims axes will be preserved, however
695
+ there will be no coordinates for "channels".
696
+ """
697
+ data_vars = [
698
+ variable_to_stacked(dataset.variables[name], sizes or dataset.sizes,
699
+ preserved_dims)
700
+ for name in sorted(dataset.data_vars.keys())
701
+ ]
702
+ coords = {
703
+ dim: coord
704
+ for dim, coord in dataset.coords.items()
705
+ if dim in preserved_dims
706
+ }
707
+ return xarray.DataArray(
708
+ data=xarray.Variable.concat(data_vars, dim="channels"), coords=coords)
709
+
710
+
711
+ def stacked_to_dataset(
712
+ stacked_array: xarray.Variable,
713
+ template_dataset: xarray.Dataset,
714
+ preserved_dims: Tuple[str, ...] = ("batch", "lat", "lon"),
715
+ ) -> xarray.Dataset:
716
+ """The inverse of dataset_to_stacked.
717
+
718
+ Requires a template dataset to demonstrate the variables/shapes/coordinates
719
+ required.
720
+ All variables must have preserved_dims dimensions.
721
+
722
+ Args:
723
+ stacked_array: Data in BHWC layout, encoded the same as dataset_to_stacked
724
+ would if it was asked to encode `template_dataset`.
725
+ template_dataset: A template Dataset (or other mapping of DataArrays)
726
+ demonstrating the shape of output required (variables, shapes,
727
+ coordinates etc).
728
+ preserved_dims: dimensions from the target_template that were not folded in
729
+ the predictions channels. The preserved_dims need to be a subset of the
730
+ dims of all the variables of template_dataset.
731
+
732
+ Returns:
733
+ An xarray.Dataset (or other mapping of DataArrays) with the same shape and
734
+ type as template_dataset.
735
+ """
736
+ unstack_from_channels_sizes = {}
737
+ var_names = sorted(template_dataset.keys())
738
+ for name in var_names:
739
+ template_var = template_dataset[name]
740
+ if not all(dim in template_var.dims for dim in preserved_dims):
741
+ raise ValueError(
742
+ f"stacked_to_dataset requires all Variables to have {preserved_dims} "
743
+ f"dimensions, but found only {template_var.dims}.")
744
+ unstack_from_channels_sizes[name] = {
745
+ dim: size for dim, size in template_var.sizes.items()
746
+ if dim not in preserved_dims}
747
+
748
+ channels = {name: np.prod(list(unstack_sizes.values()), dtype=np.int64)
749
+ for name, unstack_sizes in unstack_from_channels_sizes.items()}
750
+ total_expected_channels = sum(channels.values())
751
+ found_channels = stacked_array.sizes["channels"]
752
+ if total_expected_channels != found_channels:
753
+ raise ValueError(
754
+ f"Expected {total_expected_channels} channels but found "
755
+ f"{found_channels}, when trying to convert a stacked array of shape "
756
+ f"{stacked_array.sizes} to a dataset of shape {template_dataset}.")
757
+
758
+ data_vars = {}
759
+ index = 0
760
+ for name in var_names:
761
+ template_var = template_dataset[name]
762
+ var = stacked_array.isel({"channels": slice(index, index + channels[name])})
763
+ index += channels[name]
764
+ var = var.unstack({"channels": unstack_from_channels_sizes[name]})
765
+ var = var.transpose(*template_var.dims)
766
+ data_vars[name] = xarray.DataArray(
767
+ data=var,
768
+ coords=template_var.coords,
769
+ # This might not always be the same as the name it's keyed under; it
770
+ # will refer to the original variable name, whereas the key might be
771
+ # some alias e.g. temperature_850 under which it should be logged:
772
+ name=template_var.name,
773
+ )
774
+ return type(template_dataset)(data_vars) # pytype:disable=not-callable,wrong-arg-count
775
+
776
+
777
+ def fourier_features(
778
+ values: jnp.ndarray,
779
+ base_period: float,
780
+ num_frequencies: int,
781
+ ) -> jnp.ndarray:
782
+ """Maps values to sin/cos features for a range of frequencies.
783
+
784
+ Args:
785
+ values: Values to compute Fourier features for.
786
+ base_period: The base period to use. This should be greater or equal to the
787
+ range of the values, or to the period if the values have periodic
788
+ semantics (e.g. 2pi if they represent angles). Frequencies used will be
789
+ integer multiples of 1/base_period.
790
+ num_frequencies: The number of frequencies to use, we will use integer
791
+ multiples of 1/base_period from 1 up to num_frequencies inclusive. (We
792
+ don't include a zero frequency as this would just give constant features
793
+ which are redundant if a bias term is present).
794
+
795
+ Returns:
796
+ Array with same shape as values except with an extra trailing dimension
797
+ of size 2*num_frequencies, which contains a sin and a cos feature for each
798
+ frequency.
799
+ """
800
+ frequencies = np.arange(1, num_frequencies + 1) / base_period
801
+ angular_frequencies = jnp.array(2 * np.pi * frequencies, dtype=values.dtype)
802
+ values_times_angular_freqs = values[..., None] * angular_frequencies
803
+ return jnp.concatenate(
804
+ [jnp.cos(values_times_angular_freqs),
805
+ jnp.sin(values_times_angular_freqs)],
806
+ axis=-1)
807
+
model/graphcast/nan_cleaning.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2024 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Wrappers for Predictors which allow them to work with data cleaned of NaNs.
15
+
16
+ The Predictor which is wrapped sees inputs and targets without NaNs, and makes
17
+ NaNless predictions.
18
+ """
19
+
20
+ from typing import Optional, Tuple
21
+
22
+ from . import predictor_base as base
23
+ import numpy as np
24
+ import xarray
25
+
26
+
27
+ class NaNCleaner(base.Predictor):
28
+ """A predictor wrapper than removes NaNs from ingested data.
29
+
30
+ The Predictor which is wrapped sees inputs and targets without NaNs.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ predictor: base.Predictor,
36
+ var_to_clean: str,
37
+ fill_value: xarray.Dataset,
38
+ reintroduce_nans: bool = False,
39
+ ):
40
+ """Initializes the NaNCleaner."""
41
+ self._predictor = predictor
42
+ self._fill_value = fill_value[var_to_clean]
43
+ self._var_to_clean = var_to_clean
44
+ self._reintroduce_nans = reintroduce_nans
45
+
46
+ def _clean(self, dataset: xarray.Dataset) -> xarray.Dataset:
47
+ """Cleans the dataset of NaNs."""
48
+ data_array = dataset[self._var_to_clean]
49
+ dataset = dataset.assign(
50
+ {self._var_to_clean: data_array.fillna(self._fill_value)}
51
+ )
52
+ return dataset
53
+
54
+ def _maybe_reintroduce_nans(
55
+ self, stale_inputs: xarray.Dataset, predictions: xarray.Dataset
56
+ ) -> xarray.Dataset:
57
+ # NaN positions don't change between input frames, if they do then
58
+ # we should be more careful about re-introducing them.
59
+ if self._var_to_clean in predictions.keys():
60
+ nan_mask = np.isnan(stale_inputs[self._var_to_clean]).any(dim='time')
61
+ with_nan_values = predictions[self._var_to_clean].where(~nan_mask, np.nan)
62
+ predictions = predictions.assign({self._var_to_clean: with_nan_values})
63
+ return predictions
64
+
65
+ def __call__(
66
+ self,
67
+ inputs: xarray.Dataset,
68
+ targets_template: xarray.Dataset,
69
+ forcings: Optional[xarray.Dataset] = None,
70
+ **kwargs,
71
+ ) -> xarray.Dataset:
72
+ if self._reintroduce_nans:
73
+ # Copy inputs before cleaning so that we can reintroduce NaNs later.
74
+ original_inputs = inputs.copy()
75
+ if self._var_to_clean in inputs.keys():
76
+ inputs = self._clean(inputs)
77
+ if forcings and self._var_to_clean in forcings.keys():
78
+ forcings = self._clean(forcings)
79
+ predictions = self._predictor(
80
+ inputs, targets_template, forcings, **kwargs
81
+ )
82
+ if self._reintroduce_nans:
83
+ predictions = self._maybe_reintroduce_nans(original_inputs, predictions)
84
+ return predictions
85
+
86
+ def loss(
87
+ self,
88
+ inputs: xarray.Dataset,
89
+ targets: xarray.Dataset,
90
+ forcings: Optional[xarray.Dataset] = None,
91
+ **kwargs,
92
+ ) -> base.LossAndDiagnostics:
93
+ if self._var_to_clean in inputs.keys():
94
+ inputs = self._clean(inputs)
95
+ if self._var_to_clean in targets.keys():
96
+ targets = self._clean(targets)
97
+ if forcings and self._var_to_clean in forcings.keys():
98
+ forcings = self._clean(forcings)
99
+ return self._predictor.loss(
100
+ inputs, targets, forcings, **kwargs
101
+ )
102
+
103
+ def loss_and_predictions(
104
+ self,
105
+ inputs: xarray.Dataset,
106
+ targets: xarray.Dataset,
107
+ forcings: Optional[xarray.Dataset] = None,
108
+ **kwargs,
109
+ ) -> Tuple[base.LossAndDiagnostics, xarray.Dataset]:
110
+ if self._reintroduce_nans:
111
+ # Copy inputs before cleaning so that we can reintroduce NaNs later.
112
+ original_inputs = inputs.copy()
113
+ if self._var_to_clean in inputs.keys():
114
+ inputs = self._clean(inputs)
115
+ if self._var_to_clean in targets.keys():
116
+ targets = self._clean(targets)
117
+ if forcings and self._var_to_clean in forcings.keys():
118
+ forcings = self._clean(forcings)
119
+
120
+ loss, predictions = self._predictor.loss_and_predictions(
121
+ inputs, targets, forcings, **kwargs
122
+ )
123
+ if self._reintroduce_nans:
124
+ predictions = self._maybe_reintroduce_nans(original_inputs, predictions)
125
+ return loss, predictions
model/graphcast/normalization.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2023 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Wrappers for Predictors which allow them to work with normalized data.
15
+
16
+ The Predictor which is wrapped sees normalized inputs and targets, and makes
17
+ normalized predictions. The wrapper handles translating the predictions back
18
+ to the original domain.
19
+ """
20
+
21
+ import logging
22
+ from typing import Optional, Tuple
23
+
24
+ from . import predictor_base
25
+ from . import xarray_tree
26
+ import xarray
27
+
28
+
29
+ def normalize(values: xarray.Dataset,
30
+ scales: xarray.Dataset,
31
+ locations: Optional[xarray.Dataset],
32
+ ) -> xarray.Dataset:
33
+ """Normalize variables using the given scales and (optionally) locations."""
34
+ def normalize_array(array):
35
+ if array.name is None:
36
+ raise ValueError(
37
+ "Can't look up normalization constants because array has no name.")
38
+ if locations is not None:
39
+ if array.name in locations:
40
+ array = array - locations[array.name].astype(array.dtype)
41
+ else:
42
+ logging.warning('No normalization location found for %s', array.name)
43
+ if array.name in scales:
44
+ array = array / scales[array.name].astype(array.dtype)
45
+ else:
46
+ logging.warning('No normalization scale found for %s', array.name)
47
+ return array
48
+ return xarray_tree.map_structure(normalize_array, values)
49
+
50
+
51
+ def unnormalize(values: xarray.Dataset,
52
+ scales: xarray.Dataset,
53
+ locations: Optional[xarray.Dataset],
54
+ ) -> xarray.Dataset:
55
+ """Unnormalize variables using the given scales and (optionally) locations."""
56
+ def unnormalize_array(array):
57
+ if array.name is None:
58
+ raise ValueError(
59
+ "Can't look up normalization constants because array has no name.")
60
+ if array.name in scales:
61
+ array = array * scales[array.name].astype(array.dtype)
62
+ else:
63
+ logging.warning('No normalization scale found for %s', array.name)
64
+ if locations is not None:
65
+ if array.name in locations:
66
+ array = array + locations[array.name].astype(array.dtype)
67
+ else:
68
+ logging.warning('No normalization location found for %s', array.name)
69
+ return array
70
+ return xarray_tree.map_structure(unnormalize_array, values)
71
+
72
+
73
+ class InputsAndResiduals(predictor_base.Predictor):
74
+ """Wraps with a residual connection, normalizing inputs and target residuals.
75
+
76
+ The inner predictor is given inputs that are normalized using `locations`
77
+ and `scales` to roughly zero-mean unit variance.
78
+
79
+ For target variables that are present in the inputs, the inner predictor is
80
+ trained to predict residuals (target - last_frame_of_input) that have been
81
+ normalized using `residual_scales` (and optionally `residual_locations`) to
82
+ roughly unit variance / zero mean.
83
+
84
+ This replaces `residual.Predictor` in the case where you want normalization
85
+ that's based on the scales of the residuals.
86
+
87
+ Since we return the underlying predictor's loss on the normalized residuals,
88
+ if the underlying predictor is a sum of per-variable losses, the normalization
89
+ will affect the relative weighting of the per-variable loss terms (hopefully
90
+ in a good way).
91
+
92
+ For target variables *not* present in the inputs, the inner predictor is
93
+ trained to predict targets directly, that have been normalized in the same
94
+ way as the inputs.
95
+
96
+ The transforms applied to the targets (the residual connection and the
97
+ normalization) are applied in reverse to the predictions before returning
98
+ them.
99
+ """
100
+
101
+ def __init__(
102
+ self,
103
+ predictor: predictor_base.Predictor,
104
+ stddev_by_level: xarray.Dataset,
105
+ mean_by_level: xarray.Dataset,
106
+ diffs_stddev_by_level: xarray.Dataset):
107
+ self._predictor = predictor
108
+ self._scales = stddev_by_level
109
+ self._locations = mean_by_level
110
+ self._residual_scales = diffs_stddev_by_level
111
+ self._residual_locations = None
112
+
113
+ def _unnormalize_prediction_and_add_input(self, inputs, norm_prediction):
114
+ if norm_prediction.sizes.get('time') != 1:
115
+ raise ValueError(
116
+ 'normalization.InputsAndResiduals only supports predicting a '
117
+ 'single timestep.')
118
+ if norm_prediction.name in inputs:
119
+ # Residuals are assumed to be predicted as normalized (unit variance),
120
+ # but the scale and location they need mapping to is that of the residuals
121
+ # not of the values themselves.
122
+ prediction = unnormalize(
123
+ norm_prediction, self._residual_scales, self._residual_locations)
124
+ # A prediction for which we have a corresponding input -- we are
125
+ # predicting the residual:
126
+ last_input = inputs[norm_prediction.name].isel(time=-1)
127
+ prediction = prediction + last_input
128
+ return prediction
129
+ else:
130
+ # A predicted variable which is not an input variable. We are predicting
131
+ # it directly, so unnormalize it directly to the target scale/location:
132
+ return unnormalize(norm_prediction, self._scales, self._locations)
133
+
134
+ def _subtract_input_and_normalize_target(self, inputs, target):
135
+ if target.sizes.get('time') != 1:
136
+ raise ValueError(
137
+ 'normalization.InputsAndResiduals only supports wrapping predictors'
138
+ 'that predict a single timestep.')
139
+ if target.name in inputs:
140
+ target_residual = target
141
+ last_input = inputs[target.name].isel(time=-1)
142
+ target_residual = target_residual - last_input
143
+ return normalize(
144
+ target_residual, self._residual_scales, self._residual_locations)
145
+ else:
146
+ return normalize(target, self._scales, self._locations)
147
+
148
+ def __call__(self,
149
+ inputs: xarray.Dataset,
150
+ targets_template: xarray.Dataset,
151
+ forcings: xarray.Dataset,
152
+ **kwargs
153
+ ) -> xarray.Dataset:
154
+ norm_inputs = normalize(inputs, self._scales, self._locations)
155
+ norm_forcings = normalize(forcings, self._scales, self._locations)
156
+ norm_predictions = self._predictor(
157
+ norm_inputs, targets_template, forcings=norm_forcings, **kwargs)
158
+ return xarray_tree.map_structure(
159
+ lambda pred: self._unnormalize_prediction_and_add_input(inputs, pred),
160
+ norm_predictions)
161
+
162
+ def loss(self,
163
+ inputs: xarray.Dataset,
164
+ targets: xarray.Dataset,
165
+ forcings: xarray.Dataset,
166
+ **kwargs,
167
+ ) -> predictor_base.LossAndDiagnostics:
168
+ """Returns the loss computed on normalized inputs and targets."""
169
+ norm_inputs = normalize(inputs, self._scales, self._locations)
170
+ norm_forcings = normalize(forcings, self._scales, self._locations)
171
+ norm_target_residuals = xarray_tree.map_structure(
172
+ lambda t: self._subtract_input_and_normalize_target(inputs, t),
173
+ targets)
174
+ return self._predictor.loss(
175
+ norm_inputs, norm_target_residuals, forcings=norm_forcings, **kwargs)
176
+
177
+ def loss_and_predictions( # pytype: disable=signature-mismatch # jax-ndarray
178
+ self,
179
+ inputs: xarray.Dataset,
180
+ targets: xarray.Dataset,
181
+ forcings: xarray.Dataset,
182
+ **kwargs,
183
+ ) -> Tuple[predictor_base.LossAndDiagnostics,
184
+ xarray.Dataset]:
185
+ """The loss computed on normalized data, with unnormalized predictions."""
186
+ norm_inputs = normalize(inputs, self._scales, self._locations)
187
+ norm_forcings = normalize(forcings, self._scales, self._locations)
188
+ norm_target_residuals = xarray_tree.map_structure(
189
+ lambda t: self._subtract_input_and_normalize_target(inputs, t),
190
+ targets)
191
+ (loss, scalars), norm_predictions = self._predictor.loss_and_predictions(
192
+ norm_inputs, norm_target_residuals, forcings=norm_forcings, **kwargs)
193
+ predictions = xarray_tree.map_structure(
194
+ lambda pred: self._unnormalize_prediction_and_add_input(inputs, pred),
195
+ norm_predictions)
196
+ return (loss, scalars), predictions
model/graphcast/predictor_base.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2023 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Abstract base classes for an xarray-based Predictor API."""
15
+
16
+ import abc
17
+
18
+ from typing import Tuple
19
+
20
+ from . import losses
21
+ from . import xarray_jax
22
+ import jax.numpy as jnp
23
+ import xarray
24
+
25
+ LossAndDiagnostics = losses.LossAndDiagnostics
26
+
27
+
28
+ class Predictor(abc.ABC):
29
+ """A possibly-trainable predictor of weather, exposing an xarray-based API.
30
+
31
+ Typically wraps an underlying JAX model and handles translating the xarray
32
+ Dataset values to and from plain JAX arrays that are convenient for input to
33
+ (and output from) the underlying model.
34
+
35
+ Different subclasses may exist to wrap different kinds of underlying model,
36
+ e.g. models taking stacked inputs/outputs, models taking separate 2D and 3D
37
+ inputs/outputs, autoregressive models.
38
+
39
+ You can also implement a specific model directly as a Predictor if you want,
40
+ for example if it has quite specific/unique requirements for its input/output
41
+ or loss function, or if it's convenient to implement directly using xarray.
42
+ """
43
+
44
+ @abc.abstractmethod
45
+ def __call__(self,
46
+ inputs: xarray.Dataset,
47
+ targets_template: xarray.Dataset,
48
+ forcings: xarray.Dataset,
49
+ **optional_kwargs
50
+ ) -> xarray.Dataset:
51
+ """Makes predictions.
52
+
53
+ This is only used by the Experiment for inference / evaluation, with
54
+ training going via the .loss method. So it should default to making
55
+ predictions for evaluation, although you can also support making predictions
56
+ for use in the loss via an is_training argument -- see
57
+ LossFunctionPredictor which helps with that.
58
+
59
+ Args:
60
+ inputs: An xarray.Dataset of inputs.
61
+ targets_template: An xarray.Dataset or other mapping of xarray.DataArrays,
62
+ with the same shape as the targets, to demonstrate what kind of
63
+ predictions are required. You can use this to determine which variables,
64
+ levels and lead times must be predicted.
65
+ You are free to raise an error if you don't support predicting what is
66
+ requested.
67
+ forcings: An xarray.Dataset of forcings terms. Forcings are variables
68
+ that can be fed to the model, but do not need to be predicted. This is
69
+ often because this variable can be computed analytically (e.g. the toa
70
+ radiation of the sun is mostly a function of geometry) or are considered
71
+ to be controlled for the experiment (e.g., impose a scenario of C02
72
+ emission into the atmosphere). Unlike `inputs`, the `forcings` can
73
+ include information "from the future", that is, information at target
74
+ times specified in the `targets_template`.
75
+ **optional_kwargs: Implementations may support extra optional kwargs,
76
+ provided they set appropriate defaults for them.
77
+
78
+ Returns:
79
+ Predictions, as an xarray.Dataset or other mapping of DataArrays which
80
+ is capable of being evaluated against targets with shape given by
81
+ targets_template.
82
+ For probabilistic predictors which can return multiple samples from a
83
+ predictive distribution, these should (by convention) be returned along
84
+ an additional 'sample' dimension.
85
+ """
86
+
87
+ def loss(self,
88
+ inputs: xarray.Dataset,
89
+ targets: xarray.Dataset,
90
+ forcings: xarray.Dataset,
91
+ **optional_kwargs,
92
+ ) -> LossAndDiagnostics:
93
+ """Computes a training loss, for predictors that are trainable.
94
+
95
+ Why make this the Predictor's responsibility, rather than letting callers
96
+ compute their own loss function using predictions obtained from
97
+ Predictor.__call__?
98
+
99
+ Doing it this way gives Predictors more control over their training setup.
100
+ For example, some predictors may wish to train using different targets to
101
+ the ones they predict at evaluation time -- perhaps different lead times and
102
+ variables, perhaps training to predict transformed versions of targets
103
+ where the transform needs to be inverted at evaluation time, etc.
104
+
105
+ It's also necessary for generative models (VAEs, GANs, ...) where the
106
+ training loss is more complex and isn't expressible as a parameter-free
107
+ function of predictions and targets.
108
+
109
+ Args:
110
+ inputs: An xarray.Dataset.
111
+ targets: An xarray.Dataset or other mapping of xarray.DataArrays. See
112
+ docs on __call__ for an explanation about the targets.
113
+ forcings: xarray.Dataset of forcing terms.
114
+ **optional_kwargs: Implementations may support extra optional kwargs,
115
+ provided they set appropriate defaults for them.
116
+
117
+ Returns:
118
+ loss: A DataArray with dimensions ('batch',) containing losses for each
119
+ element of the batch. These will be averaged to give the final
120
+ loss, locally and across replicas.
121
+ diagnostics: Mapping of additional quantities to log by name alongside the
122
+ loss. These will will typically correspond to terms in the loss. They
123
+ should also have dimensions ('batch',) and will be averaged over the
124
+ batch before logging.
125
+ You need not include the loss itself in this dict; it will be added for
126
+ you.
127
+ """
128
+ del targets, forcings, optional_kwargs
129
+ batch_size = inputs.sizes['batch']
130
+ dummy_loss = xarray_jax.DataArray(jnp.zeros(batch_size), dims=('batch',))
131
+ return dummy_loss, {} # pytype: disable=bad-return-type
132
+
133
+ def loss_and_predictions(
134
+ self,
135
+ inputs: xarray.Dataset,
136
+ targets: xarray.Dataset,
137
+ forcings: xarray.Dataset,
138
+ **optional_kwargs,
139
+ ) -> Tuple[LossAndDiagnostics, xarray.Dataset]:
140
+ """Like .loss but also returns corresponding predictions.
141
+
142
+ Implementing this is optional as it's not used directly by the Experiment,
143
+ but it is required by autoregressive.Predictor when applying an inner
144
+ Predictor autoregressively at training time; we need a loss at each step but
145
+ also predictions to feed back in for the next step.
146
+
147
+ Note the loss itself may not be directly regressing the predictions towards
148
+ targets, the loss may be computed in terms of transformed predictions and
149
+ targets (or in some other way). For this reason we can't always cleanly
150
+ separate this into step 1: get predictions, step 2: compute loss from them,
151
+ hence the need for this combined method.
152
+
153
+ Args:
154
+ inputs:
155
+ targets:
156
+ forcings:
157
+ **optional_kwargs:
158
+ As for self.loss.
159
+
160
+ Returns:
161
+ (loss, diagnostics)
162
+ As for self.loss
163
+ predictions:
164
+ The predictions which the loss relates to. These should be of the same
165
+ shape as what you would get from
166
+ `self.__call__(inputs, targets_template=targets)`, and should be in the
167
+ same 'domain' as the inputs (i.e. they shouldn't be transformed
168
+ differently to how the predictor expects its inputs).
169
+ """
170
+ raise NotImplementedError
model/graphcast/rollout.py ADDED
@@ -0,0 +1,498 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2023 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Utils for rolling out models."""
15
+
16
+ from typing import Iterator, Optional, Sequence
17
+
18
+ from absl import logging
19
+ import chex
20
+ import dask.array
21
+ from . import xarray_jax
22
+ from . import xarray_tree
23
+ import jax
24
+ import jax.numpy as jnp
25
+ import numpy as np
26
+ import typing_extensions
27
+ import xarray
28
+
29
+
30
+ def _device_put_sharded(data_list, devices, axis_name):
31
+ """Stack data and put on devices with consistent sharding.
32
+
33
+ Creates a mesh with axis_name to ensure JIT cache consistency with pmap.
34
+
35
+ Args:
36
+ data_list: List of data to stack and put on devices.
37
+ devices: List of devices to put the data on.
38
+ axis_name: Name of the axis to use for sharding.
39
+
40
+ Returns:
41
+ Data put on devices with consistent sharding.
42
+ """
43
+
44
+ mesh = jax.sharding.Mesh(np.array(devices), (axis_name,))
45
+ sharding = jax.NamedSharding(mesh, jax.P(axis_name))
46
+ stack_fn = (
47
+ jnp.stack
48
+ if all(isinstance(x, jax.Array) for x in data_list)
49
+ else np.stack
50
+ )
51
+ stacked = stack_fn(data_list, axis=0)
52
+ return jax.device_put(stacked, sharding)
53
+
54
+
55
+ class PredictorFn(typing_extensions.Protocol):
56
+ """Functional version of base.Predictor.__call__ with explicit rng."""
57
+
58
+ def __call__(
59
+ self, rng: chex.PRNGKey, inputs: xarray.Dataset,
60
+ targets_template: xarray.Dataset,
61
+ forcings: xarray.Dataset,
62
+ **optional_kwargs,
63
+ ) -> xarray.Dataset:
64
+ ...
65
+
66
+
67
+ def _replicate_dataset(
68
+ data: xarray.Dataset, replica_dim: str,
69
+ replicate_to_device: bool,
70
+ devices: Sequence[jax.Device],
71
+ ) -> xarray.Dataset:
72
+ """Used to prepare for xarray_jax.pmap."""
73
+
74
+ def replicate_variable(variable: xarray.Variable) -> xarray.Variable:
75
+ if replica_dim in variable.dims:
76
+ # TODO(pricei): call device_put_replicated when replicate_to_device==True
77
+ return variable.transpose(replica_dim, ...)
78
+ else:
79
+ data = len(devices) * [variable.data]
80
+ if replicate_to_device:
81
+ assert devices is not None
82
+ data = _device_put_sharded(data, devices, replica_dim)
83
+ else:
84
+ data = np.stack(data, axis=0)
85
+ return xarray_jax.Variable(
86
+ data=data, dims=(replica_dim,) + variable.dims, attrs=variable.attrs
87
+ )
88
+
89
+ def replicate_dataset(dataset: xarray.Dataset) -> xarray.Dataset:
90
+ if dataset is None:
91
+ return None
92
+ data_variables = {
93
+ name: replicate_variable(var)
94
+ for name, var in dataset.data_vars.variables.items()
95
+ }
96
+ coords = {name: coord.variable for name, coord in dataset.coords.items()}
97
+ return xarray.Dataset(data_variables, coords=coords, attrs=dataset.attrs)
98
+
99
+ return replicate_dataset(data)
100
+
101
+
102
+ def chunked_prediction_generator_multiple_runs(
103
+ predictor_fn: PredictorFn,
104
+ rngs: chex.PRNGKey,
105
+ inputs: xarray.Dataset,
106
+ targets_template: xarray.Dataset,
107
+ forcings: Optional[xarray.Dataset],
108
+ num_samples: Optional[int],
109
+ pmap_devices: Optional[Sequence[jax.Device]] = None,
110
+ **chunked_prediction_kwargs,
111
+ ) -> Iterator[xarray.Dataset]:
112
+ """Outputs a trajectory of multiple samples by yielding chunked predictions.
113
+
114
+ Args:
115
+ predictor_fn: Function to use to make predictions for each chunk.
116
+ rngs: RNG sequence to be used for each ensemble member.
117
+ inputs: Inputs for the model.
118
+ targets_template: Template for the target prediction, requires targets
119
+ equispaced in time.
120
+ forcings: Optional forcing for the model.
121
+ num_samples: The number of runs / samples to rollout.
122
+ pmap_devices: List of devices over which predictor_fn is pmapped, or None if
123
+ it is not pmapped.
124
+ **chunked_prediction_kwargs:
125
+ See chunked_prediction, some of these are required arguments.
126
+
127
+ Yields:
128
+ The predictions for each chunked step of the chunked rollout, such that
129
+ if all predictions are concatenated in time and sample dimension squeezed,
130
+ this would match the targets template in structure.
131
+
132
+ """
133
+ if pmap_devices is not None:
134
+ assert (
135
+ num_samples % len(pmap_devices) == 0
136
+ ), "num_samples must be a multiple of len(pmap_devices)"
137
+
138
+ def predictor_fn_pmap_named_args(rng, inputs, targets_template, forcings):
139
+ targets_template = _replicate_dataset(
140
+ targets_template,
141
+ replica_dim="sample",
142
+ replicate_to_device=True,
143
+ devices=pmap_devices,
144
+ )
145
+ return predictor_fn(rng, inputs, targets_template, forcings)
146
+
147
+ for i in range(0, num_samples, len(pmap_devices)):
148
+ sample_idx = slice(i, i + len(pmap_devices))
149
+ logging.info("Samples %s out of %s", sample_idx, num_samples)
150
+ logging.flush()
151
+ sample_group_rngs = _device_put_sharded(
152
+ rngs[sample_idx], pmap_devices, "sample")
153
+
154
+ if "sample" not in inputs.dims:
155
+ sample_inputs = inputs
156
+ else:
157
+ sample_inputs = inputs.isel(sample=sample_idx, drop=True)
158
+
159
+ sample_inputs = _replicate_dataset(
160
+ sample_inputs,
161
+ replica_dim="sample",
162
+ replicate_to_device=True,
163
+ devices=pmap_devices,
164
+ )
165
+
166
+ if forcings is not None:
167
+ if "sample" not in forcings.dims:
168
+ sample_forcings = forcings
169
+ else:
170
+ sample_forcings = forcings.isel(sample=sample_idx, drop=True)
171
+
172
+ # TODO(pricei): We are replicating the full forcings for all rollout
173
+ # timesteps here, rather than inside `predictor_fn_pmap_named_args` like
174
+ # the targets_template above, because the forcings are concatenated with
175
+ # the inputs which will already be replicated. We should refactor this
176
+ # so that chunked prediction is aware of whether it is being run with
177
+ # pmap, and if so do the replication and device_put only of the
178
+ # necessary timesteps, as part of the chunked prediction function.
179
+ sample_forcings = _replicate_dataset(
180
+ sample_forcings,
181
+ replica_dim="sample",
182
+ replicate_to_device=False,
183
+ devices=pmap_devices,
184
+ )
185
+ else:
186
+ sample_forcings = None
187
+
188
+ for prediction_chunk in chunked_prediction_generator(
189
+ predictor_fn=predictor_fn_pmap_named_args,
190
+ rng=sample_group_rngs,
191
+ inputs=sample_inputs,
192
+ targets_template=targets_template,
193
+ forcings=sample_forcings,
194
+ pmap_devices=pmap_devices,
195
+ replica_axis="sample",
196
+ **chunked_prediction_kwargs,
197
+ ):
198
+ prediction_chunk.coords["sample"] = np.arange(
199
+ sample_idx.start, sample_idx.stop, sample_idx.step
200
+ )
201
+ yield prediction_chunk
202
+ del prediction_chunk
203
+ else:
204
+ for i in range(num_samples):
205
+ logging.info("Sample %d/%d", i, num_samples)
206
+ logging.flush()
207
+ this_sample_rng = rngs[i]
208
+
209
+ if "sample" in inputs.dims:
210
+ sample_inputs = inputs.isel(sample=i, drop=True)
211
+ else:
212
+ sample_inputs = inputs
213
+
214
+ sample_forcings = forcings
215
+ if sample_forcings is not None:
216
+ if "sample" in sample_forcings.dims:
217
+ sample_forcings = sample_forcings.isel(sample=i, drop=True)
218
+
219
+ for prediction_chunk in chunked_prediction_generator(
220
+ predictor_fn=predictor_fn,
221
+ rng=this_sample_rng,
222
+ inputs=sample_inputs,
223
+ targets_template=targets_template,
224
+ forcings=sample_forcings,
225
+ **chunked_prediction_kwargs):
226
+ prediction_chunk.coords["sample"] = i
227
+ yield prediction_chunk
228
+ del prediction_chunk
229
+
230
+
231
+ def chunked_prediction(
232
+ predictor_fn: PredictorFn,
233
+ rng: chex.PRNGKey,
234
+ inputs: xarray.Dataset,
235
+ targets_template: xarray.Dataset,
236
+ forcings: xarray.Dataset,
237
+ num_steps_per_chunk: int = 1,
238
+ verbose: bool = False,
239
+ ) -> xarray.Dataset:
240
+ """Outputs a long trajectory by iteratively concatenating chunked predictions.
241
+
242
+ Args:
243
+ predictor_fn: Function to use to make predictions for each chunk.
244
+ rng: Random key.
245
+ inputs: Inputs for the model.
246
+ targets_template: Template for the target prediction, requires targets
247
+ equispaced in time.
248
+ forcings: Optional forcing for the model.
249
+ num_steps_per_chunk: How many of the steps in `targets_template` to predict
250
+ at each call of `predictor_fn`. It must evenly divide the number of
251
+ steps in `targets_template`.
252
+ verbose: Whether to log the current chunk being predicted.
253
+
254
+ Returns:
255
+ Predictions for the targets template.
256
+
257
+ """
258
+ chunks_list = []
259
+ for prediction_chunk in chunked_prediction_generator(
260
+ predictor_fn=predictor_fn,
261
+ rng=rng,
262
+ inputs=inputs,
263
+ targets_template=targets_template,
264
+ forcings=forcings,
265
+ num_steps_per_chunk=num_steps_per_chunk,
266
+ verbose=verbose,
267
+ ):
268
+ chunks_list.append(jax.device_get(prediction_chunk))
269
+ return xarray.concat(chunks_list, dim="time")
270
+
271
+
272
+ def chunked_prediction_generator(
273
+ predictor_fn: PredictorFn,
274
+ rng: chex.PRNGKey,
275
+ inputs: xarray.Dataset,
276
+ targets_template: xarray.Dataset,
277
+ forcings: xarray.Dataset,
278
+ num_steps_per_chunk: int = 1,
279
+ verbose: bool = False,
280
+ pmap_devices: Sequence[jax.Device] | None = None,
281
+ replica_axis: str | None = None,
282
+ ) -> Iterator[xarray.Dataset]:
283
+ """Outputs a long trajectory by yielding chunked predictions.
284
+
285
+ Args:
286
+ predictor_fn: Function to use to make predictions for each chunk.
287
+ rng: Random key.
288
+ inputs: Inputs for the model.
289
+ targets_template: Template for the target prediction, requires targets
290
+ equispaced in time.
291
+ forcings: Optional forcing for the model.
292
+ num_steps_per_chunk: How many of the steps in `targets_template` to predict
293
+ at each call of `predictor_fn`. It must evenly divide the number of
294
+ steps in `targets_template`.
295
+ verbose: Whether to log the current chunk being predicted.
296
+ pmap_devices: List of devices over which predictor_fn is pmapped, or None if
297
+ it is not pmapped.
298
+ replica_axis: Dimension name to use for the replicas.
299
+
300
+ Yields:
301
+ The predictions for each chunked step of the chunked rollout, such as
302
+ if all predictions are concatenated in time this would match the targets
303
+ template in structure.
304
+
305
+ """
306
+
307
+ if pmap_devices is not None and replica_axis is None:
308
+ raise ValueError("Must provide replica_axis when pmap_devices is provided.")
309
+
310
+ # Create copies to avoid mutating inputs.
311
+ inputs = inputs.copy()
312
+ targets_template = targets_template.copy()
313
+ forcings = forcings.copy()
314
+
315
+ if "datetime" in inputs.coords:
316
+ del inputs.coords["datetime"]
317
+
318
+ if "datetime" in targets_template.coords:
319
+ output_datetime = targets_template.coords["datetime"]
320
+ del targets_template.coords["datetime"]
321
+ else:
322
+ output_datetime = None
323
+
324
+ if "datetime" in forcings.coords:
325
+ del forcings.coords["datetime"]
326
+
327
+ num_target_steps = targets_template.dims["time"]
328
+ num_chunks, remainder = divmod(num_target_steps, num_steps_per_chunk)
329
+ if remainder != 0:
330
+ raise ValueError(
331
+ f"The number of steps per chunk {num_steps_per_chunk} must "
332
+ f"evenly divide the number of target steps {num_target_steps} ")
333
+
334
+ if len(np.unique(np.diff(targets_template.coords["time"].data))) > 1:
335
+ raise ValueError("The targets time coordinates must be evenly spaced")
336
+
337
+ # Our template targets will always have a time axis corresponding for the
338
+ # timedeltas for the first chunk.
339
+ targets_chunk_time = targets_template.time.isel(
340
+ time=slice(0, num_steps_per_chunk))
341
+
342
+ current_inputs = inputs
343
+
344
+ def split_rng_fn(rng):
345
+ # Note, this is *not* equivalent to `return jax.random.split(rng)`, because
346
+ # by assigning to a tuple, the single numpy array returned by
347
+ # `jax.random.split` actually gets split into two arrays, so when calling
348
+ # the function with pmap the output is Tuple[Array, Array], where the
349
+ # leading axis of each array is `num devices`.
350
+ rng1, rng2 = jax.random.split(rng)
351
+ return rng1, rng2
352
+
353
+ if pmap_devices is not None:
354
+ split_rng_fn = jax.pmap(
355
+ split_rng_fn, devices=pmap_devices, axis_name=replica_axis
356
+ )
357
+
358
+ for chunk_index in range(num_chunks):
359
+ if verbose:
360
+ logging.info("Chunk %d/%d", chunk_index, num_chunks)
361
+ logging.flush()
362
+
363
+ # Select targets for the time period that we are predicting for this chunk.
364
+ target_offset = num_steps_per_chunk * chunk_index
365
+ target_slice = slice(target_offset, target_offset + num_steps_per_chunk)
366
+ current_targets_template = targets_template.isel(time=target_slice)
367
+
368
+ # Replace the timedelta, by the one corresponding to the first chunk, so we
369
+ # don't recompile at every iteration, keeping the
370
+ actual_target_time = current_targets_template.coords["time"]
371
+ current_targets_template = current_targets_template.assign_coords(
372
+ time=targets_chunk_time).compute()
373
+
374
+ current_forcings = forcings.isel(time=target_slice)
375
+ current_forcings = current_forcings.assign_coords(time=targets_chunk_time)
376
+ current_forcings = current_forcings.compute()
377
+ # Make predictions for the chunk.
378
+ rng, this_rng = split_rng_fn(rng)
379
+ predictions = predictor_fn(
380
+ rng=this_rng,
381
+ inputs=current_inputs,
382
+ targets_template=current_targets_template,
383
+ forcings=current_forcings)
384
+
385
+ # In the pmapped case, profiling reveals that the predictions, forcings and
386
+ # inputs are all copied onto a single TPU, causing OOM. To avoid this
387
+ # we pull all of the input/output data off the devices. This will have
388
+ # some performance impact, but maximise the memory efficiency.
389
+ # TODO(aelkadi): Pmap `_get_next_inputs` when running under pmap, and
390
+ # remove the device_get.
391
+ if pmap_devices is not None:
392
+ predictions = jax.device_get(predictions)
393
+ current_forcings = jax.device_get(current_forcings)
394
+ current_inputs = jax.device_get(current_inputs)
395
+
396
+ if chunk_index == num_chunks - 1:
397
+ # No need to call `_get_next_inputs` on the last iteration.
398
+ current_inputs = None
399
+ else:
400
+ next_frame = xarray.merge([predictions, current_forcings])
401
+ next_inputs = _get_next_inputs(current_inputs, next_frame)
402
+ # Shift timedelta coordinates, so we don't recompile at every iteration.
403
+ next_inputs = next_inputs.assign_coords(
404
+ time=current_inputs.coords["time"])
405
+ current_inputs = next_inputs
406
+
407
+ # At this point we can assign the actual targets time coordinates.
408
+ predictions = predictions.assign_coords(time=actual_target_time)
409
+ if output_datetime is not None:
410
+ predictions.coords["datetime"] = output_datetime.isel(
411
+ time=target_slice)
412
+ yield predictions
413
+ del predictions
414
+
415
+
416
+ def _get_next_inputs(
417
+ prev_inputs: xarray.Dataset, next_frame: xarray.Dataset,
418
+ ) -> xarray.Dataset:
419
+ """Computes next inputs, from previous inputs and predictions."""
420
+
421
+ # Make sure are are predicting all inputs with a time axis.
422
+ non_predicted_or_forced_inputs = list(
423
+ set(prev_inputs.keys()) - set(next_frame.keys()))
424
+ if "time" in prev_inputs[non_predicted_or_forced_inputs].dims:
425
+ raise ValueError(
426
+ "Found an input with a time index that is not predicted or forced.")
427
+
428
+ # Keys we need to copy from predictions to inputs.
429
+ next_inputs_keys = list(
430
+ set(next_frame.keys()).intersection(set(prev_inputs.keys())))
431
+ next_inputs = next_frame[next_inputs_keys]
432
+
433
+ # Apply concatenate next frame with inputs, crop what we don't need.
434
+ num_inputs = prev_inputs.dims["time"]
435
+ return (
436
+ xarray.concat(
437
+ [prev_inputs, next_inputs], dim="time", data_vars="different")
438
+ .tail(time=num_inputs))
439
+
440
+
441
+ def extend_targets_template(
442
+ targets_template: xarray.Dataset,
443
+ required_num_steps: int) -> xarray.Dataset:
444
+ """Extends `targets_template` to `required_num_steps` with lazy arrays.
445
+
446
+ It uses lazy dask arrays of zeros, so it does not require instantiating the
447
+ array in memory.
448
+
449
+ Args:
450
+ targets_template: Input template to extend.
451
+ required_num_steps: Number of steps required in the returned template.
452
+
453
+ Returns:
454
+ `xarray.Dataset` identical in variables and timestep to `targets_template`
455
+ full of `dask.array.zeros` such that the time axis has `required_num_steps`.
456
+
457
+ """
458
+
459
+ # Extend the "time" and "datetime" coordinates
460
+ time = targets_template.coords["time"]
461
+
462
+ # Assert the first target time corresponds to the timestep.
463
+ timestep = time[0].data
464
+ if time.shape[0] > 1:
465
+ assert np.all(timestep == time[1:] - time[:-1])
466
+
467
+ extended_time = (np.arange(required_num_steps) + 1) * timestep
468
+
469
+ if "datetime" in targets_template.coords:
470
+ datetime = targets_template.coords["datetime"]
471
+ extended_datetime = (datetime[0].data - timestep) + extended_time
472
+ else:
473
+ extended_datetime = None
474
+
475
+ # Replace the values with empty dask arrays extending the time coordinates.
476
+ datetime = targets_template.coords["time"]
477
+
478
+ def extend_time(data_array: xarray.DataArray) -> xarray.DataArray:
479
+ dims = data_array.dims
480
+ shape = list(data_array.shape)
481
+ shape[dims.index("time")] = required_num_steps
482
+ dask_data = dask.array.zeros(
483
+ shape=tuple(shape),
484
+ chunks=-1, # Will give chunk info directly to `ChunksToZarr``.
485
+ dtype=data_array.dtype)
486
+
487
+ coords = dict(data_array.coords)
488
+ coords["time"] = extended_time
489
+
490
+ if extended_datetime is not None:
491
+ coords["datetime"] = ("time", extended_datetime)
492
+
493
+ return xarray.DataArray(
494
+ dims=dims,
495
+ data=dask_data,
496
+ coords=coords)
497
+
498
+ return xarray_tree.map_structure(extend_time, targets_template)
model/graphcast/samplers_base.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2024 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Base class for diffusion samplers."""
15
+
16
+ import abc
17
+ from typing import Optional
18
+
19
+ from . import denoisers_base
20
+ import xarray
21
+
22
+
23
+ class Sampler(abc.ABC):
24
+ """A sampling algorithm for a denoising diffusion model.
25
+
26
+ This is constructed with a denoising function, and uses it to draw samples.
27
+ """
28
+
29
+ _denoiser: denoisers_base.Denoiser
30
+
31
+ def __init__(self, denoiser: denoisers_base.Denoiser):
32
+ """Constructs Sampler.
33
+
34
+ Args:
35
+ denoiser: A Denoiser which has been trained with an MSE loss to predict
36
+ the noise-free targets.
37
+ """
38
+ self._denoiser = denoiser
39
+
40
+ @abc.abstractmethod
41
+ def __call__(
42
+ self,
43
+ inputs: xarray.Dataset,
44
+ targets_template: xarray.Dataset,
45
+ forcings: Optional[xarray.Dataset] = None,
46
+ **kwargs) -> xarray.Dataset:
47
+ """Draws a sample using self._denoiser. Contract like Predictor.__call__."""
model/graphcast/samplers_utils.py ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2024 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Utils for diffusion samplers. Makes use of dinosaur.spherical_harmonic."""
15
+
16
+ import dataclasses
17
+ import functools
18
+ from typing import Any, cast, Optional, Tuple
19
+
20
+ import chex
21
+ from dinosaur import spherical_harmonic
22
+ from . import xarray_jax
23
+ from . import xarray_tree
24
+ import haiku as hk
25
+ import jax
26
+ import jax.numpy as jnp
27
+ import numpy as np
28
+ import xarray
29
+
30
+ # Some useful constants useful when dealing with Earth's geometry.
31
+ # The earth isn't really a sphere so these are only approximate, this is the
32
+ # average radius according to https://en.wikipedia.org/wiki/Earth_radius,
33
+ # with the actual value varying from 6378 to 6357km.
34
+ EARTH_RADIUS_KM = 6371.
35
+ # And this is also approximate, but we've chosen to make it consistent with the
36
+ # radius above when modelling the earth as a sphere. This gives a value of
37
+ # around 40030; the actual value varies from 40008 to 40075.
38
+ EARTH_CIRCUMFERENCE_KM = EARTH_RADIUS_KM * 2 * np.pi
39
+
40
+
41
+ @dataclasses.dataclass(frozen=True)
42
+ class _ArrayGrid:
43
+ """A class that performs operations and transformations in the spectral basis.
44
+
45
+ Attributes:
46
+ longitude_wavenumbers: num of longitude wavenumbers in the spectral basis.
47
+ total_wavenumbers: number of total wavenumbers in the spectral basis.
48
+ longitude_nodes: number of quadrature nodes along the lon direction.
49
+ latitude_nodes: number of quadrature nodes along the lat direction.
50
+ latitude_spacing: either 'gauss' or 'equiangular'. This determines the
51
+ spacing of nodal grid points in the latitudinal (north-south) direction.
52
+ """
53
+ longitude_wavenumbers: int
54
+ total_wavenumbers: int
55
+ longitude_nodes: int
56
+ latitude_nodes: int
57
+ latitude_spacing: str
58
+
59
+ @classmethod
60
+ def with_lat_lon(
61
+ cls,
62
+ lat: np.ndarray,
63
+ lon: np.ndarray,
64
+ ) -> '_ArrayGrid':
65
+ """_ArrayGrid for use with data in specified lat/lon grid (in degrees)."""
66
+
67
+ latitude_nodes = lat.shape[0]
68
+ longitude_nodes = lon.shape[0]
69
+ latitude_spacing = _infer_latitude_spacing(lat)
70
+ if latitude_spacing in ['equiangular', 'gauss']:
71
+ if longitude_nodes != 2 * latitude_nodes:
72
+ # Technically not a requirement but useful to ensure `max_wavenumber`
73
+ # makes sense.
74
+ raise ValueError(
75
+ 'Unexpected number of longitude nodes. '
76
+ f'Expected {2 * latitude_nodes}, got {longitude_nodes}')
77
+ elif latitude_spacing == 'equiangular_with_poles':
78
+ if longitude_nodes != 2 * (latitude_nodes - 1):
79
+ # Technically not a requirement but useful to ensure `max_wavenumber`
80
+ # makes s
81
+ raise ValueError(
82
+ 'Unexpected number of longitude nodes. '
83
+ f'Expected {2 * (latitude_nodes - 1)}, got {longitude_nodes}')
84
+ else:
85
+ raise ValueError(f'Unexpected latitude_spacing={latitude_spacing}')
86
+ max_wavenumber = int(longitude_nodes // 2) - 1
87
+ grid = cls(
88
+ longitude_wavenumbers=max_wavenumber+1,
89
+ # total_wavenumbers should be one larger than max_wavenumber as the
90
+ # wavenumbers go from 0 to max_wavenumber inclusive.
91
+ total_wavenumbers=max_wavenumber+1,
92
+ longitude_nodes=longitude_nodes,
93
+ latitude_nodes=latitude_nodes,
94
+ latitude_spacing=latitude_spacing,
95
+ )
96
+ _verify_nodal_axes(lat, lon, grid.nodal_axes)
97
+ return grid
98
+
99
+ @functools.cached_property
100
+ def _grid(self) -> spherical_harmonic.Grid:
101
+ return spherical_harmonic.Grid(
102
+ spherical_harmonics_impl=spherical_harmonic.RealSphericalHarmonics,
103
+ **dataclasses.asdict(self),
104
+ )
105
+
106
+ @functools.cached_property
107
+ def nodal_axes(self) -> Tuple[np.ndarray, np.ndarray]:
108
+ """Longitude and sin(latitude) coordinates of the nodal basis."""
109
+ return self._grid.nodal_axes
110
+
111
+ @functools.cached_property
112
+ def modal_axes(self) -> Tuple[np.ndarray, np.ndarray]:
113
+ """Longitudinal and total wavenumbers (m, l) of the modal basis."""
114
+ return self._grid.modal_axes
115
+
116
+ def to_nodal(self, x: chex.Array) -> chex.Array:
117
+ """Maps `x` from a modal to nodal representation."""
118
+ return self._grid.to_nodal(x)
119
+
120
+
121
+ def _infer_latitude_spacing(lat: np.ndarray) -> str:
122
+ """Infers the type of latitude spacing given the latitude."""
123
+ if not np.all(np.diff(lat) > 0.):
124
+ raise ValueError('Latitude values are expected to be sorted.')
125
+
126
+ if np.allclose(np.diff(lat), lat[1] - lat[0]):
127
+ if np.isclose(max(lat), 90.):
128
+ spacing = 'equiangular_with_poles'
129
+ else:
130
+ spacing = 'equiangular'
131
+ else:
132
+ spacing = 'gauss'
133
+ return spacing
134
+
135
+
136
+ def _verify_nodal_axes(lat_coords: np.ndarray, lon_coords: np.ndarray,
137
+ nodal_axes: Tuple[np.ndarray, np.ndarray]):
138
+ nodal_axes_lon, nodal_axes_sin_lat = nodal_axes
139
+ if not np.allclose(nodal_axes_sin_lat, np.sin(np.deg2rad(lat_coords))):
140
+ raise ValueError(
141
+ "Latitude coords don't match those used by "
142
+ "spherical_harmonic.SphericalHarmonicBasis.")
143
+ if not np.allclose(nodal_axes_lon, np.deg2rad(lon_coords)):
144
+ raise ValueError(
145
+ "Longitude coords don't match those used by "
146
+ "spherical_harmonic.SphericalHarmonicBasis.")
147
+
148
+
149
+ class Grid:
150
+ """xarray wrapper around _ArrayGrid."""
151
+
152
+ @classmethod
153
+ def for_nodal_data(
154
+ cls,
155
+ nodal_data: xarray.DataArray,
156
+ ) -> 'Grid':
157
+ """A Grid for use with a given shape of nodal (lat/lon grid) data.
158
+
159
+ This uses the maximum number of spherical harmonics that the grid is able
160
+ to resolve.
161
+
162
+ This class supports data arrays with latitude spacings as defined by
163
+ "dinosaur.spherical_harmonic". In summary:
164
+ * 'equiangular': equally spaced (by `d_lat`) values between -90 + d_lat and
165
+ 90 - d_lat / 2. In our case, longitude must also be spaced by `d_lat`.
166
+ * 'equiangular_with_poles': equally spaced (by `d_lat`) values between -90
167
+ and 90. In our case, longitude must also be spaced by `d_lat`.
168
+ * 'gauss': Gauss-Legendre nodes.
169
+
170
+ Args:
171
+ nodal_data: An xarray with 'lat' and 'lon' dimensions and coordinates in
172
+ degrees.
173
+
174
+ Returns:
175
+ A grid with the specified latitude_nodes, with
176
+ longitude_nodes=2*latitude_nodes and max_wavenumber=latitude_nodes-1.
177
+ """
178
+
179
+ grid = _ArrayGrid.with_lat_lon(
180
+ nodal_data.coords['lat'].data,
181
+ nodal_data.coords['lon'].data)
182
+ return cls(grid,
183
+ nodal_data.coords['lat'].data,
184
+ nodal_data.coords['lon'].data)
185
+
186
+ def __init__(self,
187
+ grid: _ArrayGrid,
188
+ lat_coords: np.ndarray,
189
+ lon_coords: np.ndarray):
190
+ _verify_nodal_axes(lat_coords, lon_coords, grid.nodal_axes)
191
+ self._underlying = grid
192
+ # Record the exact original lat/lon coords so we can return them exactly
193
+ # from an inverse transform, avoiding any xarray merge issues if coordinates
194
+ # are off by a rounding error.
195
+ self._lat_coords = lat_coords
196
+ self._lon_coords = lon_coords
197
+ self._longitude_wavenumber_coords, self._total_wavenumber_coords = (
198
+ grid.modal_axes)
199
+
200
+ @property
201
+ def total_wavenumber_coords(self) -> xarray.DataArray:
202
+ """Coords that must be used for 'total_wavenumber' dimension."""
203
+ return xarray.DataArray(
204
+ data=self._total_wavenumber_coords,
205
+ dims=('total_wavenumber',),
206
+ coords={'total_wavenumber': self._total_wavenumber_coords})
207
+
208
+ @property
209
+ def longitude_wavenumber_coords(self) -> xarray.DataArray:
210
+ """Coords that must be used for 'longitude_wavenumber' dimension."""
211
+ return xarray.DataArray(
212
+ data=self._longitude_wavenumber_coords,
213
+ dims=('longitude_wavenumber',),
214
+ coords={'longitude_wavenumber': self._longitude_wavenumber_coords})
215
+
216
+ def to_nodal(
217
+ self, modal_data: xarray.DataArray) -> xarray.DataArray:
218
+ """Applies the inverse spherical harmonic transform.
219
+
220
+ Args:
221
+ modal_data: A tree of xarray.DataArray with 'longitude_wavenumber' and
222
+ 'total_wavenumber' dimensions with coords
223
+ `self.longitude_wavenumber_coords` and `self.total_wavenumber_coords`
224
+ respectively, and with the same sparsity pattern described under
225
+ `to_modal`.
226
+
227
+ Returns:
228
+ Corresponding tree where the 'longitude_wavenumber' and
229
+ 'total_wavenumber' dimensions are replaced by 'lat', 'lon' dimensions.
230
+ """
231
+ def inverse_transform(modal: xarray.DataArray) -> xarray.DataArray:
232
+ if (not np.all(modal.coords['longitude_wavenumber'] ==
233
+ self._longitude_wavenumber_coords) or
234
+ not np.all(modal.coords['total_wavenumber'] ==
235
+ self._total_wavenumber_coords)):
236
+ raise ValueError('Wavenumber coords don\'t follow required convention.')
237
+
238
+ return xarray_jax.apply_ufunc(
239
+ self._underlying.to_nodal, modal,
240
+ input_core_dims=[['longitude_wavenumber', 'total_wavenumber']],
241
+ output_core_dims=[['lon', 'lat']],
242
+ ).assign_coords(
243
+ lon=self._lon_coords,
244
+ lat=self._lat_coords,
245
+ )
246
+
247
+ return xarray_tree.map_structure(inverse_transform, modal_data)
248
+
249
+
250
+ def sample(
251
+ key: jnp.ndarray,
252
+ power_spectrum: xarray.DataArray,
253
+ template: xarray.DataArray,
254
+ grid: Optional[Grid] = None,
255
+ ) -> xarray.DataArray:
256
+ """Samples Gaussian Process noise on a sphere, with a given power spectrum.
257
+
258
+ This means the noise will have the given power spectrum *in expectation*; the
259
+ power spectrum of individual samples may vary.
260
+
261
+ The noise will be isotropic, meaning the distribution is invariant to
262
+ rotations of the sphere.
263
+
264
+ The marginal variance of the returned values will be equal to the total power,
265
+ i.e. the sum of power_spectrum. So if you want unit marginal variance, just
266
+ make sure to normalize the power_spectrum to sum to 1.
267
+
268
+ Args:
269
+ key: JAX rng key.
270
+ power_spectrum: An array with shape (total_wavenumber,) giving the power
271
+ which is desired at each total wavenumber (corresponding to a wavelength
272
+ EARTH_CIRCUMFERENCE/total_wavenumber) for total wavenumbers 0 up to some
273
+ maximum. This is in squared units of the quantity being sampled.
274
+ template: An array with the shape that you want the samples in, containing
275
+ 'lat' and 'lon' dimensions. If other dimensions are present, we draw
276
+ multiple independent samples along these other dimensions.
277
+ grid: spherical_harmonic.Grid on which to sample the noise. If not specified
278
+ a grid will be created based on `template`, however note you may save some
279
+ RAM and compute by re-using a single Grid instance across multiple calls.
280
+
281
+ Returns:
282
+ DataArray with the same shape as template.
283
+ """
284
+ if grid is None:
285
+ grid = Grid.for_nodal_data(template)
286
+ dims = [d for d in template.dims if d not in ('lat', 'lon')]
287
+ shape = [template.sizes[d] for d in dims]
288
+ coords = {name: coord for name, coord in template.coords.items()
289
+ if name not in ('lat', 'lon')}
290
+ dims.extend(('total_wavenumber', 'longitude_wavenumber'))
291
+ shape.extend((len(grid.total_wavenumber_coords),
292
+ len(grid.longitude_wavenumber_coords)))
293
+ coords.update({'total_wavenumber': grid.total_wavenumber_coords,
294
+ 'longitude_wavenumber': grid.longitude_wavenumber_coords})
295
+ coeffs = xarray_jax.DataArray(
296
+ data=jax.random.normal(key, shape), dims=dims, coords=coords)
297
+ # Mask out coefficients which are out of range. This broadcasts to a
298
+ # triangular mask with shape (total_wavenumber, longitude_wavenumber):
299
+ mask = (
300
+ abs(coeffs.longitude_wavenumber) <= coeffs.total_wavenumber
301
+ ).astype(np.float32)
302
+ # For total_wavenumber t, there will be 2t+1 non-zero coefficients at
303
+ # different longitude_wavenumbers. We must normalize the coefficients so that
304
+ # summing their squares at each total_wavenumber, sums to the corresponding
305
+ # value in the power spectrum:
306
+ multiplier = mask * np.sqrt(power_spectrum / mask.sum(
307
+ 'longitude_wavenumber', skipna=False))
308
+ # And a standard normalization factor used in this implementation of the
309
+ # spherical harmonic transform:
310
+ multiplier *= np.sqrt(4 * np.pi)
311
+ # Only finally multiply by coeffs to avoid too many broadcasting
312
+ # multiplications:
313
+ coeffs *= multiplier
314
+ result = cast(xarray.DataArray, grid.to_nodal(coeffs))
315
+ result = result.astype(template.dtype)
316
+ return result.transpose(*template.dims)
317
+
318
+
319
+ def spherical_white_noise_like(template: xarray.Dataset) -> xarray.Dataset:
320
+ """Samples isotropic mean 0 variance 1 white noise on the sphere."""
321
+ def spherical_white_noise_like_dataarray(data_array: xarray.DataArray
322
+ ) -> xarray.DataArray:
323
+ num_wavenumbers = data_array.lon.shape[0] // 2
324
+ key = hk.next_rng_key()
325
+ return sample(
326
+ key=key,
327
+ power_spectrum=xarray_jax.DataArray(
328
+ data=np.array([1/num_wavenumbers for _ in range(num_wavenumbers)]),
329
+ dims=['total_wavenumber']),
330
+ template=data_array)
331
+ return template.map(spherical_white_noise_like_dataarray)
332
+
333
+
334
+ def rho_inverse_cdf(
335
+ min_value: float,
336
+ max_value: float,
337
+ rho: float,
338
+ cdf: Any) -> Any:
339
+ """Quantiles of rho distribution used for noise levels at sampling time.
340
+
341
+ This is parameterised by rho as in Eqn 5 from the Elucidating paper
342
+ (but with max/min flipped so that quantiles are given in ascending not
343
+ descending order). It's equivalent to a Beta[rho, 1] distribution rescaled to
344
+ [min_value, max_value].
345
+
346
+ At sampling time we use noise levels at fixed quantiles of this distribution.
347
+ Unlike in the paper, we also use the same distribution for noise levels at
348
+ training time (albeit potentially with different parameters, and sampling from
349
+ it at random).
350
+
351
+ Args:
352
+ min_value:
353
+ max_value:
354
+ Define the support of the distribution.
355
+ rho:
356
+ Shape parameter.
357
+ cdf:
358
+ Value or values between 0 and 1 indicating which quantile you want. Can
359
+ be a numpy or jax array.
360
+
361
+ Returns:
362
+ Quantiles of the distribution, with same shape/type as `cdf`.
363
+ """
364
+ return (
365
+ min_value**(1 / rho) + cdf *
366
+ (max_value**(1 / rho) - min_value**(1 / rho))
367
+ )**rho
368
+
369
+
370
+ def tree_where(
371
+ cond: jnp.ndarray,
372
+ xs: Any,
373
+ ys: Any
374
+ ) -> Any:
375
+ """Like jnp.where but works with trees for xs and ys (but not for cond)."""
376
+ return jax.tree_util.tree_map(lambda x, y: jnp.where(cond, x, y), xs, ys)
377
+
378
+
379
+ def noise_schedule(
380
+ max_noise_level: float = 80.,
381
+ min_noise_level: float = 0.002,
382
+ num_noise_levels: int = 30,
383
+ rho: float = 7.,
384
+ ) -> np.ndarray:
385
+ """Computes a descending noise schedule for sampling, ending with zero."""
386
+ noise_levels = rho_inverse_cdf(
387
+ min_value=min_noise_level,
388
+ max_value=max_noise_level,
389
+ rho=rho,
390
+ # We want the noise levels in descending order, so ask for quantiles
391
+ # 1 down to 0:
392
+ cdf=np.linspace(1, 0, num_noise_levels))
393
+ # The final zero noise level is somewhat special-cased. We don't actually
394
+ # denoise from this noise level but appending it here is convenient for
395
+ # sampling loop implementations.
396
+ return np.append(noise_levels, 0.)
397
+
398
+
399
+ def stochastic_churn_rate_schedule(
400
+ noise_levels: np.ndarray,
401
+ stochastic_churn_rate: float = 0.,
402
+ churn_min_noise_level: float = 0.05,
403
+ churn_max_noise_level: float = 50.0,
404
+ ) -> np.ndarray:
405
+ """Computes a stochastic churn rate for each noise level."""
406
+ num_noise_levels = len(noise_levels)-1 # Exclude final zero noise level.
407
+ # As in the Elucidated Diffusion paper, clamp this so it doesn't increase the
408
+ # variance by a factor of more than 2, no matter how few noise levels are
409
+ # used:
410
+ per_step_churn_rate = min(stochastic_churn_rate / num_noise_levels,
411
+ np.sqrt(2) - 1)
412
+ return (
413
+ (churn_min_noise_level <= noise_levels[:-1]) &
414
+ (noise_levels[:-1] <= churn_max_noise_level)
415
+ ) * per_step_churn_rate
416
+
417
+
418
+ def apply_stochastic_churn(
419
+ x: Any,
420
+ noise_level: jax.typing.ArrayLike,
421
+ stochastic_churn_rate: jax.typing.ArrayLike,
422
+ noise_level_inflation_factor: jax.typing.ArrayLike,
423
+ ) -> tuple[Any, jax.typing.ArrayLike]:
424
+ """Returns x at higher noise level, and the higher noise level itself."""
425
+ # We increase the noise level of x a bit before taking it down again:
426
+ new_noise_level = noise_level * (1.0 + stochastic_churn_rate)
427
+ noise_diff = new_noise_level**2 - noise_level**2
428
+ # stochastic_churn_rate == 0 => new_noise_level == noise_level
429
+ # => noise_diff == 0. This can resolve to a negative value because of
430
+ # floating point rounding errors. To avoid this we clamp noise_diff to zero if
431
+ # it's negative.
432
+ noise_diff = jnp.maximum(noise_diff, 0)
433
+ extra_noise_stddev = jnp.sqrt(noise_diff)* noise_level_inflation_factor
434
+ updated_x = x + spherical_white_noise_like(x) * extra_noise_stddev
435
+ return updated_x, new_noise_level
436
+
model/graphcast/solar_radiation.py ADDED
@@ -0,0 +1,605 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2023 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Computes TOA incident solar radiation compatible with ERA5.
15
+
16
+ The Top-Of-the-Atmosphere (TOA) incident solar radiation is available in the
17
+ ERA5 dataset as the parameter `toa_incident_solar_radiation` (or `tisr`). This
18
+ represents the TOA solar radiation flux integrated over a period of one hour
19
+ ending at the timestamp given by the `datetime` coordinate. See
20
+ https://confluence.ecmwf.int/display/CKB/ERA5%3A+data+documentation and
21
+ https://codes.ecmwf.int/grib/param-db/?id=212.
22
+ """
23
+
24
+ from collections.abc import Callable, Sequence
25
+ import dataclasses
26
+ import functools
27
+
28
+ import chex
29
+ import jax
30
+ import jax.numpy as jnp
31
+ import numpy as np
32
+ import pandas as pd
33
+ import xarray as xa
34
+
35
+
36
+ # Default value of the `integration_period` argument to be compatible with ERA5.
37
+ _DEFAULT_INTEGRATION_PERIOD = pd.Timedelta(hours=1)
38
+
39
+ # Default value for the `num_integration_bins` argument. This provides a good
40
+ # approximation of the solar radiation in ERA5.
41
+ _DEFAULT_NUM_INTEGRATION_BINS = 360
42
+
43
+ # The length of a Julian year in days.
44
+ # https://en.wikipedia.org/wiki/Julian_year_(astronomy)
45
+ _JULIAN_YEAR_LENGTH_IN_DAYS = 365.25
46
+
47
+ # Julian Date for the J2000 epoch, a standard reference used in astronomy.
48
+ # https://en.wikipedia.org/wiki/Epoch_(astronomy)#Julian_years_and_J2000
49
+ _J2000_EPOCH = 2451545.0
50
+
51
+ # Number of seconds in a day.
52
+ _SECONDS_PER_DAY = 60 * 60 * 24
53
+
54
+
55
+ _TimestampLike = str | pd.Timestamp | np.datetime64
56
+ _TimedeltaLike = str | pd.Timedelta | np.timedelta64
57
+
58
+
59
+ # Interface for loading Total Solar Irradiance (TSI) data.
60
+ # Returns a xa.DataArray containing yearly average TSI values with a `time`
61
+ # coordinate in units of years since 0000-1-1. E.g. 2023.5 corresponds to
62
+ # the middle of the year 2023.
63
+ TsiDataLoader = Callable[[], xa.DataArray]
64
+
65
+
66
+ # Total Solar Irradiance (TSI): Energy input to the top of the Earth's
67
+ # atmosphere in W⋅m⁻². TSI varies with time. This is the reference TSI value
68
+ # that can be used when more accurate data is not available.
69
+ # https://www.ncei.noaa.gov/products/climate-data-records/total-solar-irradiance
70
+ # https://github.com/ecmwf-ifs/ecrad/blob/6db82f929fb75028cc20606a04da87c0abe9b642/radiation/radiation_ecckd.F90#L296
71
+ _REFERENCE_TSI = 1361.0
72
+
73
+
74
+ def reference_tsi_data() -> xa.DataArray:
75
+ """A TsiDataProvider that returns a single reference TSI value."""
76
+ return xa.DataArray(
77
+ np.array([_REFERENCE_TSI]),
78
+ dims=["time"],
79
+ coords={"time": np.array([0.0])},
80
+ )
81
+
82
+
83
+ def era5_tsi_data() -> xa.DataArray:
84
+ """A TsiDataProvider that returns ERA5 compatible TSI data."""
85
+ # ECMWF provided the data used for ERA5, which was hardcoded in the IFS (cycle
86
+ # 41r2). The values were scaled down to agree better with more recent
87
+ # observations of the sun.
88
+ time = np.arange(1951.5, 2035.5, 1.0)
89
+ tsi = 0.9965 * np.array([
90
+ # fmt: off
91
+ # 1951-1995 (non-repeating sequence)
92
+ 1365.7765, 1365.7676, 1365.6284, 1365.6564, 1365.7773,
93
+ 1366.3109, 1366.6681, 1366.6328, 1366.3828, 1366.2767,
94
+ 1365.9199, 1365.7484, 1365.6963, 1365.6976, 1365.7341,
95
+ 1365.9178, 1366.1143, 1366.1644, 1366.2476, 1366.2426,
96
+ 1365.9580, 1366.0525, 1365.7991, 1365.7271, 1365.5345,
97
+ 1365.6453, 1365.8331, 1366.2747, 1366.6348, 1366.6482,
98
+ 1366.6951, 1366.2859, 1366.1992, 1365.8103, 1365.6416,
99
+ 1365.6379, 1365.7899, 1366.0826, 1366.6479, 1366.5533,
100
+ 1366.4457, 1366.3021, 1366.0286, 1365.7971, 1365.6996,
101
+ # 1996-2008 (13 year cycle, repeated below)
102
+ 1365.6121, 1365.7399, 1366.1021, 1366.3851, 1366.6836,
103
+ 1366.6022, 1366.6807, 1366.2300, 1366.0480, 1365.8545,
104
+ 1365.8107, 1365.7240, 1365.6918,
105
+ # 2009-2021
106
+ 1365.6121, 1365.7399, 1366.1021, 1366.3851, 1366.6836,
107
+ 1366.6022, 1366.6807, 1366.2300, 1366.0480, 1365.8545,
108
+ 1365.8107, 1365.7240, 1365.6918,
109
+ # 2022-2034
110
+ 1365.6121, 1365.7399, 1366.1021, 1366.3851, 1366.6836,
111
+ 1366.6022, 1366.6807, 1366.2300, 1366.0480, 1365.8545,
112
+ 1365.8107, 1365.7240, 1365.6918,
113
+ # fmt: on
114
+ ])
115
+ return xa.DataArray(tsi, dims=["time"], coords={"time": time})
116
+
117
+
118
+ # HRES compatible TSI data is from IFS cycle 47r1. The dataset can be obtained
119
+ # from the ECRAD package: https://confluence.ecmwf.int/display/ECRAD.
120
+ # The example code below can load this dataset from a local file.
121
+
122
+ # def hres_tsi_data() -> xa.DataArray:
123
+ # with open("total_solar_irradiance_CMIP6_47r1.nc", "rb") as f:
124
+ # with xa.load_dataset(f, decode_times=False) as ds:
125
+ # return ds["tsi"]
126
+
127
+
128
+ _DEFAULT_TSI_DATA_LOADER: TsiDataLoader = era5_tsi_data
129
+
130
+
131
+ def get_tsi(
132
+ timestamps: Sequence[_TimestampLike], tsi_data: xa.DataArray
133
+ ) -> chex.Array:
134
+ """Returns TSI values for the given timestamps.
135
+
136
+ TSI values are interpolated from the provided yearly TSI data.
137
+
138
+ Args:
139
+ timestamps: Timestamps for which to compute TSI values.
140
+ tsi_data: A DataArray with a single dimension `time` that has coordinates in
141
+ units of years since 0000-1-1. E.g. 2023.5 corresponds to the middle of
142
+ the year 2023.
143
+
144
+ Returns:
145
+ An Array containing interpolated TSI data.
146
+ """
147
+ timestamps = pd.DatetimeIndex(timestamps)
148
+ timestamps_date = pd.DatetimeIndex(timestamps.date)
149
+ day_fraction = (timestamps - timestamps_date) / pd.Timedelta(days=1)
150
+ year_length = 365 + timestamps.is_leap_year
151
+ year_fraction = (timestamps.dayofyear - 1 + day_fraction) / year_length
152
+ fractional_year = timestamps.year + year_fraction
153
+ return np.interp(fractional_year, tsi_data.coords["time"].data, tsi_data.data)
154
+
155
+
156
+ @dataclasses.dataclass(frozen=True)
157
+ class _OrbitalParameters:
158
+ """Parameters characterising Earth's position relative to the Sun.
159
+
160
+ The parameters characterize the position of the Earth in its orbit around the
161
+ Sun for specific points in time. Each attribute is an N-dimensional array
162
+ to represent orbital parameters for multiple points in time.
163
+
164
+ Attributes:
165
+ theta: The number of Julian years since the Julian epoch J2000.0.
166
+ rotational_phase: The phase of the Earth's rotation along its axis as a
167
+ ratio with 0 representing the phase at Julian epoch J2000.0 at exactly
168
+ 12:00 Terrestrial Time (TT). Multiplying this value by `2*pi` yields the
169
+ phase in radians.
170
+ sin_declination: Sine of the declination of the Sun as seen from the Earth.
171
+ cos_declination: Cosine of the declination of the Sun as seen from the
172
+ Earth.
173
+ eq_of_time_seconds: The value of the equation of time, in seconds.
174
+ solar_distance_au: Earth-Sun distance in astronomical units.
175
+ """
176
+
177
+ theta: chex.Array
178
+ rotational_phase: chex.Array
179
+ sin_declination: chex.Array
180
+ cos_declination: chex.Array
181
+ eq_of_time_seconds: chex.Array
182
+ solar_distance_au: chex.Array
183
+
184
+
185
+ def _get_j2000_days(timestamp: pd.Timestamp) -> float:
186
+ """Returns the number of days since the J2000 epoch.
187
+
188
+ Args:
189
+ timestamp: A timestamp for which to compute the J2000 days.
190
+
191
+ Returns:
192
+ The J2000 days corresponding to the input timestamp.
193
+ """
194
+ return timestamp.to_julian_date() - _J2000_EPOCH
195
+
196
+
197
+ def _get_orbital_parameters(j2000_days: chex.Array) -> _OrbitalParameters:
198
+ """Computes the orbital parameters for the given J2000 days.
199
+
200
+ Args:
201
+ j2000_days: Timestamps represented as the number of days since the J2000
202
+ epoch.
203
+
204
+ Returns:
205
+ Orbital parameters for the given timestamps. Each attribute of the return
206
+ value is an array containing the same dimensions as the input.
207
+ """
208
+ # Orbital parameters are computed based on the formulas in this code, which
209
+ # were determined empirically to produce radiation values similar to ERA5:
210
+ # https://github.com/ECCC-ASTD-MRD/gem/blob/1d711f7b89971cd7b1e10afc7508d1135b51397d/src/rpnphy/src/base/sucst.F90
211
+ # https://github.com/ECCC-ASTD-MRD/gem/blob/1d711f7b89971cd7b1e10afc7508d1135b51397d/src/rpnphy/src/base/fctast.cdk
212
+ # https://github.com/ECCC-ASTD-MRD/gem/blob/1d711f7b89971cd7b1e10afc7508d1135b51397d/src/rpnphy/src/base/fcttim.cdk
213
+ # There are many variations to these formulas, but since the goal is to match
214
+ # the values in ERA5, the formulas were implemented as is. Comments reference
215
+ # the notation used in those sources. Here are some additional references
216
+ # related to the quantities being computed here:
217
+ # https://aa.usno.navy.mil/faq/sun_approx
218
+ # https://en.wikipedia.org/wiki/Position_of_the_Sun
219
+ # https://en.wikipedia.org/wiki/Equation_of_time
220
+
221
+ # Number of Julian years since the J2000 epoch (including fractional years).
222
+ theta = j2000_days / _JULIAN_YEAR_LENGTH_IN_DAYS
223
+ # The phase of the Earth's rotation along its axis as a ratio. 0 represents
224
+ # Julian epoch J2000.0 at exactly 12:00 Terrestrial Time (TT).
225
+ rotational_phase = j2000_days % 1.0
226
+
227
+ # REL(PTETA).
228
+ rel = 1.7535 + 6.283076 * theta
229
+ # REM(PTETA).
230
+ rem = 6.240041 + 6.283020 * theta
231
+ # RLLS(PTETA).
232
+ rlls = 4.8951 + 6.283076 * theta
233
+
234
+ # Variables used in the three polynomials below.
235
+ one = jnp.ones_like(theta)
236
+ sin_rel = jnp.sin(rel)
237
+ cos_rel = jnp.cos(rel)
238
+ sin_two_rel = jnp.sin(2.0 * rel)
239
+ cos_two_rel = jnp.cos(2.0 * rel)
240
+ sin_two_rlls = jnp.sin(2.0 * rlls)
241
+ cos_two_rlls = jnp.cos(2.0 * rlls)
242
+ sin_four_rlls = jnp.sin(4.0 * rlls)
243
+ sin_rem = jnp.sin(rem)
244
+ sin_two_rem = jnp.sin(2.0 * rem)
245
+
246
+ # Ecliptic longitude of the Sun - RLLLS(PTETA).
247
+ rllls = jnp.dot(
248
+ jnp.stack(
249
+ [one, theta, sin_rel, cos_rel, sin_two_rel, cos_two_rel], axis=-1
250
+ ),
251
+ jnp.array([4.8952, 6.283320, -0.0075, -0.0326, -0.0003, 0.0002]),
252
+ )
253
+
254
+ # Angle in radians between the Earth's rotational axis and its orbital axis.
255
+ # Equivalent to 23.4393°.
256
+ repsm = 0.409093
257
+
258
+ # Declination of the Sun - RDS(teta).
259
+ sin_declination = jnp.sin(repsm) * jnp.sin(rllls)
260
+ cos_declination = jnp.sqrt(1.0 - sin_declination**2)
261
+
262
+ # Equation of time in seconds - RET(PTETA).
263
+ eq_of_time_seconds = jnp.dot(
264
+ jnp.stack(
265
+ [
266
+ sin_two_rlls,
267
+ sin_rem,
268
+ sin_rem * cos_two_rlls,
269
+ sin_four_rlls,
270
+ sin_two_rem,
271
+ ],
272
+ axis=-1,
273
+ ),
274
+ jnp.array([591.8, -459.4, 39.5, -12.7, -4.8]),
275
+ )
276
+
277
+ # Earth-Sun distance in astronomical units - RRS(PTETA).
278
+ solar_distance_au = jnp.dot(
279
+ jnp.stack([one, sin_rel, cos_rel], axis=-1),
280
+ jnp.array([1.0001, -0.0163, 0.0037]),
281
+ )
282
+
283
+ return _OrbitalParameters(
284
+ theta=theta,
285
+ rotational_phase=rotational_phase,
286
+ sin_declination=sin_declination,
287
+ cos_declination=cos_declination,
288
+ eq_of_time_seconds=eq_of_time_seconds,
289
+ solar_distance_au=solar_distance_au,
290
+ )
291
+
292
+
293
+ def _get_solar_sin_altitude(
294
+ op: _OrbitalParameters,
295
+ sin_latitude: chex.Array,
296
+ cos_latitude: chex.Array,
297
+ longitude: chex.Array,
298
+ ) -> chex.Array:
299
+ """Returns the sine of the solar altitude angle.
300
+
301
+ All computations are vectorized. Dimensions of all the inputs should be
302
+ broadcastable using standard NumPy rules. For example, if `op` has shape
303
+ `(T, 1, 1)`, `latitude` has shape `(1, H, 1)`, and `longitude` has shape
304
+ `(1, H, W)`, the return value will have shape `(T, H, W)`.
305
+
306
+ Args:
307
+ op: Orbital parameters characterising Earth's position relative to the Sun.
308
+ sin_latitude: Sine of latitude coordinates.
309
+ cos_latitude: Cosine of latitude coordinates.
310
+ longitude: Longitude coordinates in radians.
311
+
312
+ Returns:
313
+ Sine of the solar altitude angle for each set of orbital parameters and each
314
+ geographical coordinates. The returned array has the shape resulting from
315
+ broadcasting all the inputs together.
316
+ """
317
+ solar_time = op.rotational_phase + op.eq_of_time_seconds / _SECONDS_PER_DAY
318
+ # https://en.wikipedia.org/wiki/Hour_angle#Solar_hour_angle
319
+ hour_angle = 2.0 * jnp.pi * solar_time + longitude
320
+ # https://en.wikipedia.org/wiki/Solar_zenith_angle
321
+ sin_altitude = (
322
+ cos_latitude * op.cos_declination * jnp.cos(hour_angle)
323
+ + sin_latitude * op.sin_declination
324
+ )
325
+ return sin_altitude
326
+
327
+
328
+ def _get_radiation_flux(
329
+ j2000_days: chex.Array,
330
+ sin_latitude: chex.Array,
331
+ cos_latitude: chex.Array,
332
+ longitude: chex.Array,
333
+ tsi: chex.Array,
334
+ ) -> chex.Array:
335
+ """Computes the instantaneous TOA incident solar radiation flux.
336
+
337
+ Computes the instantanous Top-Of-the-Atmosphere (TOA) incident radiation flux
338
+ in W⋅m⁻² for the given timestamps and locations on the surface of the Earth.
339
+ See https://en.wikipedia.org/wiki/Solar_irradiance.
340
+
341
+ All inputs are assumed to be broadcastable together using standard NumPy
342
+ rules.
343
+
344
+ Args:
345
+ j2000_days: Timestamps represented as the number of days since the J2000
346
+ epoch.
347
+ sin_latitude: Sine of latitude coordinates.
348
+ cos_latitude: Cosine of latitude coordinates.
349
+ longitude: Longitude coordinates in radians.
350
+ tsi: Total Solar Irradiance (TSI) in W⋅m⁻². This can be a scalar (default)
351
+ to use the same TSI value for all the inputs, or an array to allow TSI to
352
+ depend on the timestamps.
353
+
354
+ Returns:
355
+ The instataneous TOA incident solar radiation flux in W⋅m⁻² for the given
356
+ timestamps and geographical coordinates. The returned array has the shape
357
+ resulting from broadcasting all the inputs together.
358
+ """
359
+ op = _get_orbital_parameters(j2000_days)
360
+ # Attenuation of the solar radiation based on the solar distance.
361
+ solar_factor = (1.0 / op.solar_distance_au) ** 2
362
+ sin_altitude = _get_solar_sin_altitude(
363
+ op, sin_latitude, cos_latitude, longitude
364
+ )
365
+ return tsi * solar_factor * jnp.maximum(sin_altitude, 0.0)
366
+
367
+
368
+ def _get_integrated_radiation(
369
+ j2000_days: chex.Array,
370
+ sin_latitude: chex.Array,
371
+ cos_latitude: chex.Array,
372
+ longitude: chex.Array,
373
+ tsi: chex.Array,
374
+ integration_period: pd.Timedelta,
375
+ num_integration_bins: int,
376
+ ) -> chex.Array:
377
+ """Returns the TOA solar radiation flux integrated over a time period.
378
+
379
+ Integrates the instantaneous TOA solar radiation flux over a time period.
380
+ The input timestamps represent the end times of each integration period.
381
+ When the integration period is one hour this approximates the
382
+ `toa_incident_solar_radiation` (or `tisr`) parameter from the ERA5 dataset.
383
+ See https://confluence.ecmwf.int/display/CKB/ERA5%3A+data+documentation and
384
+ https://codes.ecmwf.int/grib/param-db/?id=212.
385
+
386
+ All inputs are assumed to be broadcastable together using standard NumPy
387
+ rules. To approximate the integral, the instantaneous radiation is computed
388
+ at `num_integration_bins+1` time steps using `_get_radiation_flux` and
389
+ integrated using the trapezoidal rule. A dimension is appended at the end
390
+ of all inputs to compute the instantaneous radiation, which is then integrated
391
+ over to compute the final result.
392
+
393
+ Args:
394
+ j2000_days: Timestamps represented as the number of days since the J2000
395
+ epoch. These correspond to the end times of each integration period.
396
+ sin_latitude: Sine of latitude coordinates.
397
+ cos_latitude: Cosine of latitude coordinates.
398
+ longitude: Longitude in radians.
399
+ tsi: Total Solar Irradiance (TSI) in W⋅m⁻².
400
+ integration_period: Integration period.
401
+ num_integration_bins: Number of bins to divide the `integration_period` to
402
+ approximate the integral using the trapezoidal rule.
403
+
404
+ Returns:
405
+ The TOA solar radiation flux integrated over the requested time period for
406
+ the given timestamps and geographical coordinates. Unit is J⋅m⁻² .
407
+ """
408
+ # Offsets for the integration time steps.
409
+ offsets = (
410
+ pd.timedelta_range(
411
+ start=-integration_period,
412
+ end=pd.Timedelta(0),
413
+ periods=num_integration_bins + 1,
414
+ )
415
+ / pd.Timedelta(days=1)
416
+ ).to_numpy()
417
+
418
+ # Integration happens over the time dimension. Compute the instantaneous
419
+ # radiation flux for all the integration time steps by appending a dimension
420
+ # to all the inputs and adding `offsets` to `j2000_days` (will be broadcast
421
+ # over all the other dimensions).
422
+ fluxes = _get_radiation_flux(
423
+ j2000_days=jnp.expand_dims(j2000_days, axis=-1) + offsets,
424
+ sin_latitude=jnp.expand_dims(sin_latitude, axis=-1),
425
+ cos_latitude=jnp.expand_dims(cos_latitude, axis=-1),
426
+ longitude=jnp.expand_dims(longitude, axis=-1),
427
+ tsi=jnp.expand_dims(tsi, axis=-1),
428
+ )
429
+
430
+ # Size of each bin in seconds. The instantaneous solar radiation flux is
431
+ # returned in units of W⋅m⁻². Integrating over time expressed in seconds
432
+ # yields a result in units of J⋅m⁻².
433
+ dx = (integration_period / num_integration_bins) / pd.Timedelta(seconds=1)
434
+ return jax.scipy.integrate.trapezoid(fluxes, dx=dx)
435
+
436
+
437
+ _get_integrated_radiation_jitted = jax.jit(
438
+ _get_integrated_radiation,
439
+ static_argnames=["integration_period", "num_integration_bins"],
440
+ )
441
+
442
+
443
+ def get_toa_incident_solar_radiation(
444
+ timestamps: Sequence[_TimestampLike],
445
+ latitude: chex.Array,
446
+ longitude: chex.Array,
447
+ tsi_data: xa.DataArray | None = None,
448
+ integration_period: _TimedeltaLike = _DEFAULT_INTEGRATION_PERIOD,
449
+ num_integration_bins: int = _DEFAULT_NUM_INTEGRATION_BINS,
450
+ use_jit: bool = False,
451
+ ) -> chex.Array:
452
+ """Computes the solar radiation incident at the top of the atmosphere.
453
+
454
+ The solar radiation is computed for each element in `timestamps` for all the
455
+ locations on the grid determined by the `latitude` and `longitude` parameters.
456
+
457
+ To approximate the `toa_incident_solar_radiation` (or `tisr`) parameter from
458
+ the ERA5 dataset, set `integration_period` to one hour (default). See
459
+ https://confluence.ecmwf.int/display/CKB/ERA5%3A+data+documentation and
460
+ https://codes.ecmwf.int/grib/param-db/?id=212.
461
+
462
+ Args:
463
+ timestamps: Timestamps for which to compute the solar radiation.
464
+ latitude: The latitude coordinates in degrees of the grid for which to
465
+ compute the solar radiation.
466
+ longitude: The longitude coordinates in degrees of the grid for which to
467
+ compute the solar radiation.
468
+ tsi_data: A DataArray containing yearly TSI data as returned by a
469
+ `TsiDataLoader`. The default is to use ERA5 compatible TSI data.
470
+ integration_period: Timedelta to use to integrate the radiation, e.g. if
471
+ producing radiation for 1989-11-08 21:00:00, and `integration_period` is
472
+ "1h", radiation will be integrated from 1989-11-08 20:00:00 to 1989-11-08
473
+ 21:00:00. The default value ("1h") matches ERA5.
474
+ num_integration_bins: Number of equally spaced bins to divide the
475
+ `integration_period` in when approximating the integral using the
476
+ trapezoidal rule. Performance and peak memory usage are affected by this
477
+ value. The default (360) provides a good approximation, but lower values
478
+ may work to improve performance and reduce memory usage.
479
+ use_jit: Set to True to use the jitted implementation, or False (default) to
480
+ use the non-jitted one.
481
+
482
+ Returns:
483
+ An 3D array with dimensions (time, lat, lon) containing the total
484
+ top of atmosphere solar radiation integrated for the `integration_period`
485
+ up to each timestamp.
486
+ """
487
+ # Add a trailing dimension to latitude to get dimensions (lat, lon).
488
+ lat = jnp.radians(latitude).reshape((-1, 1))
489
+ lon = jnp.radians(longitude)
490
+ sin_lat = jnp.sin(lat)
491
+ cos_lat = jnp.cos(lat)
492
+ integration_period = pd.Timedelta(integration_period)
493
+ if tsi_data is None:
494
+ tsi_data = _DEFAULT_TSI_DATA_LOADER()
495
+ tsi = get_tsi(timestamps, tsi_data)
496
+ fn = (
497
+ _get_integrated_radiation_jitted if use_jit else _get_integrated_radiation
498
+ )
499
+
500
+ # Compute integral for each timestamp individually. Although this could be
501
+ # done in one step, peak memory usage would be proportional to
502
+ # `len(timestamps) * num_integration_bins`. Computing each timestamp
503
+ # individually reduces this to `max(len(timestamps), num_integration_bins)`.
504
+ # E.g. memory usage for a single timestamp, with a full 0.25° grid and 360
505
+ # integration bins is about 1.5 GB (1440 * 721 * 361 * 4 bytes); computing
506
+ # forcings for 40 prediction steps would require 60 GB.
507
+ results = []
508
+ for idx, timestamp in enumerate(timestamps):
509
+ results.append(
510
+ fn(
511
+ j2000_days=jnp.array(_get_j2000_days(pd.Timestamp(timestamp))),
512
+ sin_latitude=sin_lat,
513
+ cos_latitude=cos_lat,
514
+ longitude=lon,
515
+ tsi=tsi[idx],
516
+ integration_period=integration_period,
517
+ num_integration_bins=num_integration_bins,
518
+ )
519
+ )
520
+ return jnp.stack(results, axis=0)
521
+
522
+
523
+ def get_toa_incident_solar_radiation_for_xarray(
524
+ data_array_like: xa.DataArray | xa.Dataset,
525
+ tsi_data: xa.DataArray | None = None,
526
+ integration_period: _TimedeltaLike = _DEFAULT_INTEGRATION_PERIOD,
527
+ num_integration_bins: int = _DEFAULT_NUM_INTEGRATION_BINS,
528
+ use_jit: bool = False,
529
+ ) -> xa.DataArray:
530
+ """Computes the solar radiation incident at the top of the atmosphere.
531
+
532
+ This method is a wrapper for `get_toa_incident_solar_radiation` using
533
+ coordinates from an Xarray and returning an Xarray.
534
+
535
+ Args:
536
+ data_array_like: A xa.Dataset or xa.DataArray from which to take the time
537
+ and spatial coordinates for which to compute the solar radiation. It must
538
+ contain `lat` and `lon` spatial dimensions with corresponding coordinates.
539
+ If a `time` dimension is present, the `datetime` coordinate should be a
540
+ vector associated with that dimension containing timestamps for which to
541
+ compute the solar radiation. Otherwise, the `datetime` coordinate should
542
+ be a scalar representing the timestamp for which to compute the solar
543
+ radiation.
544
+ tsi_data: A DataArray containing yearly TSI data as returned by a
545
+ `TsiDataLoader`. The default is to use ERA5 compatible TSI data.
546
+ integration_period: Timedelta to use to integrate the radiation, e.g. if
547
+ producing radiation for 1989-11-08 21:00:00, and `integration_period` is
548
+ "1h", radiation will be integrated from 1989-11-08 20:00:00 to 1989-11-08
549
+ 21:00:00. The default value ("1h") matches ERA5.
550
+ num_integration_bins: Number of equally spaced bins to divide the
551
+ `integration_period` in when approximating the integral using the
552
+ trapezoidal rule. Performance and peak memory usage are affected by this
553
+ value. The default (360) provides a good approximation, but lower values
554
+ may work to improve performance and reduce memory usage.
555
+ use_jit: Set to True to use the jitted implementation, or False to use the
556
+ non-jitted one.
557
+
558
+ Returns:
559
+ xa.DataArray with dimensions `(time, lat, lon)` if `data_array_like` had
560
+ a `time` dimension; or dimensions `(lat, lon)` otherwise. The `datetime`
561
+ coordinates and those for the dimensions are copied to the returned array.
562
+ The array contains the total top of atmosphere solar radiation integrated
563
+ for `integration_period` up to the corresponding `datetime`.
564
+
565
+ Raises:
566
+ ValueError: If there are missing coordinates or dimensions.
567
+ """
568
+ missing_dims = set(["lat", "lon"]) - set(data_array_like.dims)
569
+ if missing_dims:
570
+ raise ValueError(
571
+ f"'{missing_dims}' dimensions are missing in `data_array_like`."
572
+ )
573
+
574
+ missing_coords = set(["datetime", "lat", "lon"]) - set(data_array_like.coords)
575
+ if missing_coords:
576
+ raise ValueError(
577
+ f"'{missing_coords}' coordinates are missing in `data_array_like`."
578
+ )
579
+
580
+ if "time" in data_array_like.dims:
581
+ timestamps = data_array_like.coords["datetime"].data
582
+ else:
583
+ timestamps = [data_array_like.coords["datetime"].data.item()]
584
+
585
+ radiation = get_toa_incident_solar_radiation(
586
+ timestamps=timestamps,
587
+ latitude=data_array_like.coords["lat"].data,
588
+ longitude=data_array_like.coords["lon"].data,
589
+ tsi_data=tsi_data,
590
+ integration_period=integration_period,
591
+ num_integration_bins=num_integration_bins,
592
+ use_jit=use_jit,
593
+ )
594
+
595
+ if "time" in data_array_like.dims:
596
+ output = xa.DataArray(radiation, dims=("time", "lat", "lon"))
597
+ else:
598
+ output = xa.DataArray(radiation[0], dims=("lat", "lon"))
599
+
600
+ # Preserve as many of the original coordinates as possible, so long as the
601
+ # dimension or the coordinate still exist in the output array.
602
+ for k, coord in data_array_like.coords.items():
603
+ if set(coord.dims).issubset(set(output.dims)):
604
+ output.coords[k] = coord
605
+ return output
model/graphcast/sparse_transformer.py ADDED
@@ -0,0 +1,577 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2024 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Transformer with either dense or sparse attention.
15
+
16
+ The sparse attention implemented here is for nodes to attend only to themselves
17
+ and their neighbours on the graph). It assumes that the adjacency matrix has a
18
+ banded structure, and is implemented with dense operations computing with only
19
+ the diagonal, super diagonal, and subdiagonal blocks of the tri-block-diagonal
20
+ attention matrix.
21
+
22
+ The basic model structure of the transformer and some functions were adapted
23
+ from xlm's transformer_simple.py.
24
+ """
25
+
26
+ import dataclasses
27
+ import logging
28
+ from typing import Any, Callable, Literal, Optional, Tuple
29
+
30
+ from . import mlp as mlp_builder
31
+ from . import sparse_transformer_utils as utils
32
+ import haiku as hk
33
+ import jax
34
+ from jax.experimental.pallas.ops.tpu import splash_attention
35
+ import jax.numpy as jnp
36
+ import numpy as np
37
+ import scipy as sp
38
+
39
+
40
+ @dataclasses.dataclass
41
+ class _ModelConfig:
42
+ """Transformer config."""
43
+ # Depth, or num transformer blocks. One 'layer' is attn + ffw.
44
+ num_layers: int
45
+ # Primary width, the number of channels on the carrier path.
46
+ d_model: int
47
+ # Number of heads for self-attention.
48
+ num_heads: int
49
+ # Mask block size.
50
+ mask_block_size: int
51
+ # Attention type - 'mha' or 'triblockdiag_mha'
52
+ attention_type: str = 'triblockdiag_mha'
53
+ block_q: Optional[int] = None
54
+ block_kv: Optional[int] = None
55
+ block_kv_compute: Optional[int] = None
56
+ block_q_dkv: Optional[int] = None
57
+ block_kv_dkv: Optional[int] = None
58
+ block_kv_dkv_compute: Optional[int] = None
59
+ # mask type if splash attention being used - 'full' or 'lazy'
60
+ mask_type: Optional[str] = 'full'
61
+ # Number of channels per-head for self-attn QK computation.
62
+ key_size: Optional[int] = None
63
+ # Number of channels per-head for self-attn V computation.
64
+ value_size: Optional[int] = None
65
+ # Activation to use, any in jax.nn.
66
+ activation: str = 'gelu'
67
+ # Init scale for ffw layers (divided by num_layers)
68
+ ffw_winit_mult: float = 2.0
69
+ # Init scale for final ffw layer (divided by depth)
70
+ ffw_winit_final_mult: float = 2.0
71
+ # Init scale for mha proj (divided by depth).
72
+ attn_winit_mult: float = 2.0
73
+ # Init scale for mha w (divided by depth).
74
+ attn_winit_final_mult: float = 2.0
75
+ # Number of hidden units in the MLP blocks. Defaults to 4 * d_model.
76
+ ffw_hidden: Optional[int] = None
77
+
78
+ def __post_init__(self):
79
+ if self.ffw_hidden is None:
80
+ self.ffw_hidden = 4 * self.d_model
81
+ # Compute key_size and value_size from d_model // num_heads.
82
+ if self.key_size is None:
83
+ if self.d_model % self.num_heads != 0:
84
+ raise ValueError('num_heads has to divide d_model exactly')
85
+ self.key_size = self.d_model // self.num_heads
86
+ if self.value_size is None:
87
+ if self.d_model % self.num_heads != 0:
88
+ raise ValueError('num_heads has to divide d_model exactly')
89
+ self.value_size = self.d_model // self.num_heads
90
+
91
+
92
+ def get_mask_block_size(mask: sp.sparse.csr_matrix) -> int:
93
+ """Get blocksize of the adjacency matrix (attn mask) for the permuted mesh."""
94
+ # sub-diagonal bandwidth
95
+ lbandwidth = (
96
+ np.arange(mask.shape[0]) - (mask != 0).argmax(axis=0) + 1).max()
97
+ # super-diagonal bandwidth
98
+ ubandwidth = (
99
+ (mask.shape[0]-1) - np.argmax(mask[::-1,:] != 0, axis=0
100
+ ) - np.arange(mask.shape[0]) + 1).max()
101
+ block_size = np.maximum(lbandwidth, ubandwidth)
102
+ return block_size
103
+
104
+
105
+ def ffw(x: jnp.ndarray, cfg: _ModelConfig) -> jnp.ndarray:
106
+ """Feed-forward block."""
107
+ ffw_winit = hk.initializers.VarianceScaling(cfg.ffw_winit_mult /
108
+ cfg.num_layers)
109
+ ffw_winit_final = hk.initializers.VarianceScaling(cfg.ffw_winit_final_mult /
110
+ cfg.num_layers)
111
+ x = hk.Linear(cfg.ffw_hidden, name='ffw_up', w_init=ffw_winit)(x)
112
+ x = getattr(jax.nn, cfg.activation)(x)
113
+ return hk.Linear(cfg.d_model, name='ffw_down', w_init=ffw_winit_final)(x)
114
+
115
+
116
+ def triblockdiag_softmax(logits: Tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]
117
+ ) -> Tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
118
+ """Softmax given the diag, upper diag, and lower diag logit blocks."""
119
+
120
+ logits_d, logits_u, logits_l = logits
121
+
122
+ m = jnp.max(jnp.stack([
123
+ jax.lax.stop_gradient(logits_d.max(-1, keepdims=True)),
124
+ jax.lax.stop_gradient(logits_u.max(-1, keepdims=True)),
125
+ jax.lax.stop_gradient(logits_l.max(-1, keepdims=True))]), axis=0)
126
+
127
+ unnormalized_d = jnp.exp(logits_d - m)
128
+ unnormalized_u = jnp.exp(logits_u - m)
129
+ unnormalized_l = jnp.exp(logits_l - m)
130
+
131
+ denom = (
132
+ unnormalized_d.sum(-1, keepdims=True)
133
+ + unnormalized_u.sum(-1, keepdims=True)
134
+ + unnormalized_l.sum(-1, keepdims=True)
135
+ )
136
+
137
+ logits_d = unnormalized_d / denom
138
+ logits_u = unnormalized_u / denom
139
+ logits_l = unnormalized_l / denom
140
+
141
+ return (logits_d, logits_u, logits_l)
142
+
143
+
144
+ def triblockdiag_mha(q_input: jnp.ndarray, kv_input: jnp.ndarray,
145
+ mask: jnp.ndarray, cfg: _ModelConfig,
146
+ ) -> jnp.ndarray:
147
+ """Triblockdiag multihead attention."""
148
+
149
+ # q_inputs, kv_input: (batch, num_blocks, block_size, num_heads, d_model)
150
+ q = multihead_linear(q_input, 'q', cfg)
151
+ k = multihead_linear(kv_input, 'k', cfg)
152
+ v = multihead_linear(kv_input, 'v', cfg)
153
+
154
+ k = jnp.pad(k, ((0, 0), (1, 1), (0, 0), (0, 0), (0, 0)))
155
+ v = jnp.pad(v, ((0, 0), (1, 1), (0, 0), (0, 0), (0, 0)))
156
+
157
+ def qk_prod(queries, keys):
158
+ return jnp.einsum('bnqhd,bnkhd->bnhqk', queries, keys)
159
+
160
+ # q shape is (batch, num_blocks, block_size, num_heads, qk_dim)
161
+ # k shape is (batch, num_blocks + 2, block_size, num_heads, qk_dim)
162
+ logits_d = qk_prod(q, k[:, 1:-1, ...]) * cfg.key_size**-0.5
163
+ logits_u = qk_prod(q, k[:, 2:, ...]) * cfg.key_size**-0.5
164
+ logits_l = qk_prod(q, k[:, :-2, ...]) * cfg.key_size**-0.5
165
+
166
+ # apply mask
167
+ logits_d = jnp.where(mask[:, 0, ...], logits_d, -1e30)
168
+ logits_u = jnp.where(mask[:, 1, ...], logits_u, -1e30)
169
+ logits_l = jnp.where(mask[:, 2, ...], logits_l, -1e30)
170
+
171
+ logits_d, logits_u, logits_l = utils.wrap_fn_for_upcast_downcast(
172
+ (logits_d, logits_u, logits_l),
173
+ triblockdiag_softmax
174
+ )
175
+
176
+ def av_prod(attn_weights, values):
177
+ return jnp.einsum('bnhqk,bnkhd->bnqhd', attn_weights, values)
178
+
179
+ out_d = av_prod(logits_d, v[:, 1:-1, ...])
180
+ out_u = av_prod(logits_u, v[:, 2:, ...])
181
+ out_l = av_prod(logits_l, v[:, :-2, ...])
182
+ # x shape is (batch, num_blocks, block_size, num_heads, d_model)
183
+ x = out_d + out_u + out_l
184
+
185
+ x = jnp.reshape(x, x.shape[:-2] + (cfg.num_heads * cfg.value_size,))
186
+ attn_winit_final = hk.initializers.VarianceScaling(
187
+ cfg.attn_winit_final_mult / cfg.num_layers)
188
+ x = hk.Linear(cfg.d_model, name='mha_final', w_init=attn_winit_final)(x)
189
+ return x
190
+
191
+
192
+ def multihead_linear(
193
+ x: jnp.ndarray, qkv: str, cfg: _ModelConfig
194
+ ) -> jnp.ndarray:
195
+ """Linearly project `x` to have `head_size` dimensions per head."""
196
+ head_size = cfg.value_size if qkv == 'v' else cfg.key_size
197
+ attn_winit = hk.initializers.VarianceScaling(cfg.attn_winit_mult /
198
+ cfg.num_layers)
199
+ out = hk.Linear(
200
+ cfg.num_heads * head_size,
201
+ w_init=attn_winit,
202
+ name='mha_proj_' + qkv,
203
+ with_bias=False,
204
+ )(x)
205
+ shape = out.shape[:-1] + (cfg.num_heads, head_size)
206
+ return jnp.reshape(out, shape)
207
+
208
+
209
+ def mha(q_input: jnp.ndarray, kv_input: jnp.ndarray,
210
+ mask: jnp.ndarray, cfg: _ModelConfig,
211
+ normalize_logits: bool = True,
212
+ ) -> jnp.ndarray:
213
+ """Multi head attention."""
214
+
215
+ q = multihead_linear(q_input, 'q', cfg)
216
+ k = multihead_linear(kv_input, 'k', cfg)
217
+ v = multihead_linear(kv_input, 'v', cfg)
218
+
219
+ logits = jnp.einsum('bthd, bThd->bhtT', q, k)
220
+ if normalize_logits:
221
+ logits *= cfg.key_size**-0.5
222
+ if mask is not None:
223
+ def apply_mask(m, l):
224
+ return jnp.where(m, l, -1e30)
225
+ logits = jax.vmap(jax.vmap(
226
+ apply_mask, in_axes=[None, 0]), in_axes=[None, 0])(mask, logits)
227
+
228
+ # Wrap softmax weights for upcasting & downcasting in case of BF16 activations
229
+ weights = utils.wrap_fn_for_upcast_downcast(logits, jax.nn.softmax)
230
+
231
+ # Note: our mask never has all 0 rows, since nodes always have self edges,
232
+ # so no need to account for that possibility explicitly.
233
+
234
+ x = jnp.einsum('bhtT,bThd->bthd', weights, v)
235
+ x = jnp.reshape(x, x.shape[:-2] + (cfg.num_heads * cfg.value_size,))
236
+
237
+ attn_winit_final = hk.initializers.VarianceScaling(
238
+ cfg.attn_winit_final_mult / cfg.num_layers)
239
+
240
+ x = hk.Linear(cfg.d_model, name='mha_final', w_init=attn_winit_final)(x)
241
+ return x
242
+
243
+
244
+ def _make_splash_mha(
245
+ mask,
246
+ mask_type: str,
247
+ num_heads: int,
248
+ block_q: Optional[int] = None,
249
+ block_kv: Optional[int] = None,
250
+ block_kv_compute: Optional[int] = None,
251
+ block_q_dkv: Optional[int] = None,
252
+ block_kv_dkv: Optional[int] = None,
253
+ block_kv_dkv_compute: Optional[int] = None,
254
+ tanh_soft_cap: Optional[float] = None,
255
+ ) -> Callable[..., jnp.ndarray]:
256
+ """Construct attention kernel."""
257
+ if mask_type == 'full':
258
+ mask = np.broadcast_to(mask[None],
259
+ (num_heads, *mask.shape)).astype(np.bool_)
260
+
261
+ block_sizes = splash_attention.BlockSizes(
262
+ block_q=block_q,
263
+ block_kv=block_kv,
264
+ block_kv_compute=block_kv_compute,
265
+ block_q_dkv=block_q_dkv,
266
+ block_kv_dkv=block_kv_dkv,
267
+ block_kv_dkv_compute=block_kv_dkv_compute,
268
+ use_fused_bwd_kernel=True,
269
+ )
270
+ attn = splash_attention.make_splash_mha(mask, block_sizes=block_sizes,
271
+ head_shards=1,
272
+ q_seq_shards=1,
273
+ attn_logits_soft_cap=tanh_soft_cap,
274
+ )
275
+ return attn
276
+
277
+
278
+ def splash_mha(q_input: jnp.ndarray, kv_input: jnp.ndarray,
279
+ mask: jnp.ndarray | splash_attention.splash_attention_mask.Mask,
280
+ cfg: _ModelConfig,
281
+ tanh_soft_cap: Optional[float] = None,
282
+ normalize_q: bool = True) -> jnp.ndarray:
283
+ """Splash attention."""
284
+
285
+ q = multihead_linear(q_input, 'q', cfg)
286
+ k = multihead_linear(kv_input, 'k', cfg)
287
+ v = multihead_linear(kv_input, 'v', cfg)
288
+
289
+ _, _, num_heads, head_dim = q.shape
290
+
291
+ assert head_dim % 128 == 0 # splash attention kernel requires this
292
+
293
+ attn = _make_splash_mha(
294
+ mask=mask,
295
+ mask_type=cfg.mask_type,
296
+ num_heads=num_heads,
297
+ block_q=cfg.block_q,
298
+ block_kv=cfg.block_kv,
299
+ block_kv_compute=cfg.block_kv_compute,
300
+ block_q_dkv=cfg.block_q_dkv,
301
+ block_kv_dkv=cfg.block_kv_dkv,
302
+ block_kv_dkv_compute=cfg.block_kv_dkv_compute,
303
+ tanh_soft_cap=tanh_soft_cap,
304
+ )
305
+ attn = jax.vmap(attn) # Add batch axis.
306
+
307
+ if normalize_q:
308
+ q *= cfg.key_size**-0.5
309
+
310
+ # (batch, nodes, num_heads, head_dim) -> (batch, num_heads, nodes, head_dim)
311
+ reformat = lambda y: y.transpose(0, 2, 1, 3)
312
+ x = attn(q=reformat(q), k=reformat(k), v=reformat(v))
313
+ x = x.transpose(0, 2, 1, 3)
314
+
315
+ x = jnp.reshape(x, x.shape[:-2] + (cfg.num_heads * cfg.value_size,))
316
+
317
+ attn_winit_final = hk.initializers.VarianceScaling(
318
+ cfg.attn_winit_final_mult / cfg.num_layers)
319
+
320
+ x = hk.Linear(cfg.d_model, name='mha_final', w_init=attn_winit_final)(x)
321
+ return x
322
+
323
+
324
+ def layernorm(
325
+ x: jnp.ndarray, create_scale: bool, create_offset: bool
326
+ ) -> jnp.ndarray:
327
+ return hk.LayerNorm(
328
+ axis=-1, create_scale=create_scale, create_offset=create_offset,
329
+ name='norm')(x)
330
+
331
+
332
+ def mask_block_diags(mask: sp.sparse.csr_matrix,
333
+ num_padding_nodes: int,
334
+ block_size: int) -> jnp.ndarray:
335
+ """Pad and reshape mask diag, super-siag and sub-diag blocks."""
336
+ # add zero padding to mask
337
+ mask_padding_rows = sp.sparse.csr_matrix(
338
+ (num_padding_nodes, mask.shape[1]), dtype=jnp.int32)
339
+ mask = sp.sparse.vstack([mask, mask_padding_rows])
340
+ mask_padding_cols = sp.sparse.csr_matrix(
341
+ (mask.shape[0], num_padding_nodes), dtype=jnp.int32)
342
+ mask = sp.sparse.hstack([mask, mask_padding_cols])
343
+
344
+ assert (mask.shape[-1] % block_size) == 0
345
+ mask_daig_blocks = jnp.stack(
346
+ [jnp.array(mask[i * block_size : (i + 1) * block_size,
347
+ i * block_size : (i + 1) * block_size,
348
+ ].toarray())
349
+ for i in range(mask.shape[0] // block_size)])
350
+ mask_upper_diag_blocks = jnp.stack(
351
+ [jnp.array(mask[i * block_size : (i + 1) * block_size,
352
+ (i + 1) * block_size : (i + 2) * block_size,
353
+ ].toarray())
354
+ for i in range(mask.shape[0] // block_size - 1)]
355
+ + [jnp.zeros((block_size, block_size), dtype=mask.dtype)])
356
+ mask_lower_diag_blocks = jnp.stack(
357
+ [jnp.zeros((block_size, block_size), dtype=mask.dtype)]
358
+ + [jnp.array(mask[(i + 1) * block_size : (i + 2) * block_size,
359
+ i * block_size : (i + 1) * block_size,
360
+ ].toarray())
361
+ for i in range(mask.shape[0] // block_size - 1)])
362
+ mask = jnp.stack(
363
+ [mask_daig_blocks, mask_upper_diag_blocks, mask_lower_diag_blocks]
364
+ )
365
+ mask = jnp.expand_dims(mask, (0, 3))
366
+ return mask
367
+
368
+
369
+ def _pad_mask(mask, num_padding_nodes: Tuple[int, int]) -> jnp.ndarray:
370
+ q_padding, kv_padding = num_padding_nodes
371
+ mask_padding_rows = sp.sparse.csr_matrix(
372
+ (q_padding, mask.shape[1]), dtype=np.bool_)
373
+ mask = sp.sparse.vstack([mask, mask_padding_rows])
374
+ mask_padding_cols = sp.sparse.csr_matrix(
375
+ (mask.shape[0], kv_padding), dtype=np.bool_)
376
+ mask = sp.sparse.hstack([mask, mask_padding_cols])
377
+ return mask
378
+
379
+
380
+ class WeatherMeshMask(splash_attention.splash_attention_mask.Mask):
381
+ """Lazy local mask, prevent attention to embeddings outside window.
382
+
383
+ Attributes:
384
+ mask:
385
+ """
386
+
387
+ _shape: Tuple[int, int]
388
+ mask: sp.sparse.spmatrix
389
+
390
+ def __init__(
391
+ self,
392
+ mask: Any
393
+ ):
394
+ self._shape = mask.shape
395
+ self.mask = mask
396
+
397
+ @property
398
+ def shape(self) -> Tuple[int, int]:
399
+ return self._shape
400
+
401
+ def __getitem__(self, idx) -> np.ndarray:
402
+ if len(idx) != 2:
403
+ raise NotImplementedError(f'Unsupported slice: {idx}')
404
+ q_slice, kv_slice = idx
405
+ if not isinstance(q_slice, slice) or not isinstance(kv_slice, slice):
406
+ raise NotImplementedError(f'Unsupported slice: {idx}')
407
+
408
+ return self.mask[q_slice, kv_slice].toarray()
409
+
410
+
411
+ class Block(hk.Module):
412
+ """Transformer block (mha and ffw)."""
413
+
414
+ def __init__(self, cfg, mask, num_nodes, num_padding_nodes, name=None):
415
+ super().__init__(name=name)
416
+ self._cfg = cfg
417
+ self.mask = mask
418
+ self.num_nodes = num_nodes
419
+ self.num_padding_nodes = num_padding_nodes
420
+
421
+ def __call__(self, x, global_norm_conditioning=jax.Array):
422
+ # x shape is (batch, num_nodes, feature_dim)
423
+ def attn(x):
424
+ if self._cfg.attention_type == 'triblockdiag_mha':
425
+ # We pad -> reshape -> compute attn -> reshape -> select at each block
426
+ # so as to avoid complications involved in making the norm layers and
427
+ # ffw blocks account for the padding. However, this might be decreasing
428
+ # efficiency.
429
+
430
+ # Add padding so that number of nodes is divisible into blocks
431
+ x = jnp.pad(x, ((0, 0), (0, self.num_padding_nodes), (0, 0)))
432
+ x = x.reshape(x.shape[0],
433
+ x.shape[1]//self._cfg.mask_block_size,
434
+ self._cfg.mask_block_size,
435
+ x.shape[-1])
436
+ x = triblockdiag_mha(x, x, mask=self.mask, cfg=self._cfg)
437
+ x = x.reshape(x.shape[0],
438
+ self.num_nodes + self.num_padding_nodes,
439
+ x.shape[-1])
440
+ return x[:,:self.num_nodes, :]
441
+
442
+ elif self._cfg.attention_type == 'mha':
443
+ return mha(x, x, mask=self.mask, cfg=self._cfg)
444
+
445
+ elif self._cfg.attention_type == 'splash_mha':
446
+ # We pad -> reshape -> compute attn -> reshape -> select at each block
447
+ # so as to avoid complications involved in making the norm layers and
448
+ # ffw blocks account for the padding. However, this might be decreasing
449
+ # efficiency.
450
+
451
+ # Add padding so that number of nodes is divisible by block sizes.
452
+ x = jnp.pad(x, ((0, 0), (0, self.num_padding_nodes[0]), (0, 0)))
453
+ x = splash_mha(x, x, mask=self.mask, cfg=self._cfg)
454
+ return x[:,:self.num_nodes, :]
455
+
456
+ else:
457
+ raise NotImplementedError()
458
+
459
+ def norm_conditioning_layer(x):
460
+ return mlp_builder.LinearNormConditioning(
461
+ name=self.name+'_norm_conditioning')(
462
+ x,
463
+ norm_conditioning=jnp.expand_dims(global_norm_conditioning, 1)
464
+ )
465
+
466
+ x = x + attn(
467
+ norm_conditioning_layer(
468
+ layernorm(x, create_scale=False, create_offset=False)
469
+ )
470
+ )
471
+ x = x + ffw(
472
+ norm_conditioning_layer(
473
+ layernorm(x, create_scale=False, create_offset=False)
474
+ ),
475
+ self._cfg,
476
+ )
477
+ return x
478
+
479
+
480
+ class Transformer(hk.Module):
481
+ """Main transformer module that processes embeddings.
482
+
483
+ All but the very first and very last layer of a 'classic' Transformer:
484
+ Receives already embedded inputs instead of discrete tokens.
485
+ Outputs an embedding for each 'node'/'position' rather than logits.
486
+ """
487
+
488
+ def __init__(self,
489
+ adj_mat: sp.sparse.csr_matrix,
490
+ attention_k_hop: int,
491
+ attention_type: Literal['splash_mha', 'triblockdiag_mha', 'mha'],
492
+ mask_type: Literal['full', 'lazy'],
493
+ num_heads=1,
494
+ name=None,
495
+ block_q: Optional[int] = None,
496
+ block_kv: Optional[int] = None,
497
+ block_kv_compute: Optional[int] = None,
498
+ block_q_dkv: Optional[int] = None,
499
+ block_kv_dkv: Optional[int] = None,
500
+ block_kv_dkv_compute: Optional[int] = None,
501
+ **kwargs):
502
+ super().__init__(name=name)
503
+
504
+ # Construct mask and deduce block size.
505
+ mask = adj_mat ** attention_k_hop
506
+ mask_block_size = get_mask_block_size(mask)
507
+ logging.info('mask_block_size: %s.', mask_block_size)
508
+
509
+ if attention_type == 'triblockdiag_mha':
510
+ # we will stack the nodes in blocks of 'block_size' nodes, so we need to
511
+ # pad the input such that (num_nodes + num_padding_nodes) % block_size = 0
512
+ self.num_padding_nodes = int(np.ceil(
513
+ mask.shape[0]/mask_block_size)*mask_block_size
514
+ - mask.shape[0])
515
+ self.mask = mask_block_diags(
516
+ mask, self.num_padding_nodes, mask_block_size)
517
+ elif attention_type == 'splash_mha':
518
+ max_q_block_size = np.maximum(block_q, block_q_dkv)
519
+ max_kv_block_size = np.maximum(block_kv, block_kv_dkv)
520
+ q_padding = int(np.ceil(
521
+ mask.shape[0]/max_q_block_size)*max_q_block_size - mask.shape[0])
522
+ kv_padding = int(np.ceil(
523
+ mask.shape[1]/max_kv_block_size)*max_kv_block_size - mask.shape[1])
524
+ self.num_padding_nodes = (q_padding, kv_padding)
525
+ mask = _pad_mask(mask, self.num_padding_nodes)
526
+ if mask_type == 'lazy':
527
+ splash_mask = [
528
+ WeatherMeshMask(mask)
529
+ for _ in range(num_heads)
530
+ ]
531
+ self.mask = splash_attention.splash_attention_mask.MultiHeadMask(
532
+ splash_mask)
533
+ elif mask_type == 'full':
534
+ self.mask = mask.toarray() # pytype: disable=attribute-error
535
+ elif attention_type == 'mha':
536
+ self.mask = jnp.array(mask.toarray())
537
+ self.num_padding_nodes = 0
538
+ else:
539
+ raise ValueError(
540
+ 'Unsupported attention type: %s' % attention_type
541
+ )
542
+
543
+ # Construct config for use within class.
544
+ self._cfg = _ModelConfig(
545
+ mask_block_size=mask_block_size,
546
+ attention_type=attention_type,
547
+ mask_type=mask_type,
548
+ num_heads=num_heads,
549
+ block_q=block_q,
550
+ block_kv=block_kv,
551
+ block_kv_compute=block_kv_compute,
552
+ block_q_dkv=block_q_dkv,
553
+ block_kv_dkv=block_kv_dkv,
554
+ block_kv_dkv_compute=block_kv_dkv_compute,
555
+ **kwargs)
556
+
557
+ def __call__(self, node_features, global_norm_conditioning: jax.Array):
558
+ # node_features expected to have shape (batch, num_nodes, d)
559
+ x = node_features
560
+ for i_layer in range(self._cfg.num_layers):
561
+ x = Block(cfg=self._cfg, mask=self.mask,
562
+ num_nodes=node_features.shape[1],
563
+ num_padding_nodes=self.num_padding_nodes,
564
+ name='block_%02d' % i_layer
565
+ )(x, global_norm_conditioning=global_norm_conditioning)
566
+
567
+ def norm_conditioning_layer(x):
568
+ return mlp_builder.LinearNormConditioning(
569
+ name=self.name+'_final_norm_conditioning')(
570
+ x,
571
+ norm_conditioning=jnp.expand_dims(global_norm_conditioning, 1)
572
+ )
573
+ x = norm_conditioning_layer(
574
+ layernorm(x, create_scale=False, create_offset=False)
575
+ )
576
+
577
+ return x
model/graphcast/sparse_transformer_utils.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2024 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Utils for training models in low precision."""
15
+
16
+ import functools
17
+ from typing import Callable, Tuple, Union
18
+
19
+ import jax
20
+ import jax.numpy as jnp
21
+
22
+
23
+ # Wrappers for jax.lax.reduce_precision which is non-differentiable.
24
+ @functools.partial(jax.custom_vjp, nondiff_argnums=(1, 2))
25
+ def reduce_precision(x, exponent_bits, mantissa_bits):
26
+ return jax.tree_util.tree_map(
27
+ lambda y: jax.lax.reduce_precision(y, exponent_bits, mantissa_bits), x)
28
+
29
+
30
+ def reduce_precision_fwd(x, exponent_bits, mantissa_bits):
31
+ return reduce_precision(x, exponent_bits, mantissa_bits), None
32
+
33
+
34
+ def reduce_precision_bwd(exponent_bits, mantissa_bits, res, dout):
35
+ del res # Unused.
36
+ return reduce_precision(dout, exponent_bits, mantissa_bits),
37
+
38
+
39
+ reduce_precision.defvjp(reduce_precision_fwd, reduce_precision_bwd)
40
+
41
+
42
+ def wrap_fn_for_upcast_downcast(inputs: Union[jnp.ndarray,
43
+ Tuple[jnp.ndarray, ...]],
44
+ fn: Callable[[Union[jnp.ndarray,
45
+ Tuple[jnp.ndarray, ...]]],
46
+ Union[jnp.ndarray,
47
+ Tuple[jnp.ndarray, ...]]],
48
+ f32_upcast: bool = True,
49
+ guard_against_excess_precision: bool = True
50
+ ) -> Union[jnp.ndarray,
51
+ Tuple[jnp.ndarray, ...]]:
52
+ """Wraps `fn` to upcast to float32 and then downcast, for use with BF16."""
53
+ # Do not upcast if the inputs are already in float32.
54
+ # This removes a no-op `jax.lax.reduce_precision` which is unsupported
55
+ # in jax2tf at the moment.
56
+ if isinstance(inputs, Tuple):
57
+ f32_upcast = f32_upcast and inputs[0].dtype != jnp.float32
58
+ orig_dtype = inputs[0].dtype
59
+ else:
60
+ f32_upcast = f32_upcast and inputs.dtype != jnp.float32
61
+ orig_dtype = inputs.dtype
62
+
63
+ if f32_upcast:
64
+ inputs = jax.tree_util.tree_map(lambda x: x.astype(jnp.float32), inputs)
65
+
66
+ if guard_against_excess_precision:
67
+ # This is evil magic to guard against differences in precision in the QK
68
+ # calculation between the forward pass and backwards pass. This is like
69
+ # --xla_allow_excess_precision=false but scoped here.
70
+ finfo = jnp.finfo(orig_dtype) # jnp important!
71
+ inputs = reduce_precision(inputs, finfo.nexp, finfo.nmant)
72
+
73
+ output = fn(inputs)
74
+ if f32_upcast:
75
+ output = jax.tree_util.tree_map(lambda x: x.astype(orig_dtype), output)
76
+ return output
model/graphcast/transformer.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2024 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """A Transformer model for weather predictions.
15
+
16
+ This model wraps the a transformer model and swaps the leading two axes of the
17
+ nodes in the input graph prior to evaluating the model to make it compatible
18
+ with a [nodes, batch, ...] ordering of the inputs.
19
+ """
20
+
21
+ from typing import Any, Mapping, Optional
22
+
23
+ from . import typed_graph
24
+ import haiku as hk
25
+ import jax
26
+ import jax.numpy as jnp
27
+ import numpy as np
28
+ from scipy import sparse
29
+
30
+
31
+ Kwargs = Mapping[str, Any]
32
+
33
+
34
+ def _get_adj_matrix_for_edge_set(
35
+ graph: typed_graph.TypedGraph,
36
+ edge_set_name: str,
37
+ add_self_edges: bool,
38
+ ):
39
+ """Returns the adjacency matrix for the given graph and edge set."""
40
+ # Get nodes and edges of the graph.
41
+ edge_set_key = graph.edge_key_by_name(edge_set_name)
42
+ sender_node_set, receiver_node_set = edge_set_key.node_sets
43
+
44
+ # Compute number of sender and receiver nodes.
45
+ sender_n_node = graph.nodes[sender_node_set].n_node[0]
46
+ receiver_n_node = graph.nodes[receiver_node_set].n_node[0]
47
+
48
+ # Build adjacency matrix.
49
+ adj_mat = sparse.csr_matrix((sender_n_node, receiver_n_node), dtype=np.bool_)
50
+ edge_set = graph.edges[edge_set_key]
51
+ s, r = edge_set.indices
52
+ adj_mat[s, r] = True
53
+ if add_self_edges:
54
+ # Should only do this if we are certain the adjacency matrix is square.
55
+ assert sender_node_set == receiver_node_set
56
+ adj_mat[np.arange(sender_n_node), np.arange(receiver_n_node)] = True
57
+ return adj_mat
58
+
59
+
60
+ class MeshTransformer(hk.Module):
61
+ """A Transformer for inputs with ordering [nodes, batch, ...]."""
62
+
63
+ def __init__(self,
64
+ transformer_ctor,
65
+ transformer_kwargs: Kwargs,
66
+ name: Optional[str] = None):
67
+ """Initialises the Transformer model.
68
+
69
+ Args:
70
+ transformer_ctor: Constructor for transformer.
71
+ transformer_kwargs: Kwargs to pass to the transformer module.
72
+ name: Optional name for haiku module.
73
+ """
74
+ super().__init__(name=name)
75
+ # We defer the transformer initialisation to the first call to __call__,
76
+ # where we can build the mask senders and receivers of the TypedGraph
77
+ self._batch_first_transformer = None
78
+ self._transformer_ctor = transformer_ctor
79
+ self._transformer_kwargs = transformer_kwargs
80
+
81
+ @hk.name_like('__init__')
82
+ def _maybe_init_batch_first_transformer(self, x: typed_graph.TypedGraph):
83
+ if self._batch_first_transformer is not None:
84
+ return
85
+ self._batch_first_transformer = self._transformer_ctor(
86
+ adj_mat=_get_adj_matrix_for_edge_set(
87
+ graph=x,
88
+ edge_set_name='mesh',
89
+ add_self_edges=True,
90
+ ),
91
+ **self._transformer_kwargs,
92
+ )
93
+
94
+ def __call__(
95
+ self, x: typed_graph.TypedGraph,
96
+ global_norm_conditioning: jax.Array
97
+ ) -> typed_graph.TypedGraph:
98
+ """Applies the model to the input graph and returns graph of same shape."""
99
+
100
+ if set(x.nodes.keys()) != {'mesh_nodes'}:
101
+ raise ValueError(
102
+ f'Expected x.nodes to have key `mesh_nodes`, got {x.nodes.keys()}.'
103
+ )
104
+ features = x.nodes['mesh_nodes'].features
105
+ if features.ndim != 3: # pytype: disable=attribute-error # jax-ndarray
106
+ raise ValueError(
107
+ 'Expected `x.nodes["mesh_nodes"].features` to be 3, got'
108
+ f' {features.ndim}.'
109
+ ) # pytype: disable=attribute-error # jax-ndarray
110
+
111
+ # Initialise transformer and mask.
112
+ self._maybe_init_batch_first_transformer(x)
113
+
114
+ y = jnp.transpose(features, axes=[1, 0, 2])
115
+ y = self._batch_first_transformer(y, global_norm_conditioning)
116
+ y = jnp.transpose(y, axes=[1, 0, 2])
117
+ x = x._replace(
118
+ nodes={
119
+ 'mesh_nodes': x.nodes['mesh_nodes']._replace(
120
+ features=y.astype(features.dtype)
121
+ )
122
+ }
123
+ )
124
+ return x
model/graphcast/typed_graph.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2023 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Data-structure for storing graphs with typed edges and nodes."""
15
+
16
+ from typing import NamedTuple, Any, Union, Tuple, Mapping, TypeVar
17
+
18
+ ArrayLike = Union[Any] # np.ndarray, jnp.ndarray, tf.tensor
19
+ ArrayLikeTree = Union[Any, ArrayLike] # Nest of ArrayLike
20
+
21
+ _T = TypeVar('_T')
22
+
23
+
24
+ # All tensors have a "flat_batch_axis", which is similar to the leading
25
+ # axes of graph_tuples:
26
+ # * In the case of nodes this is simply a shared node and flat batch axis, with
27
+ # size corresponding to the total number of nodes in the flattened batch.
28
+ # * In the case of edges this is simply a shared edge and flat batch axis, with
29
+ # size corresponding to the total number of edges in the flattened batch.
30
+ # * In the case of globals this is simply the number of graphs in the flattened
31
+ # batch.
32
+
33
+ # All shapes may also have any additional leading shape "batch_shape".
34
+ # Options for building batches are:
35
+ # * Use a provided "flatten" method that takes a leading `batch_shape` and
36
+ # it into the flat_batch_axis (this will be useful when using `tf.Dataset`
37
+ # which supports batching into RaggedTensors, with leading batch shape even
38
+ # if graphs have different numbers of nodes and edges), so the RaggedBatches
39
+ # can then be converted into something without ragged dimensions that jax can
40
+ # use.
41
+ # * Directly build a "flat batch" using a provided function for batching a list
42
+ # of graphs (how it is done in `jraph`).
43
+
44
+
45
+ class NodeSet(NamedTuple):
46
+ """Represents a set of nodes."""
47
+ n_node: ArrayLike # [num_flat_graphs]
48
+ features: ArrayLikeTree # Prev. `nodes`: [num_flat_nodes] + feature_shape
49
+
50
+
51
+ class EdgesIndices(NamedTuple):
52
+ """Represents indices to nodes adjacent to the edges."""
53
+ senders: ArrayLike # [num_flat_edges]
54
+ receivers: ArrayLike # [num_flat_edges]
55
+
56
+
57
+ class EdgeSet(NamedTuple):
58
+ """Represents a set of edges."""
59
+ n_edge: ArrayLike # [num_flat_graphs]
60
+ indices: EdgesIndices
61
+ features: ArrayLikeTree # Prev. `edges`: [num_flat_edges] + feature_shape
62
+
63
+
64
+ class Context(NamedTuple):
65
+ # `n_graph` always contains ones but it is useful to query the leading shape
66
+ # in case of graphs without any nodes or edges sets.
67
+ n_graph: ArrayLike # [num_flat_graphs]
68
+ features: ArrayLikeTree # Prev. `globals`: [num_flat_graphs] + feature_shape
69
+
70
+
71
+ class EdgeSetKey(NamedTuple):
72
+ name: str # Name of the EdgeSet.
73
+
74
+ # Sender node set name and receiver node set name connected by the edge set.
75
+ node_sets: Tuple[str, str]
76
+
77
+
78
+ class TypedGraph(NamedTuple):
79
+ """A graph with typed nodes and edges.
80
+
81
+ A typed graph is made of a context, multiple sets of nodes and multiple
82
+ sets of edges connecting those nodes (as indicated by the EdgeSetKey).
83
+ """
84
+
85
+ context: Context
86
+ nodes: Mapping[str, NodeSet]
87
+ edges: Mapping[EdgeSetKey, EdgeSet]
88
+
89
+ def edge_key_by_name(self, name: str) -> EdgeSetKey:
90
+ found_key = [k for k in self.edges.keys() if k.name == name]
91
+ if len(found_key) != 1:
92
+ raise KeyError("invalid edge key '{}'. Available edges: [{}]".format(
93
+ name, ', '.join(x.name for x in self.edges.keys())))
94
+ return found_key[0]
95
+
96
+ def edge_by_name(self, name: str) -> EdgeSet:
97
+ return self.edges[self.edge_key_by_name(name)]
model/graphcast/typed_graph_net.py ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2023 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """A library of typed Graph Neural Networks."""
15
+
16
+ from typing import Callable, Mapping, Optional, Union
17
+
18
+ from . import typed_graph
19
+ import jax.numpy as jnp
20
+ import jax.tree_util as tree
21
+ import jraph
22
+
23
+
24
+ # All features will be an ArrayTree.
25
+ NodeFeatures = EdgeFeatures = SenderFeatures = ReceiverFeatures = Globals = (
26
+ jraph.ArrayTree)
27
+
28
+ # Signature:
29
+ # (node features, outgoing edge features, incoming edge features,
30
+ # globals) -> updated node features
31
+ GNUpdateNodeFn = Callable[
32
+ [NodeFeatures, Mapping[str, SenderFeatures], Mapping[str, ReceiverFeatures],
33
+ Globals],
34
+ NodeFeatures]
35
+
36
+ GNUpdateGlobalFn = Callable[
37
+ [Mapping[str, NodeFeatures], Mapping[str, EdgeFeatures], Globals],
38
+ Globals]
39
+
40
+
41
+ def GraphNetwork( # pylint: disable=invalid-name
42
+ update_edge_fn: Mapping[str, jraph.GNUpdateEdgeFn],
43
+ update_node_fn: Mapping[str, GNUpdateNodeFn],
44
+ update_global_fn: Optional[GNUpdateGlobalFn] = None,
45
+ aggregate_edges_for_nodes_fn: jraph.AggregateEdgesToNodesFn = jraph
46
+ .segment_sum,
47
+ aggregate_nodes_for_globals_fn: jraph.AggregateNodesToGlobalsFn = jraph
48
+ .segment_sum,
49
+ aggregate_edges_for_globals_fn: jraph.AggregateEdgesToGlobalsFn = jraph
50
+ .segment_sum,
51
+ ):
52
+ """Returns a method that applies a configured GraphNetwork.
53
+
54
+ This implementation follows Algorithm 1 in https://arxiv.org/abs/1806.01261
55
+ extended to Typed Graphs with multiple edge sets and node sets and extended to
56
+ allow aggregating not only edges received by the nodes, but also edges sent by
57
+ the nodes.
58
+
59
+ Example usage::
60
+
61
+ gn = GraphNetwork(update_edge_function,
62
+ update_node_function, **kwargs)
63
+ # Conduct multiple rounds of message passing with the same parameters:
64
+ for _ in range(num_message_passing_steps):
65
+ graph = gn(graph)
66
+
67
+ Args:
68
+ update_edge_fn: mapping of functions used to update a subset of the edge
69
+ types, indexed by edge type name.
70
+ update_node_fn: mapping of functions used to update a subset of the node
71
+ types, indexed by node type name.
72
+ update_global_fn: function used to update the globals or None to deactivate
73
+ globals updates.
74
+ aggregate_edges_for_nodes_fn: function used to aggregate messages to each
75
+ node.
76
+ aggregate_nodes_for_globals_fn: function used to aggregate the nodes for the
77
+ globals.
78
+ aggregate_edges_for_globals_fn: function used to aggregate the edges for the
79
+ globals.
80
+
81
+ Returns:
82
+ A method that applies the configured GraphNetwork.
83
+ """
84
+
85
+ def _apply_graph_net(graph: typed_graph.TypedGraph) -> typed_graph.TypedGraph:
86
+ """Applies a configured GraphNetwork to a graph.
87
+
88
+ This implementation follows Algorithm 1 in https://arxiv.org/abs/1806.01261
89
+ extended to Typed Graphs with multiple edge sets and node sets and extended
90
+ to allow aggregating not only edges received by the nodes, but also edges
91
+ sent by the nodes.
92
+
93
+ Args:
94
+ graph: a `TypedGraph` containing the graph.
95
+
96
+ Returns:
97
+ Updated `TypedGraph`.
98
+ """
99
+
100
+ updated_graph = graph
101
+
102
+ # Edge update.
103
+ updated_edges = dict(updated_graph.edges)
104
+ for edge_set_name, edge_fn in update_edge_fn.items():
105
+ edge_set_key = graph.edge_key_by_name(edge_set_name)
106
+ updated_edges[edge_set_key] = _edge_update(
107
+ updated_graph, edge_fn, edge_set_key)
108
+ updated_graph = updated_graph._replace(edges=updated_edges)
109
+
110
+ # Node update.
111
+ updated_nodes = dict(updated_graph.nodes)
112
+ for node_set_key, node_fn in update_node_fn.items():
113
+ updated_nodes[node_set_key] = _node_update(
114
+ updated_graph, node_fn, node_set_key, aggregate_edges_for_nodes_fn)
115
+ updated_graph = updated_graph._replace(nodes=updated_nodes)
116
+
117
+ # Global update.
118
+ if update_global_fn:
119
+ updated_context = _global_update(
120
+ updated_graph, update_global_fn,
121
+ aggregate_edges_for_globals_fn,
122
+ aggregate_nodes_for_globals_fn)
123
+ updated_graph = updated_graph._replace(context=updated_context)
124
+
125
+ return updated_graph
126
+
127
+ return _apply_graph_net
128
+
129
+
130
+ def _edge_update(graph, edge_fn, edge_set_key): # pylint: disable=invalid-name
131
+ """Updates an edge set of a given key."""
132
+
133
+ sender_nodes = graph.nodes[edge_set_key.node_sets[0]]
134
+ receiver_nodes = graph.nodes[edge_set_key.node_sets[1]]
135
+ edge_set = graph.edges[edge_set_key]
136
+ senders = edge_set.indices.senders # pytype: disable=attribute-error
137
+ receivers = edge_set.indices.receivers # pytype: disable=attribute-error
138
+
139
+ sent_attributes = tree.tree_map(
140
+ lambda n: n[senders], sender_nodes.features)
141
+ received_attributes = tree.tree_map(
142
+ lambda n: n[receivers], receiver_nodes.features)
143
+
144
+ n_edge = edge_set.n_edge
145
+ sum_n_edge = senders.shape[0]
146
+ global_features = tree.tree_map(
147
+ lambda g: jnp.repeat(g, n_edge, axis=0, total_repeat_length=sum_n_edge),
148
+ graph.context.features)
149
+ new_features = edge_fn(
150
+ edge_set.features, sent_attributes, received_attributes,
151
+ global_features)
152
+ return edge_set._replace(features=new_features)
153
+
154
+
155
+ def _node_update(graph, node_fn, node_set_key, aggregation_fn): # pylint: disable=invalid-name
156
+ """Updates an edge set of a given key."""
157
+ node_set = graph.nodes[node_set_key]
158
+ sum_n_node = tree.tree_leaves(node_set.features)[0].shape[0]
159
+
160
+ sent_features = {}
161
+ for edge_set_key, edge_set in graph.edges.items():
162
+ sender_node_set_key = edge_set_key.node_sets[0]
163
+ if sender_node_set_key == node_set_key:
164
+ assert isinstance(edge_set.indices, typed_graph.EdgesIndices)
165
+ senders = edge_set.indices.senders
166
+ sent_features[edge_set_key.name] = tree.tree_map(
167
+ lambda e: aggregation_fn(e, senders, sum_n_node), edge_set.features) # pylint: disable=cell-var-from-loop
168
+
169
+ received_features = {}
170
+ for edge_set_key, edge_set in graph.edges.items():
171
+ receiver_node_set_key = edge_set_key.node_sets[1]
172
+ if receiver_node_set_key == node_set_key:
173
+ assert isinstance(edge_set.indices, typed_graph.EdgesIndices)
174
+ receivers = edge_set.indices.receivers
175
+ received_features[edge_set_key.name] = tree.tree_map(
176
+ lambda e: aggregation_fn(e, receivers, sum_n_node), edge_set.features) # pylint: disable=cell-var-from-loop
177
+
178
+ n_node = node_set.n_node
179
+ global_features = tree.tree_map(
180
+ lambda g: jnp.repeat(g, n_node, axis=0, total_repeat_length=sum_n_node),
181
+ graph.context.features)
182
+ new_features = node_fn(
183
+ node_set.features, sent_features, received_features, global_features)
184
+ return node_set._replace(features=new_features)
185
+
186
+
187
+ def _global_update(graph, global_fn, edge_aggregation_fn, node_aggregation_fn): # pylint: disable=invalid-name
188
+ """Updates an edge set of a given key."""
189
+ n_graph = graph.context.n_graph.shape[0]
190
+ graph_idx = jnp.arange(n_graph)
191
+
192
+ edge_features = {}
193
+ for edge_set_key, edge_set in graph.edges.items():
194
+ assert isinstance(edge_set.indices, typed_graph.EdgesIndices)
195
+ sum_n_edge = edge_set.indices.senders.shape[0]
196
+ edge_gr_idx = jnp.repeat(
197
+ graph_idx, edge_set.n_edge, axis=0, total_repeat_length=sum_n_edge)
198
+ edge_features[edge_set_key.name] = tree.tree_map(
199
+ lambda e: edge_aggregation_fn(e, edge_gr_idx, n_graph), # pylint: disable=cell-var-from-loop
200
+ edge_set.features)
201
+
202
+ node_features = {}
203
+ for node_set_key, node_set in graph.nodes.items():
204
+ sum_n_node = tree.tree_leaves(node_set.features)[0].shape[0]
205
+ node_gr_idx = jnp.repeat(
206
+ graph_idx, node_set.n_node, axis=0, total_repeat_length=sum_n_node)
207
+ node_features[node_set_key] = tree.tree_map(
208
+ lambda n: node_aggregation_fn(n, node_gr_idx, n_graph), # pylint: disable=cell-var-from-loop
209
+ node_set.features)
210
+
211
+ new_features = global_fn(node_features, edge_features, graph.context.features)
212
+ return graph.context._replace(features=new_features)
213
+
214
+
215
+ InteractionUpdateNodeFn = Callable[
216
+ [jraph.NodeFeatures,
217
+ Mapping[str, SenderFeatures],
218
+ Mapping[str, ReceiverFeatures]],
219
+ jraph.NodeFeatures]
220
+
221
+
222
+ InteractionUpdateNodeFnNoSentEdges = Callable[
223
+ [jraph.NodeFeatures,
224
+ Mapping[str, ReceiverFeatures]],
225
+ jraph.NodeFeatures]
226
+
227
+
228
+ def InteractionNetwork( # pylint: disable=invalid-name
229
+ update_edge_fn: Mapping[str, jraph.InteractionUpdateEdgeFn],
230
+ update_node_fn: Mapping[str, Union[InteractionUpdateNodeFn,
231
+ InteractionUpdateNodeFnNoSentEdges]],
232
+ aggregate_edges_for_nodes_fn: jraph.AggregateEdgesToNodesFn = jraph
233
+ .segment_sum,
234
+ include_sent_messages_in_node_update: bool = False):
235
+ """Returns a method that applies a configured InteractionNetwork.
236
+
237
+ An interaction network computes interactions on the edges based on the
238
+ previous edges features, and on the features of the nodes sending into those
239
+ edges. It then updates the nodes based on the incoming updated edges.
240
+ See https://arxiv.org/abs/1612.00222 for more details.
241
+
242
+ This implementation extends the behavior to `TypedGraphs` adding an option
243
+ to include edge features for which a node is a sender in the arguments to
244
+ the node update function.
245
+
246
+ Args:
247
+ update_edge_fn: mapping of functions used to update a subset of the edge
248
+ types, indexed by edge type name.
249
+ update_node_fn: mapping of functions used to update a subset of the node
250
+ types, indexed by node type name.
251
+ aggregate_edges_for_nodes_fn: function used to aggregate messages to each
252
+ node.
253
+ include_sent_messages_in_node_update: pass edge features for which a node is
254
+ a sender to the node update function.
255
+ """
256
+ # An InteractionNetwork is a GraphNetwork without globals features,
257
+ # so we implement the InteractionNetwork as a configured GraphNetwork.
258
+
259
+ # An InteractionNetwork edge function does not have global feature inputs,
260
+ # so we filter the passed global argument in the GraphNetwork.
261
+ wrapped_update_edge_fn = tree.tree_map(
262
+ lambda fn: lambda e, s, r, g: fn(e, s, r), update_edge_fn)
263
+
264
+ # Similarly, we wrap the update_node_fn to ensure only the expected
265
+ # arguments are passed to the Interaction net.
266
+ if include_sent_messages_in_node_update:
267
+ wrapped_update_node_fn = tree.tree_map(
268
+ lambda fn: lambda n, s, r, g: fn(n, s, r), update_node_fn)
269
+ else:
270
+ wrapped_update_node_fn = tree.tree_map(
271
+ lambda fn: lambda n, s, r, g: fn(n, r), update_node_fn)
272
+ return GraphNetwork(
273
+ update_edge_fn=wrapped_update_edge_fn,
274
+ update_node_fn=wrapped_update_node_fn,
275
+ aggregate_edges_for_nodes_fn=aggregate_edges_for_nodes_fn)
276
+
277
+
278
+ def GraphMapFeatures( # pylint: disable=invalid-name
279
+ embed_edge_fn: Optional[Mapping[str, jraph.EmbedEdgeFn]] = None,
280
+ embed_node_fn: Optional[Mapping[str, jraph.EmbedNodeFn]] = None,
281
+ embed_global_fn: Optional[jraph.EmbedGlobalFn] = None):
282
+ """Returns function which embeds the components of a graph independently.
283
+
284
+ Args:
285
+ embed_edge_fn: mapping of functions used to embed each edge type,
286
+ indexed by edge type name.
287
+ embed_node_fn: mapping of functions used to embed each node type,
288
+ indexed by node type name.
289
+ embed_global_fn: function used to embed the globals.
290
+ """
291
+
292
+ def _embed(graph: typed_graph.TypedGraph) -> typed_graph.TypedGraph:
293
+
294
+ updated_edges = dict(graph.edges)
295
+ if embed_edge_fn:
296
+ for edge_set_name, embed_fn in embed_edge_fn.items():
297
+ edge_set_key = graph.edge_key_by_name(edge_set_name)
298
+ edge_set = graph.edges[edge_set_key]
299
+ updated_edges[edge_set_key] = edge_set._replace(
300
+ features=embed_fn(edge_set.features))
301
+
302
+ updated_nodes = dict(graph.nodes)
303
+ if embed_node_fn:
304
+ for node_set_key, embed_fn in embed_node_fn.items():
305
+ node_set = graph.nodes[node_set_key]
306
+ updated_nodes[node_set_key] = node_set._replace(
307
+ features=embed_fn(node_set.features))
308
+
309
+ updated_context = graph.context
310
+ if embed_global_fn:
311
+ updated_context = updated_context._replace(
312
+ features=embed_global_fn(updated_context.features))
313
+
314
+ return graph._replace(edges=updated_edges, nodes=updated_nodes,
315
+ context=updated_context)
316
+
317
+ return _embed
model/graphcast/xarray_jax.py ADDED
@@ -0,0 +1,1080 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2023 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Helpers to use xarray.{Variable,DataArray,Dataset} with JAX.
15
+
16
+ Allows them to be based on JAX arrays without converting to numpy arrays under
17
+ the hood, so you can start with a JAX array, do some computation with it in
18
+ xarray-land, get a JAX array out the other end and (for example) jax.jit
19
+ through the whole thing. You can even jax.jit a function which accepts and
20
+ returns xarray.Dataset, DataArray and Variable.
21
+
22
+ ## Creating xarray datatypes from jax arrays, and vice-versa.
23
+
24
+ You can use the xarray_jax.{Variable,DataArray,Dataset} constructors, which have
25
+ the same API as the standard xarray constructors but will accept JAX arrays
26
+ without converting them to numpy.
27
+
28
+ It does this by wrapping the JAX array in a wrapper before passing it to
29
+ xarray; you can also do this manually by calling xarray_jax.wrap on your JAX
30
+ arrays before passing them to the standard xarray constructors.
31
+
32
+ To get non-wrapped JAX arrays out the other end, you can use e.g.:
33
+
34
+ xarray_jax.jax_vars(dataset)
35
+ xarray_jax.jax_data(dataset.some_var)
36
+
37
+ which will complain if the data isn't actually a JAX array. Use this if you need
38
+ to make sure the computation has gone via JAX, e.g. if it's the output of code
39
+ that you want to JIT or compute gradients through. If this is not the case and
40
+ you want to support passing plain numpy arrays through as well as potentially
41
+ JAX arrays, you can use:
42
+
43
+ xarray_jax.unwrap_vars(dataset)
44
+ xarray_jax.unwrap_data(dataset.some_var)
45
+
46
+ which will unwrap the data if it is a wrapped JAX array, but otherwise pass
47
+ it through to you without complaint.
48
+
49
+ The wrapped JAX arrays aim to support all the core operations from the numpy
50
+ array API that xarray expects, however there may still be some gaps; if you run
51
+ into any problems around this, you may need to add a few more proxy methods onto
52
+ the wrapper class below.
53
+
54
+ In future once JAX and xarray support the new Python array API standard
55
+ (https://data-apis.org/array-api/latest/index.html), we hope to avoid the need
56
+ for wrapping the JAX arrays like this.
57
+
58
+ ## jax.jit and pmap of functions taking and returning xarray datatypes
59
+
60
+ We register xarray datatypes with jax.tree_util, which allows them to be treated
61
+ as generic containers of jax arrays by various parts of jax including jax.jit.
62
+
63
+ This allows for, e.g.:
64
+
65
+ @jax.jit
66
+ def foo(input: xarray.Dataset) -> xarray.Dataset:
67
+ ...
68
+
69
+ It will not work out-of-the-box with shape-modifying transformations like
70
+ jax.pmap, or e.g. a jax.tree_util.tree_map with some transform that alters array
71
+ shapes or dimension order. That's because we won't know what dimension names
72
+ and/or coordinates to use when unflattening, if the results have a different
73
+ shape to the data that was originally flattened.
74
+
75
+ You can work around this using xarray_jax.dims_change_on_unflatten, however,
76
+ and in the case of jax.pmap we provide a wrapper xarray_jax.pmap which allows
77
+ it to be used with functions taking and returning xarrays.
78
+
79
+ ## Treatment of coordinates
80
+
81
+ We don't support passing jax arrays as coordinates when constructing a
82
+ DataArray/Dataset. This is because xarray's advanced indexing and slicing is
83
+ unlikely to work with jax arrays (at least when a Tracer is used during
84
+ jax.jit), and also because some important datatypes used for coordinates, like
85
+ timedelta64 and datetime64, are not supported by jax.
86
+
87
+ For the purposes of tree_util and jax.jit, coordinates are not treated as leaves
88
+ of the tree (array data 'contained' by a Dataset/DataArray), they are just a
89
+ static part of the structure. That means that if a jit'ed function is called
90
+ twice with Dataset inputs that use different coordinates, it will compile a
91
+ separate version of the function for each. The coordinates are treated like
92
+ static_argnums by jax.jit.
93
+
94
+ If you want to use dynamic data for coordinates, we recommend making it a
95
+ data_var instead of a coord. You won't be able to do indexing and slicing using
96
+ the coordinate, but that wasn't going to work with a jax array anyway.
97
+ """
98
+
99
+ import collections
100
+ import contextlib
101
+ import contextvars
102
+ from typing import Any, Callable, Iterator, Mapping, Optional, Union, Tuple, TypeVar, cast
103
+ from typing import Hashable # pylint: disable=deprecated-class
104
+
105
+ import jax
106
+ import jax.numpy as jnp
107
+ import numpy as np
108
+ import tree
109
+ import xarray
110
+
111
+
112
+ # Types which we wrap with JaxArrayWrapper to allow creating xarray datatypes
113
+ # from them.
114
+ # Note this includes some non-Array types which jax sometimes needs to use as
115
+ # leaves of pytrees, in order to ensure we can still use xarray datatypes as
116
+ # internal pytree nodes in these cases.
117
+ _WRAPPED_TYPES = (
118
+ jax.Array, jax.ShapeDtypeStruct, jax.stages.ArgInfo)
119
+
120
+
121
+ def Variable(dims, data, **kwargs) -> xarray.Variable: # pylint:disable=invalid-name
122
+ """Like xarray.Variable, but can wrap JAX arrays."""
123
+ return xarray.Variable(dims, wrap(data), **kwargs)
124
+
125
+
126
+ _JAX_COORD_ATTR_NAME = '_jax_coord'
127
+
128
+
129
+ def DataArray( # pylint:disable=invalid-name
130
+ data,
131
+ coords=None,
132
+ dims=None,
133
+ name=None,
134
+ attrs=None,
135
+ jax_coords=None,
136
+ ) -> xarray.DataArray:
137
+ """Like xarray.DataArray, but supports using JAX arrays.
138
+
139
+ Args:
140
+ data: As for xarray.DataArray, except jax arrays are also supported.
141
+ coords: Coordinates for the array, see xarray.DataArray. These coordinates
142
+ must be based on plain numpy arrays or something convertible to plain
143
+ numpy arrays. Their values will form a static part of the data structure
144
+ from the point of view of jax.tree_util. In particular this means these
145
+ coordinates will be passed as plain numpy arrays even inside a JIT'd
146
+ function, and the JIT'd function will be recompiled under the hood if the
147
+ coordinates of DataArrays passed into it change.
148
+ If this is not convenient for you, see also jax_coords below.
149
+ dims: See xarray.DataArray.
150
+ name: See xarray.DataArray.
151
+ attrs: See xarray.DataArray.
152
+ jax_coords: Additional coordinates, which *can* use JAX arrays. These
153
+ coordinates will be treated as JAX data from the point of view of
154
+ jax.tree_util, that means when JIT'ing they will be passed as tracers and
155
+ computation involving them will be JIT'd.
156
+ Unfortunately a side-effect of this is that they can't be used as index
157
+ coordinates (because xarray's indexing logic is not JIT-able). If you
158
+ specify a coordinate with the same name as a dimension here, it will not
159
+ be set as an index coordinate; this behaviour is different to the default
160
+ for `coords`, and it means that things like `.sel` based on the jax
161
+ coordinate will not work.
162
+ Note we require `jax_coords` to be explicitly specified via a different
163
+ constructor argument to `coords`, rather than just looking for jax arrays
164
+ within the `coords` and treating them differently. This is because it
165
+ affects the way jax.tree_util treats them, which is somewhat orthogonal to
166
+ whether the value is passed in as numpy or not, and generally needs to be
167
+ handled consistently so is something we encourage explicit control over.
168
+
169
+ Returns:
170
+ An instance of xarray.DataArray. Where JAX arrays are used as data or
171
+ coords, they will be wrapped with JaxArrayWrapper and can be unwrapped via
172
+ `unwrap` and `unwrap_data`.
173
+ """
174
+ result = xarray.DataArray(
175
+ wrap(data), dims=dims, name=name, attrs=attrs or {})
176
+ return assign_coords(result, coords=coords, jax_coords=jax_coords)
177
+
178
+
179
+ def Dataset( # pylint:disable=invalid-name
180
+ data_vars=None,
181
+ coords=None,
182
+ attrs=None,
183
+ jax_coords=None,
184
+ ) -> xarray.Dataset:
185
+ """Like xarray.Dataset, but can wrap JAX arrays.
186
+
187
+ Args:
188
+ data_vars: As for xarray.Dataset, except jax arrays are also supported.
189
+ coords: Coordinates for the dataset, see xarray.Dataset. These coordinates
190
+ must be based on plain numpy arrays or something convertible to plain
191
+ numpy arrays. Their values will form a static part of the data structure
192
+ from the point of view of jax.tree_util. In particular this means these
193
+ coordinates will be passed as plain numpy arrays even inside a JIT'd
194
+ function, and the JIT'd function will be recompiled under the hood if the
195
+ coordinates of DataArrays passed into it change.
196
+ If this is not convenient for you, see also jax_coords below.
197
+ attrs: See xarray.Dataset.
198
+ jax_coords: Additional coordinates, which *can* use JAX arrays. These
199
+ coordinates will be treated as JAX data from the point of view of
200
+ jax.tree_util, that means when JIT'ing they will be passed as tracers and
201
+ computation involving them will be JIT'd.
202
+ Unfortunately a side-effect of this is that they can't be used as index
203
+ coordinates (because xarray's indexing logic is not JIT-able). If you
204
+ specify a coordinate with the same name as a dimension here, it will not
205
+ be set as an index coordinate; this behaviour is different to the default
206
+ for `coords`, and it means that things like `.sel` based on the jax
207
+ coordinate will not work.
208
+ Note we require `jax_coords` to be explicitly specified via a different
209
+ constructor argument to `coords`, rather than just looking for jax arrays
210
+ within the `coords` and treating them differently. This is because it
211
+ affects the way jax.tree_util treats them, which is somewhat orthogonal to
212
+ whether the value is passed in as numpy or not, and generally needs to be
213
+ handled consistently so is something we encourage explicit control over.
214
+
215
+ Returns:
216
+ An instance of xarray.Dataset. Where JAX arrays are used as data, they
217
+ will be wrapped with JaxArrayWrapper.
218
+ """
219
+ wrapped_data_vars = {}
220
+ for name, var_like in (data_vars or {}).items():
221
+ # xarray.Dataset accepts a few different formats for data_vars:
222
+ if isinstance(var_like, _WRAPPED_TYPES):
223
+ wrapped_data_vars[name] = wrap(var_like)
224
+ elif isinstance(var_like, tuple):
225
+ # Layout is (dims, data, ...). We wrap data.
226
+ wrapped_data_vars[name] = (var_like[0], wrap(var_like[1])) + var_like[2:]
227
+ else:
228
+ # Could be a plain numpy array or scalar (we don't wrap), or an
229
+ # xarray.Variable, DataArray etc, which we must assume is already wrapped
230
+ # if necessary (e.g. if creating using xarray_jax.{Variable,DataArray}).
231
+ wrapped_data_vars[name] = var_like
232
+
233
+ result = xarray.Dataset(
234
+ data_vars=wrapped_data_vars,
235
+ attrs=attrs)
236
+
237
+ return assign_coords(result, coords=coords, jax_coords=jax_coords)
238
+
239
+
240
+ DatasetOrDataArray = TypeVar(
241
+ 'DatasetOrDataArray', xarray.Dataset, xarray.DataArray)
242
+
243
+
244
+ def assign_coords(
245
+ x: DatasetOrDataArray,
246
+ *,
247
+ coords: Optional[Mapping[Hashable, Any]] = None,
248
+ jax_coords: Optional[Mapping[Hashable, Any]] = None,
249
+ ) -> DatasetOrDataArray:
250
+ """Replacement for assign_coords which works in presence of jax_coords.
251
+
252
+ `jax_coords` allow certain specified coordinates to have their data passed as
253
+ JAX arrays (including through jax.jit boundaries). The compromise in return is
254
+ that they are not created as index coordinates and cannot be used for .sel
255
+ and other coordinate-based indexing operations. See docs for `jax_coords` on
256
+ xarray_jax.Dataset and xarray_jax.DataArray for more information.
257
+
258
+ This function can be used to set jax_coords on an existing DataArray or
259
+ Dataset, and also to set a mix of jax and non-jax coordinates. It implements
260
+ some workarounds to prevent xarray trying and failing to create IndexVariables
261
+ from jax arrays under the hood.
262
+
263
+ If you have any jax_coords with the same name as a dimension, you'll need to
264
+ use this function instead of data_array.assign_coords or dataset.assign_coords
265
+ in general, to avoid an xarray bug where it tries (and in our case fails) to
266
+ create indexes for existing jax coords. See
267
+ https://github.com/pydata/xarray/issues/7885.
268
+
269
+ Args:
270
+ x: An xarray Dataset or DataArray.
271
+ coords: Dict of (non-JAX) coords, or None if not assigning any.
272
+ jax_coords: Dict of JAX coords, or None if not assigning any. See docs for
273
+ xarray_jax.Dataset / DataArray for more information on jax_coords.
274
+
275
+ Returns:
276
+ The Dataset or DataArray with coordinates assigned, similarly to
277
+ Dataset.assign_coords / DataArray.assign_coords.
278
+ """
279
+ coords = {} if coords is None else dict(coords) # Copy before mutating.
280
+ jax_coords = {} if jax_coords is None else dict(jax_coords)
281
+
282
+ # Any existing JAX coords must be dropped and re-added via the workaround
283
+ # below, since otherwise .assign_coords will trigger an xarray bug where
284
+ # it tries to recreate the indexes again for the existing coordinates.
285
+ # Can remove if/when https://github.com/pydata/xarray/issues/7885 fixed.
286
+ existing_jax_coords = get_jax_coords(x)
287
+ jax_coords = existing_jax_coords | jax_coords
288
+ x = x.drop_vars(existing_jax_coords.keys())
289
+
290
+ # We need to ensure that xarray doesn't try to create an index for
291
+ # coordinates with the same name as a dimension, since this will fail if
292
+ # given a wrapped JAX tracer.
293
+ # It appears the only way to avoid this is to name them differently to any
294
+ # dimension name, then rename them back afterwards.
295
+ renamed_jax_coords = {}
296
+ for name, coord in jax_coords.items():
297
+ if isinstance(coord, xarray.DataArray):
298
+ coord = coord.variable
299
+
300
+ if isinstance(coord, list):
301
+ coord = np.array(coord)
302
+
303
+ if isinstance(coord, xarray.Variable):
304
+ coord = coord.copy(deep=False) # Copy before mutating attrs.
305
+ elif isinstance(coord, tuple):
306
+ # A tuple represents a pair of (dims, data).
307
+ dims, data = coord
308
+ coord = Variable(dims, data)
309
+ elif jnp.isscalar(coord):
310
+ # A scalar coord maps to a scalar Variable:
311
+ coord = Variable(dims=(), data=coord)
312
+ elif isinstance(coord, jax.typing.ArrayLike) and jnp.ndim(coord) == 1:
313
+ # A 1D array maps to a 1D Variable whose dimension is the same as the
314
+ # coordinate name:
315
+ coord = Variable((name,), coord)
316
+ else:
317
+ raise ValueError(f'Unsupported value for coordinate {name}')
318
+
319
+ # We set an attr on each jax_coord identifying it as such. These attrs on
320
+ # the coord Variable gets reflected on the coord DataArray exposed too, and
321
+ # when set on coordinates they generally get preserved under the default
322
+ # keep_attrs setting.
323
+ # These attrs are used by jax.tree_util registered flatten/unflatten to
324
+ # determine which coords need to be treated as leaves of the flattened
325
+ # structure vs static data.
326
+ coord.attrs[_JAX_COORD_ATTR_NAME] = True
327
+ renamed_jax_coords[f'__NONINDEX_{name}'] = coord
328
+
329
+ x = x.assign_coords(coords=coords | renamed_jax_coords)
330
+
331
+ rename_back_mapping = {f'__NONINDEX_{name}': name for name in jax_coords}
332
+ if isinstance(x, xarray.Dataset):
333
+ # Using 'rename' doesn't work if renaming to the same name as a dimension.
334
+ return x.rename_vars(rename_back_mapping)
335
+ else: # DataArray
336
+ return x.rename(rename_back_mapping)
337
+
338
+
339
+ def get_jax_coords(x: DatasetOrDataArray) -> Mapping[Hashable, Any]:
340
+ return {
341
+ name: coord_var
342
+ for name, coord_var in x.coords.variables.items()
343
+ if coord_var.attrs.get(_JAX_COORD_ATTR_NAME, False)}
344
+
345
+
346
+ def assign_jax_coords(
347
+ x: DatasetOrDataArray,
348
+ jax_coords: Optional[Mapping[Hashable, Any]] = None,
349
+ **jax_coords_kwargs
350
+ ) -> DatasetOrDataArray:
351
+ """Assigns only jax_coords, with same API as xarray's assign_coords."""
352
+ return assign_coords(x, jax_coords=jax_coords or jax_coords_kwargs)
353
+
354
+
355
+ def wrap(value):
356
+ """Wraps JAX arrays for use in xarray, passing through other values."""
357
+ if isinstance(value, _WRAPPED_TYPES):
358
+ return JaxArrayWrapper(value)
359
+ else:
360
+ return value
361
+
362
+
363
+ def unwrap(value, require_jax=False):
364
+ """Unwraps wrapped JAX arrays used in xarray, passing through other values."""
365
+ if isinstance(value, JaxArrayWrapper):
366
+ return value.jax_array
367
+ elif isinstance(value, jax.Array):
368
+ return value
369
+ elif require_jax:
370
+ raise TypeError(f'Expected JAX array, found {type(value)}.')
371
+ else:
372
+ return value
373
+
374
+
375
+ def _wrapped(func):
376
+ """Surrounds a function with JAX array unwrapping/wrapping."""
377
+ def wrapped_func(*args, **kwargs):
378
+ args, kwargs = tree.map_structure(unwrap, (args, kwargs))
379
+ result = func(*args, **kwargs)
380
+ return tree.map_structure(wrap, result)
381
+ return wrapped_func
382
+
383
+
384
+ def unwrap_data(
385
+ value: Union[xarray.Variable, xarray.DataArray],
386
+ require_jax: bool = False
387
+ ) -> Union[jax.Array, np.ndarray]:
388
+ """The unwrapped (see unwrap) data of a an xarray.Variable or DataArray."""
389
+ return unwrap(value.data, require_jax=require_jax)
390
+
391
+
392
+ def unwrap_vars(
393
+ dataset: Mapping[Hashable, xarray.DataArray],
394
+ require_jax: bool = False
395
+ ) -> Mapping[str, Union[jax.Array, np.ndarray]]:
396
+ """The unwrapped data (see unwrap) of the variables in a dataset."""
397
+ # xarray types variable names as Hashable, but in practice they're invariably
398
+ # strings and we convert to str to allow for a more useful return type.
399
+ return {str(name): unwrap_data(var, require_jax=require_jax)
400
+ for name, var in dataset.items()}
401
+
402
+
403
+ def unwrap_coords(
404
+ dataset: Union[xarray.Dataset, xarray.DataArray],
405
+ require_jax: bool = False
406
+ ) -> Mapping[str, Union[jax.Array, np.ndarray]]:
407
+ """The unwrapped data (see unwrap) of the coords in a Dataset or DataArray."""
408
+ return {str(name): unwrap_data(var, require_jax=require_jax)
409
+ for name, var in dataset.coords.items()}
410
+
411
+
412
+ def jax_data(value: Union[xarray.Variable, xarray.DataArray]) -> jax.Array:
413
+ """Like unwrap_data, but will complain if not a jax array."""
414
+ # Implementing this separately so we can give a more specific return type
415
+ # for it.
416
+ return cast(jax.Array, unwrap_data(value, require_jax=True))
417
+
418
+
419
+ def jax_vars(
420
+ dataset: Mapping[Hashable, xarray.DataArray]) -> Mapping[str, jax.Array]:
421
+ """Like unwrap_vars, but will complain if vars are not all jax arrays."""
422
+ return cast(Mapping[str, jax.Array], unwrap_vars(dataset, require_jax=True))
423
+
424
+
425
+ class JaxArrayWrapper(np.lib.mixins.NDArrayOperatorsMixin):
426
+ """Wraps a JAX array into a duck-typed array suitable for use with xarray.
427
+
428
+ This uses an older duck-typed array protocol based on __array_ufunc__ and
429
+ __array_function__ which works with numpy and xarray. (In newer versions
430
+ of xarray it implements xarray.namedarray._typing._array_function.)
431
+
432
+ This is in the process of being superseded by the Python array API standard
433
+ (https://data-apis.org/array-api/latest/index.html), but JAX hasn't
434
+ implemented it yet. Once they have, we should be able to get rid of
435
+ this wrapper and use JAX arrays directly with xarray.
436
+
437
+ """
438
+
439
+ def __init__(self, jax_array):
440
+ self.jax_array = jax_array
441
+
442
+ def __array_ufunc__(self, ufunc, method, *args, **kwargs):
443
+ for x in args:
444
+ if not isinstance(x, (jax.typing.ArrayLike, type(self))):
445
+ return NotImplemented
446
+ if method != '__call__':
447
+ return NotImplemented
448
+ try:
449
+ # Get the corresponding jax.numpy function to the NumPy ufunc:
450
+ func = getattr(jnp, ufunc.__name__)
451
+ except AttributeError:
452
+ return NotImplemented
453
+ # There may be an 'out' kwarg requesting an in-place operation, e.g. when
454
+ # this is called via __iadd__ (+=), __imul__ (*=) etc. JAX doesn't support
455
+ # in-place operations so we just remove this argument and have the ufunc
456
+ # return a fresh JAX array instead.
457
+ kwargs.pop('out', None)
458
+ return _wrapped(func)(*args, **kwargs)
459
+
460
+ def __array_function__(self, func, types, args, kwargs):
461
+ try:
462
+ # Get the corresponding jax.np function to the NumPy function:
463
+ func = getattr(jnp, func.__name__)
464
+ except AttributeError:
465
+ return NotImplemented
466
+ return _wrapped(func)(*args, **kwargs)
467
+
468
+ def __repr__(self):
469
+ return f'xarray_jax.JaxArrayWrapper({repr(self.jax_array)})'
470
+
471
+ # NDArrayOperatorsMixin already proxies most __dunder__ operator methods.
472
+ # We need to proxy through a few more methods in a similar way:
473
+
474
+ # Essential array properties:
475
+
476
+ @property
477
+ def shape(self):
478
+ return self.jax_array.shape
479
+
480
+ @property
481
+ def dtype(self):
482
+ return self.jax_array.dtype
483
+
484
+ @property
485
+ def ndim(self):
486
+ return self.jax_array.ndim
487
+
488
+ @property
489
+ def size(self):
490
+ return self.jax_array.size
491
+
492
+ @property
493
+ def real(self):
494
+ return self.jax_array.real
495
+
496
+ @property
497
+ def imag(self):
498
+ return self.jax_array.imag
499
+
500
+ # Array methods not covered by NDArrayOperatorsMixin:
501
+
502
+ # Allows conversion to numpy array using np.asarray etc. Warning: doing this
503
+ # will fail in a jax.jit-ed function.
504
+ def __array__(self, dtype=None, context=None):
505
+ return np.asarray(self.jax_array, dtype=dtype)
506
+
507
+ __getitem__ = _wrapped(lambda array, *args: array.__getitem__(*args))
508
+ # We drop the kwargs on this as they are not supported by JAX, but xarray
509
+ # uses at least one of them (the copy arg).
510
+ astype = _wrapped(lambda array, *args, **kwargs: array.astype(*args))
511
+
512
+ # There are many more methods which are more canonically available via (j)np
513
+ # functions, e.g. .sum() available via jnp.sum, and also mean, max, min,
514
+ # argmax, argmin etc. We don't attempt to proxy through all of these as
515
+ # methods, since this doesn't appear to be expected from a duck-typed array
516
+ # implementation. But there are a few which xarray calls as methods, so we
517
+ # proxy those:
518
+ transpose = _wrapped(jnp.transpose)
519
+ reshape = _wrapped(jnp.reshape)
520
+ all = _wrapped(jnp.all)
521
+
522
+
523
+ def apply_ufunc(func, *args, require_jax=False, **apply_ufunc_kwargs):
524
+ """Like xarray.apply_ufunc but for jax-specific ufuncs.
525
+
526
+ Many numpy ufuncs will work fine out of the box with xarray_jax and
527
+ JaxArrayWrapper, since JaxArrayWrapper quacks (mostly) like a numpy array and
528
+ will convert many numpy operations to jax ops under the hood. For these
529
+ situations, xarray.apply_ufunc should work fine.
530
+
531
+ But sometimes you need a jax-specific ufunc which needs to be given a
532
+ jax array as input or return a jax array as output. In that case you should
533
+ use this helper as it will remove any JaxArrayWrapper before calling the func,
534
+ and wrap the result afterwards before handing it back to xarray.
535
+
536
+ Args:
537
+ func: A function that works with jax arrays (e.g. using functions from
538
+ jax.numpy) but otherwise meets the spec for the func argument to
539
+ xarray.apply_ufunc.
540
+ *args: xarray arguments to be mapped to arguments for func
541
+ (see xarray.apply_ufunc).
542
+ require_jax: Whether to require that inputs are based on jax arrays or allow
543
+ those based on plain numpy arrays too.
544
+ **apply_ufunc_kwargs: See xarray.apply_ufunc.
545
+
546
+ Returns:
547
+ Corresponding xarray results (see xarray.apply_ufunc).
548
+ """
549
+ def wrapped_func(*maybe_wrapped_args):
550
+ unwrapped_args = [unwrap(a, require_jax) for a in maybe_wrapped_args]
551
+ result = func(*unwrapped_args)
552
+ # Result can be an array or a tuple of arrays, this handles both:
553
+ return jax.tree_util.tree_map(wrap, result)
554
+ return xarray.apply_ufunc(wrapped_func, *args, **apply_ufunc_kwargs)
555
+
556
+
557
+ def pmap(
558
+ fn: Callable[..., Any],
559
+ dim: str,
560
+ axis_name: Optional[str] = None,
561
+ devices=None,
562
+ backend=None,
563
+ ) -> Callable[..., Any]:
564
+ """Wraps a subset of jax.pmap functionality to handle xarray input/output.
565
+
566
+ Constraints:
567
+ * Any Dataset or DataArray passed to the function must have `dim` as the
568
+ first dimension. This will be checked. You can ensure this if necessary
569
+ by calling `.transpose(dim, ...)` beforehand.
570
+ * All args and return values will be mapped over the first dimension,
571
+ it will use in_axes=0, out_axes=0.
572
+ * No support for static_broadcasted_argnums, donate_argnums etc.
573
+
574
+ Args:
575
+ fn: Function to be pmap'd which takes and returns trees which may contain
576
+ xarray Dataset/DataArray. Any Dataset/DataArrays passed as input must use
577
+ `dim` as the first dimension on all arrays.
578
+ dim: The xarray dimension name corresponding to the first dimension that is
579
+ pmapped over (pmap is called with in_axes=0, out_axes=0).
580
+ axis_name: Used by jax to identify the mapped axis so that parallel
581
+ collectives can be applied. Defaults to same as `dim`.
582
+ devices:
583
+ backend:
584
+ See jax.pmap.
585
+
586
+ Returns:
587
+ A pmap'd version of `fn`, which takes and returns Dataset/DataArray with an
588
+ extra leading dimension `dim` relative to what the original `fn` sees.
589
+ """
590
+ return _vmap_or_pmap(
591
+ fn, dim, axis_name, devices, backend, is_vmap=False,
592
+ )
593
+
594
+
595
+ def vmap(
596
+ fn: Callable[..., Any],
597
+ dim: str,
598
+ axis_name: Optional[str] = None,
599
+ ) -> Callable[..., Any]:
600
+ """Similar to pmap, but for vmap."""
601
+ return _vmap_or_pmap(
602
+ fn, dim, axis_name, None, None, is_vmap=True,
603
+ )
604
+
605
+
606
+ def _vmap_or_pmap(
607
+ fn: Callable[..., Any],
608
+ dim: str,
609
+ axis_name: Optional[str] = None,
610
+ devices=None,
611
+ backend=None,
612
+ is_vmap: bool = False,
613
+ ) -> Callable[..., Any]:
614
+ """See pmap documentations."""
615
+
616
+ input_treedef = None
617
+ output_treedef = None
618
+
619
+ def fn_passed_to_pmap(*flat_args):
620
+ assert input_treedef is not None
621
+ # Inside the pmap the original first dimension will no longer be present:
622
+ def check_and_remove_leading_dim(dims):
623
+ try:
624
+ index = dims.index(dim)
625
+ except ValueError:
626
+ index = None
627
+ if index != 0:
628
+ raise ValueError(f'Expected dim {dim} at index 0, found at {index}.')
629
+ return dims[1:]
630
+ with dims_change_on_unflatten(check_and_remove_leading_dim):
631
+ args = jax.tree_util.tree_unflatten(input_treedef, flat_args)
632
+ result = fn(*args)
633
+ nonlocal output_treedef
634
+ flat_result, output_treedef = jax.tree_util.tree_flatten(result)
635
+ return flat_result
636
+
637
+ if is_vmap:
638
+ assert devices is None
639
+ assert backend is None
640
+ pmapped_fn = jax.vmap(
641
+ fn_passed_to_pmap,
642
+ axis_name=axis_name or dim,
643
+ in_axes=0,
644
+ out_axes=0)
645
+ else:
646
+ pmapped_fn = jax.pmap(
647
+ fn_passed_to_pmap,
648
+ axis_name=axis_name or dim,
649
+ in_axes=0,
650
+ out_axes=0,
651
+ devices=devices,
652
+ backend=backend)
653
+
654
+ def result_fn(*args):
655
+ nonlocal input_treedef
656
+ flat_args, input_treedef = jax.tree_util.tree_flatten(args)
657
+ flat_result = pmapped_fn(*flat_args)
658
+ assert output_treedef is not None
659
+ # After the pmap an extra leading axis will be present, we need to add an
660
+ # xarray dimension for this when unflattening the result:
661
+ with dims_change_on_unflatten(lambda dims: (dim,) + dims):
662
+ return jax.tree_util.tree_unflatten(output_treedef, flat_result)
663
+
664
+ return result_fn
665
+
666
+
667
+ _PyTree = TypeVar('_PyTree')
668
+
669
+
670
+ def tree_map_variables(
671
+ func: Callable[[xarray.Variable], xarray.Variable],
672
+ tree_data: _PyTree) -> _PyTree:
673
+ """Like jax.tree.map but operates with Variables as leaves.
674
+
675
+ This will work with any jax.tree_util-registered PyTree containing xarray
676
+ datatypes. All jax data in xarray datatypes is exposed via xarray.Variable
677
+ nodes by our registered flatten/unflatten functions and hence here too. Note
678
+ static coordinate data will not be mapped over however.
679
+
680
+ This allows you to see the associated dimensions for each leaf, and to change
681
+ them. If you change them, it's your responsibility to ensure that when
682
+ unflattened back into DataArray/Dataset/DataTree the result still makes sense.
683
+ In particular that any updated shapes are consistent with the shapes of any
684
+ static (non-jax_coord) coordinates, since these will not be mapped over.
685
+
686
+ Args:
687
+ func: Function from xarray.Variable to xarray.Variable.
688
+ tree_data: PyTree to be mapped over.
689
+
690
+ Returns:
691
+ PyTree with the same structure as `tree_data` but where xarray.Variables
692
+ within xarray datatypes have been mapped over by `func`. Any leaves outside
693
+ of xarray datatypes will be unchanged.
694
+ """
695
+ return jax.tree.map(
696
+ lambda leaf: func(leaf) if isinstance(leaf, xarray.Variable) else leaf,
697
+ tree_data,
698
+ is_leaf=lambda x: isinstance(x, xarray.Variable))
699
+
700
+
701
+ def tree_map_with_dims(
702
+ func: Callable[[jax.typing.ArrayLike, tuple[str, ...] | None],
703
+ jax.typing.ArrayLike],
704
+ data: _PyTree,
705
+ ) -> _PyTree:
706
+ """Like jax.tree.map but also passes in xarray dimensions where known.
707
+
708
+ This is convenient when applying logic to every jax array in some xarray data
709
+ structure, which wants to be sensitive to the xarray dimension names.
710
+ Typical examples of this would be jax operations relating to sharding where
711
+ you may want to map xarray dimension names to sharding axis names.
712
+
713
+ This only supports changing array shapes in limited situations (see below).
714
+
715
+ Unlike tree_map_variables above, this will also map over plain jax arrays
716
+ that don't occur within xarray.Variable nodes; these will be passed to func
717
+ with dims=None.
718
+
719
+ Args:
720
+ func: A function from (jax_array, dims) -> jax_array. dims will correspond
721
+ to the dimension names of the xarray.Variable containing the jax_array
722
+ where it occurs within an xarray.Variable, note this includes arrays
723
+ within xarray.Dataset and xarray.DataArray too. For plain jax arrays that
724
+ don't occur within an xarray.Variable, dims will be None.
725
+ The returned jax array should generally be of the same shape as the input.
726
+ However you can get away with changing the shape of a particular dimension
727
+ in limited circumstances: when there are no explicit coordinates involving
728
+ that dimension, or when the only coordinates involving that dimension are
729
+ jax_coords and you modify their shapes too in a consistent fashion.
730
+ You are not allowed to change the dimension order, add or remove
731
+ dimensions.
732
+ data: Any pytree with jax ArrayLike leaves suitable for use with
733
+ `jax.tree.map`. Thanks to xarray_jax such pytrees may include xarray
734
+ datatypes.
735
+
736
+ Returns:
737
+ A pytree of the same structure as data, with the result of applying func
738
+ to each jax array found.
739
+ """
740
+ # All jax arrays within xarray.Dataset, xarray.DataArray (including
741
+ # jax_coord arrays) will be exposed via xarray.Variable internal nodes by
742
+ # xarray_jax's pytree registrations. So to find xarray dimension metadata
743
+ # it's sufficient to stop descending at xarray.Variable nodes:
744
+ def is_leaf(x):
745
+ return isinstance(x, xarray.Variable)
746
+
747
+ def wrapped_func(x):
748
+ if isinstance(x, xarray.Variable):
749
+ array = unwrap(x.data)
750
+ array = func(array, x.dims)
751
+ return Variable(dims=x.dims, data=array)
752
+ else:
753
+ return func(x, None)
754
+
755
+ return jax.tree_util.tree_map(wrapped_func, data, is_leaf=is_leaf)
756
+
757
+
758
+ _Carry = TypeVar('_Carry')
759
+ _X = TypeVar('_X')
760
+ _Y = TypeVar('_Y')
761
+
762
+
763
+ def scan(f: Callable[[_Carry, _X], tuple[_Carry, _Y]],
764
+ init: _Carry,
765
+ dim: str,
766
+ xs: _X | None = None,
767
+ length: int | None = None,
768
+ reverse: bool = False,
769
+ unroll: int | bool = 1,
770
+ ) -> tuple[_Carry, _Y]:
771
+ """Like jax.lax.scan but supports xarray data.
772
+
773
+ This can handle a jax.tree containing any mix of xarray and plain jax data.
774
+ It scans along the dimension `dim` for xarray data, and the leading axis for
775
+ any non-xarray data. These scanned-along dimensions must all be consistent in
776
+ size.
777
+
778
+ Static coordinates along `dim` in the `xs` will not be present on the `x`
779
+ argument to `f`, since they would be different on each iteration and we can't
780
+ pass them through as static data. jax_coords will be passed through correctly
781
+ however.
782
+
783
+ Static coordinates along `dim` on the `xs` will also not be present on the
784
+ resulting `ys`, you will need to copy these across yourself if desired.
785
+ (This one we may be able to fix in future.)
786
+
787
+ Args:
788
+ f: Function to apply at each step of the scan. This should map
789
+ (carry, x) -> (carry, y), where `x` is a slice of the `xs` along the `dim`
790
+ axis (with the `dim` axis and any coordinates using it dropped), and `y`
791
+ is a slice of the desired output along the `dim` axis, with no `dim` axis
792
+ itself.
793
+ x, y and carry can in general be trees, in which the above applies to
794
+ each leaf of the tree.
795
+ init: Initial value of the carry.
796
+ dim: The xarray dimension name to scan along, for xarray data.
797
+ xs: The input to be scanned. If not provided, will scan over `length`
798
+ iterations with None passed as the `x` argument to `f`.
799
+ length: The length of the scan, if `xs` are not provided.
800
+ reverse: Whether to scan in reverse order.
801
+ unroll: How many steps to unroll the scan, see jax.lax.scan.
802
+
803
+ Returns:
804
+ final_carry: The carry returned from the final step of the scan.
805
+ ys: Data corresponding to the `y` returned from `f` on each step of the
806
+ scan, concatenated along an extra leading dimension (named `dim` for
807
+ xarray data).
808
+ """
809
+ if xs is not None:
810
+ # Ensure `dim` is the leading axis on any xarray data in the `xs`. This is
811
+ # what jax.lax.scan will scan over. (Any non-array data it's on you to put
812
+ # the relevant axis first).
813
+ xs = tree_map_variables(lambda v: v.transpose(dim, ...), xs)
814
+ xs_leaves, xs_treedef = jax.tree.flatten(xs)
815
+ else:
816
+ xs_treedef = None
817
+ xs_leaves = None
818
+
819
+ y_treedef = None
820
+
821
+ def scan_fn(carry, x_leaves):
822
+ if x_leaves is None:
823
+ x = None
824
+ else:
825
+ with dims_change_on_unflatten(lambda dims: dims[1:]):
826
+ x = jax.tree.unflatten(xs_treedef, x_leaves)
827
+ carry, y = f(carry, x)
828
+
829
+ nonlocal y_treedef
830
+ y_leaves, y_treedef = jax.tree.flatten(y)
831
+ return carry, y_leaves
832
+
833
+ final_carry, ys_leaves = jax.lax.scan(
834
+ scan_fn,
835
+ init,
836
+ xs_leaves,
837
+ length=length,
838
+ reverse=reverse,
839
+ unroll=unroll)
840
+
841
+ assert isinstance(y_treedef, jax.tree_util.PyTreeDef)
842
+
843
+ with dims_change_on_unflatten(lambda dims: (dim,) + dims):
844
+ ys = jax.tree.unflatten(y_treedef, ys_leaves)
845
+
846
+ return final_carry, ys
847
+
848
+
849
+ # Register xarray datatypes with jax.tree_util.
850
+
851
+
852
+ DimsChangeFn = Callable[[Tuple[Hashable, ...]], Tuple[Hashable, ...]]
853
+ _DIMS_CHANGE_ON_UNFLATTEN_FN: contextvars.ContextVar[DimsChangeFn] = (
854
+ contextvars.ContextVar('dims_change_on_unflatten_fn'))
855
+
856
+
857
+ @contextlib.contextmanager
858
+ def dims_change_on_unflatten(dims_change_fn: DimsChangeFn):
859
+ """Can be used to change the dims used when unflattening arrays into xarrays.
860
+
861
+ This is useful when some axes were added to / removed from the underlying jax
862
+ arrays after they were flattened using jax.tree_util.tree_flatten, and you
863
+ want to unflatten them again afterwards using the original treedef but
864
+ adjusted for the added/removed dimensions.
865
+
866
+ It can also be used with jax.tree_util.tree_map, when it's called with a
867
+ function that adds/removes axes or otherwise changes the axis order.
868
+
869
+ When dimensions are removed, any coordinates using those removed dimensions
870
+ will also be removed on unflatten.
871
+
872
+ This is implemented as a context manager that sets some thread-local state
873
+ affecting the behaviour of our unflatten functions, because it's not possible
874
+ to directly modify the treedef to change the dims/coords in it (and with
875
+ tree_map, the treedef isn't exposed to you anyway).
876
+
877
+ Args:
878
+ dims_change_fn: Maps a tuple of dimension names for the original
879
+ Variable/DataArray/Dataset that was flattened, to an updated tuple of
880
+ dimensions which should be used when unflattening.
881
+
882
+ Yields:
883
+ To a context manager in whose scope jax.tree_util.tree_unflatten and
884
+ jax.tree_util.tree_map will apply the dims_change_fn before reconstructing
885
+ xarrays from jax arrays.
886
+ """
887
+ token = _DIMS_CHANGE_ON_UNFLATTEN_FN.set(dims_change_fn)
888
+ try:
889
+ yield
890
+ finally:
891
+ _DIMS_CHANGE_ON_UNFLATTEN_FN.reset(token)
892
+
893
+
894
+ def _flatten_variable(v: xarray.Variable) -> Tuple[
895
+ Tuple[jax.typing.ArrayLike], Tuple[Hashable, ...]]: # pylint: disable=g-one-element-tuple
896
+ """Flattens a Variable for jax.tree_util."""
897
+ children = (unwrap_data(v),)
898
+ aux = v.dims
899
+ return children, aux
900
+
901
+
902
+ def _unflatten_variable(
903
+ aux: Tuple[Hashable, ...],
904
+ children: Tuple[jax.typing.ArrayLike]) -> xarray.Variable: # pylint: disable=g-one-element-tuple
905
+ """Unflattens a Variable for jax.tree_util."""
906
+ dims = aux
907
+ dims_change_fn = _DIMS_CHANGE_ON_UNFLATTEN_FN.get(None)
908
+ if dims_change_fn: dims = dims_change_fn(dims)
909
+ return Variable(dims=dims, data=children[0])
910
+
911
+
912
+ def _split_static_and_jax_coords(
913
+ coords: xarray.core.coordinates.Coordinates) -> Tuple[
914
+ Mapping[Hashable, xarray.Variable], Mapping[Hashable, xarray.Variable]]:
915
+ static_coord_vars = {}
916
+ jax_coord_vars = {}
917
+ for name, coord in coords.items():
918
+ if coord.attrs.get(_JAX_COORD_ATTR_NAME, False):
919
+ jax_coord_vars[name] = coord.variable
920
+ else:
921
+ assert not isinstance(coord, (jax.Array, JaxArrayWrapper))
922
+ static_coord_vars[name] = coord.variable
923
+ return static_coord_vars, jax_coord_vars
924
+
925
+
926
+ def _drop_with_none_of_dims(
927
+ coord_vars: Mapping[Hashable, xarray.Variable],
928
+ dims: Tuple[Hashable, ...]) -> Mapping[Hashable, xarray.Variable]:
929
+ return {name: var for name, var in coord_vars.items()
930
+ if set(var.dims) <= set(dims)}
931
+
932
+
933
+ class _HashableCoords(collections.abc.Mapping):
934
+ """Wraps a dict of xarray Variables as hashable, used for static coordinates.
935
+
936
+ This needs to be hashable so that when an xarray.Dataset is passed to a
937
+ jax.jit'ed function, jax can check whether it's seen an array with the
938
+ same static coordinates(*) before or whether it needs to recompile the
939
+ function for the new values of the static coordinates.
940
+
941
+ (*) note jax_coords are not included in this; their value can be different
942
+ on different calls without triggering a recompile.
943
+ """
944
+
945
+ def __init__(self, coord_vars: Mapping[Hashable, xarray.Variable]):
946
+ self._variables = coord_vars
947
+
948
+ def __repr__(self) -> str:
949
+ return f'_HashableCoords({repr(self._variables)})'
950
+
951
+ def __getitem__(self, key: Hashable) -> xarray.Variable:
952
+ return self._variables[key]
953
+
954
+ def __len__(self) -> int:
955
+ return len(self._variables)
956
+
957
+ def __iter__(self) -> Iterator[Hashable]:
958
+ return iter(self._variables)
959
+
960
+ def __hash__(self):
961
+ if not hasattr(self, '_hash'):
962
+ self._hash = hash(frozenset((name, var.data.tobytes())
963
+ for name, var in self._variables.items()))
964
+ return self._hash
965
+
966
+ def __eq__(self, other):
967
+ if self is other:
968
+ return True
969
+ elif not isinstance(other, type(self)):
970
+ return NotImplemented
971
+ elif self._variables is other._variables:
972
+ return True
973
+ else:
974
+ return self._variables.keys() == other._variables.keys() and all(
975
+ variable.equals(other._variables[name])
976
+ for name, variable in self._variables.items())
977
+
978
+
979
+ def _flatten_data_array(v: xarray.DataArray) -> Tuple[
980
+ # Children (data variable, jax_coord_vars):
981
+ Tuple[xarray.Variable, Mapping[Hashable, xarray.Variable]],
982
+ # Static auxiliary data (name, static_coord_vars):
983
+ Tuple[Optional[Hashable], _HashableCoords]]:
984
+ """Flattens a DataArray for jax.tree_util."""
985
+ static_coord_vars, jax_coord_vars = _split_static_and_jax_coords(v.coords)
986
+ children = (v.variable, jax_coord_vars)
987
+ aux = (v.name, _HashableCoords(static_coord_vars))
988
+ return children, aux
989
+
990
+
991
+ def _unflatten_data_array(
992
+ aux: Tuple[Optional[Hashable], _HashableCoords],
993
+ children: Tuple[xarray.Variable, Mapping[Hashable, xarray.Variable]],
994
+ ) -> xarray.DataArray:
995
+ """Unflattens a DataArray for jax.tree_util."""
996
+ variable, jax_coord_vars = children
997
+ name, static_coord_vars = aux
998
+ if _DIMS_CHANGE_ON_UNFLATTEN_FN.get(None):
999
+ # Drop static coords which have dims not present in any of the data_vars.
1000
+ # These would generally be dims that were dropped by a dims_change_fn, but
1001
+ # because static coordinates don't go through dims_change_fn on unflatten,
1002
+ # we just drop them where this causes a problem.
1003
+ # Since jax_coords go through the dims_change_fn on unflatten we don't need
1004
+ # to do this for jax_coords.
1005
+ static_coord_vars = _drop_with_none_of_dims(
1006
+ static_coord_vars, variable.dims)
1007
+ return DataArray(
1008
+ variable, name=name, coords=static_coord_vars, jax_coords=jax_coord_vars)
1009
+
1010
+
1011
+ def _flatten_dataset(dataset: xarray.Dataset) -> Tuple[
1012
+ # Children (data variables, jax_coord_vars):
1013
+ Tuple[Mapping[Hashable, xarray.Variable],
1014
+ Mapping[Hashable, xarray.Variable]],
1015
+ # Static auxiliary data (static_coord_vars):
1016
+ _HashableCoords]:
1017
+ """Flattens a Dataset for jax.tree_util."""
1018
+ variables = {name: data_array.variable
1019
+ for name, data_array in dataset.data_vars.items()}
1020
+ static_coord_vars, jax_coord_vars = _split_static_and_jax_coords(
1021
+ dataset.coords)
1022
+ children = (variables, jax_coord_vars)
1023
+ aux = _HashableCoords(static_coord_vars)
1024
+ return children, aux
1025
+
1026
+
1027
+ def _unflatten_dataset(
1028
+ aux: _HashableCoords,
1029
+ children: Tuple[Mapping[Hashable, xarray.Variable],
1030
+ Mapping[Hashable, xarray.Variable]],
1031
+ ) -> xarray.Dataset:
1032
+ """Unflattens a Dataset for jax.tree_util."""
1033
+ data_vars, jax_coord_vars = children
1034
+ static_coord_vars = aux
1035
+ dataset = xarray.Dataset(data_vars)
1036
+ if _DIMS_CHANGE_ON_UNFLATTEN_FN.get(None):
1037
+ # Drop static coords which have dims not present in any of the data_vars.
1038
+ # See corresponding comment in _unflatten_data_array.
1039
+ static_coord_vars = _drop_with_none_of_dims(
1040
+ static_coord_vars, dataset.dims) # pytype: disable=wrong-arg-types
1041
+ return assign_coords(
1042
+ dataset, coords=static_coord_vars, jax_coords=jax_coord_vars)
1043
+
1044
+
1045
+ def _flatten_datatree(datatree: xarray.DataTree) -> Tuple[
1046
+ Tuple[Mapping[str, xarray.DataTree], xarray.Dataset], str | None]:
1047
+ """Flattens a DataTree for jax.tree_util."""
1048
+ # For simplicity we assume DataTrees will be flattened/unflattened from the
1049
+ # root. If you give it a non-root-node, it will still work but any parents
1050
+ # (and any coordinates inherited from them) will be lost.
1051
+ node_dataset = datatree.to_dataset(inherit=False)
1052
+ children = (dict(datatree.children), node_dataset)
1053
+ aux = datatree.name
1054
+ return children, aux
1055
+
1056
+
1057
+ def _unflatten_datatree(
1058
+ aux: str | None,
1059
+ children: Tuple[Mapping[str, xarray.DataTree], xarray.Dataset],
1060
+ ) -> xarray.DataTree:
1061
+ """Unflattens a DataTree for jax.tree_util."""
1062
+ children_dict, node_dataset = children
1063
+ name = aux
1064
+ return xarray.DataTree(
1065
+ dataset=node_dataset, children=children_dict, name=name)
1066
+
1067
+
1068
+ jax.tree_util.register_pytree_node(
1069
+ xarray.Variable, _flatten_variable, _unflatten_variable)
1070
+ # This is a subclass of Variable but still needs registering separately.
1071
+ # Flatten/unflatten for IndexVariable is a bit of a corner case but we do
1072
+ # need to support it.
1073
+ jax.tree_util.register_pytree_node(
1074
+ xarray.IndexVariable, _flatten_variable, _unflatten_variable)
1075
+ jax.tree_util.register_pytree_node(
1076
+ xarray.DataArray, _flatten_data_array, _unflatten_data_array)
1077
+ jax.tree_util.register_pytree_node(
1078
+ xarray.Dataset, _flatten_dataset, _unflatten_dataset)
1079
+ jax.tree_util.register_pytree_node(
1080
+ xarray.DataTree, _flatten_datatree, _unflatten_datatree)
model/graphcast/xarray_tree.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2023 DeepMind Technologies Limited.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS-IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Utilities for working with trees of xarray.DataArray (including Datasets).
15
+
16
+ Note that xarray.Dataset doesn't work out-of-the-box with the `tree` library;
17
+ it won't work as a leaf node since it implements Mapping, but also won't work
18
+ as an internal node since tree doesn't know how to re-create it properly.
19
+
20
+ To fix this, we reimplement a subset of `map_structure`, exposing its
21
+ constituent DataArrays as leaf nodes. This means it can be mapped over as a
22
+ generic container of DataArrays, while still preserving the result as a Dataset
23
+ where possible.
24
+
25
+ This is useful because in a few places we need to handle a general
26
+ Mapping[str, DataArray] (where the coordinates might not be compatible across
27
+ the constituent DataArrays) but also the special case of a Dataset nicely.
28
+
29
+ For the result e.g. of a tree.map_structure(fn, dataset), if fn returns None for
30
+ some of the child DataArrays, they will be omitted from the returned dataset. If
31
+ any values other than DataArrays or None are returned, then we don't attempt to
32
+ return a Dataset and just return a plain dict of the results. Similarly if
33
+ DataArrays are returned but with non-matching coordinates, it will just return a
34
+ plain dict of DataArrays.
35
+
36
+ Note xarray datatypes are registered with `jax.tree_util` by xarray_jax.py,
37
+ but `jax.tree_util.tree_map` is distinct from the `xarray_tree.map_structure`.
38
+ as the former exposes the underlying JAX/numpy arrays as leaf nodes, while the
39
+ latter exposes DataArrays as leaf nodes.
40
+ """
41
+
42
+ from typing import Any, Callable
43
+
44
+ import xarray
45
+
46
+
47
+ def map_structure(func: Callable[..., Any], *structures: Any) -> Any:
48
+ """Maps func through given structures with xarrays. See tree.map_structure."""
49
+ if not callable(func):
50
+ raise TypeError(f'func must be callable, got: {func}')
51
+ if not structures:
52
+ raise ValueError('Must provide at least one structure')
53
+
54
+ first = structures[0]
55
+ if isinstance(first, xarray.Dataset):
56
+ data = {k: func(*[s[k] for s in structures]) for k in first.keys()}
57
+ if all(isinstance(a, (type(None), xarray.DataArray))
58
+ for a in data.values()):
59
+ data_arrays = [v.rename(k) for k, v in data.items() if v is not None]
60
+ try:
61
+ return xarray.merge(data_arrays, join='exact')
62
+ except ValueError: # Exact join not possible.
63
+ pass
64
+ return data
65
+ if isinstance(first, dict):
66
+ return {k: map_structure(func, *[s[k] for s in structures])
67
+ for k in first.keys()}
68
+ if isinstance(first, (list, tuple, set)):
69
+ return type(first)(map_structure(func, *s) for s in zip(*structures))
70
+ return func(*structures)
scripts/fake_data.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """生成确定性的 ERA5、GenCast 统计量和静态场。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import h5py
11
+ import numpy as np
12
+ import xarray
13
+ import yaml
14
+
15
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
16
+ sys.path.insert(0, str(PROJECT_ROOT))
17
+
18
+
19
+ PRESSURE_LEVELS = (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)
20
+ SURFACE = (
21
+ "2m_temperature", "mean_sea_level_pressure", "10m_v_component_of_wind",
22
+ "10m_u_component_of_wind", "sea_surface_temperature", "total_precipitation",
23
+ )
24
+ ATMOSPHERIC = (
25
+ "temperature", "geopotential", "u_component_of_wind",
26
+ "v_component_of_wind", "vertical_velocity", "specific_humidity",
27
+ )
28
+
29
+
30
+ def parse_args() -> argparse.Namespace:
31
+ parser = argparse.ArgumentParser(description=__doc__)
32
+ parser.add_argument("--config", default=str(PROJECT_ROOT / "conf/config.yaml"))
33
+ parser.add_argument("--height", type=int)
34
+ parser.add_argument("--width", type=int)
35
+ parser.add_argument("--timesteps", type=int)
36
+ parser.add_argument("--seed", type=int, default=42)
37
+ return parser.parse_args()
38
+
39
+
40
+ def _stat_dataset(value: float, *, minimum: bool = False) -> xarray.Dataset:
41
+ data_vars = {}
42
+ surface_targets = (
43
+ "2m_temperature", "mean_sea_level_pressure", "10m_v_component_of_wind",
44
+ "10m_u_component_of_wind", "sea_surface_temperature", "total_precipitation_12hr",
45
+ )
46
+ for name in surface_targets:
47
+ scalar = -3.0 if minimum and name == "sea_surface_temperature" else value
48
+ data_vars[name] = xarray.DataArray(np.float32(scalar))
49
+ for name in ATMOSPHERIC:
50
+ data_vars[name] = xarray.DataArray(
51
+ np.full(len(PRESSURE_LEVELS), value, dtype=np.float32),
52
+ dims=("level",),
53
+ coords={"level": np.asarray(PRESSURE_LEVELS, dtype=np.int32)},
54
+ )
55
+ # Generated forcings already in [-1, 1] — include so normalization is silent
56
+ for name in ("year_progress_sin", "year_progress_cos",
57
+ "day_progress_sin", "day_progress_cos"):
58
+ data_vars[name] = xarray.DataArray(np.float32(value))
59
+ for name in ("geopotential_at_surface", "land_sea_mask"):
60
+ data_vars[name] = xarray.DataArray(np.float32(value))
61
+ return xarray.Dataset(data_vars)
62
+
63
+
64
+ def main() -> None:
65
+ args = parse_args()
66
+ with Path(args.config).open(encoding="utf-8") as source:
67
+ config = yaml.safe_load(source)
68
+ root = PROJECT_ROOT / config["data"]["data_dir"]
69
+ height = int(args.height or config["fake_data"]["height"])
70
+ width = int(args.width or config["fake_data"]["width"])
71
+ timesteps = int(args.timesteps or config["fake_data"]["timesteps"])
72
+ if width != 2 * (height - 1):
73
+ raise ValueError("Synthetic GenCast grid must satisfy width=2*(height-1)")
74
+ variables = list(SURFACE) + [f"{name}_{level}" for name in ATMOSPHERIC for level in PRESSURE_LEVELS]
75
+ years = sorted(set(config["data"]["train_years"] + config["data"]["test_years"]))
76
+ rng = np.random.default_rng(args.seed)
77
+ (root / "data").mkdir(parents=True, exist_ok=True)
78
+ for year in years:
79
+ path = root / "data" / f"{year}.h5"
80
+ time = np.arange(timesteps, dtype=np.float32)[:, None, None, None]
81
+ channel = np.arange(len(variables), dtype=np.float32)[None, :, None, None]
82
+ lat = np.linspace(1.0, -1.0, height, dtype=np.float32)[None, None, :, None]
83
+ lon = np.linspace(0.0, 2.0 * np.pi, width, endpoint=False, dtype=np.float32)[None, None, None, :]
84
+ values = 0.01 * time + 0.001 * channel + 0.1 * lat + 0.05 * np.sin(lon)
85
+ values += rng.normal(0.0, 1e-4, values.shape).astype(np.float32)
86
+ precip_index = variables.index("total_precipitation")
87
+ values[:, precip_index] = np.maximum(values[:, precip_index], 0.0)
88
+ sst_index = variables.index("sea_surface_temperature")
89
+ values[:, sst_index, : height // 4] = np.nan
90
+ with h5py.File(path, "w") as output:
91
+ fields = output.create_dataset("fields", data=values, chunks=(1, len(variables), height, width))
92
+ fields.attrs["variables"] = variables
93
+ fields.attrs["time_step"] = 6
94
+ print(f"Generated {path} shape={values.shape}")
95
+
96
+ static_dir = root / "static"
97
+ static_dir.mkdir(parents=True, exist_ok=True)
98
+ np.save(
99
+ static_dir / "geopotential_at_surface.npy",
100
+ np.zeros((height, width), dtype=np.float32),
101
+ )
102
+ land = np.zeros((height, width), dtype=np.float32)
103
+ land[: height // 4] = 1.0
104
+ np.save(static_dir / "land_mask.npy", land)
105
+ stats_dir = root / "stats"
106
+ stats_dir.mkdir(parents=True, exist_ok=True)
107
+ _stat_dataset(0.0).to_netcdf(stats_dir / "mean_by_level.nc")
108
+ _stat_dataset(1.0).to_netcdf(stats_dir / "stddev_by_level.nc")
109
+ _stat_dataset(1.0).to_netcdf(stats_dir / "diffs_stddev_by_level.nc")
110
+ _stat_dataset(0.0, minimum=True).to_netcdf(stats_dir / "min_by_level.nc")
111
+ print(f"Generated GenCast statistics and static fields under {root}")
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()
scripts/inference.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """使用官方 GenCast DPM-Solver++ 执行集合自回归推理。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ import warnings
9
+ from pathlib import Path
10
+
11
+ warnings.filterwarnings("ignore", message="Changing the sparsity structure")
12
+
13
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
14
+ sys.path.insert(0, str(PROJECT_ROOT))
15
+
16
+ from model.common import configure_jax, load_config, load_stats, resolve_path
17
+
18
+
19
+ def parse_args() -> argparse.Namespace:
20
+ parser = argparse.ArgumentParser(description=__doc__)
21
+ parser.add_argument("--config", default=str(PROJECT_ROOT / "conf/config.yaml"))
22
+ parser.add_argument("--checkpoint")
23
+ parser.add_argument("--sample-index", type=int, default=0)
24
+ parser.add_argument("--num-members", type=int)
25
+ parser.add_argument("--prediction-steps", type=int)
26
+ parser.add_argument("--output")
27
+ return parser.parse_args()
28
+
29
+
30
+ def main() -> None:
31
+ args = parse_args()
32
+ config = load_config(args.config)
33
+ configure_jax(config["runtime"].get("platform", "auto"))
34
+
35
+ import jax
36
+ import numpy as np
37
+ import xarray
38
+
39
+ from model.graphcast import rollout
40
+ from model.gencast import GenCastModel, load_model_checkpoint
41
+ from model.common import (
42
+ load_trainer_checkpoint, validate_checkpoint_config,
43
+ )
44
+ from model.data_loader import GenCastERA5Dataset
45
+
46
+ prediction_steps = int(args.prediction_steps or config["inference"]["prediction_steps"])
47
+ num_members = int(args.num_members or config["inference"]["num_members"])
48
+ stats = load_stats(config["data"]["stats_dir"])
49
+ checkpoint_path = args.checkpoint or config["inference"].get("official_checkpoint")
50
+ if checkpoint_path:
51
+ official = load_model_checkpoint(resolve_path(checkpoint_path))
52
+ model = GenCastModel.from_checkpoint_and_stats(
53
+ official,
54
+ stats,
55
+ attention_type=config["inference"].get("attention_type_override"),
56
+ )
57
+ params, state = official.params, {}
58
+ task_config = official.task_config
59
+ else:
60
+ model = GenCastModel.from_config_and_stats(config, stats)
61
+ params, state, _, _, saved_config = load_trainer_checkpoint(
62
+ config["checkpoint"]["trainer"]
63
+ )
64
+ validate_checkpoint_config(config, saved_config, scope="inference")
65
+ task_config = model.task_config
66
+
67
+ dataset = GenCastERA5Dataset(
68
+ resolve_path(config["data"]["data_dir"]),
69
+ list(config["data"]["test_years"]),
70
+ static_dir=resolve_path(config["data"]["static_dir"]),
71
+ prediction_steps=prediction_steps,
72
+ stride=int(config["data"].get("test_stride", 1)),
73
+ task_config=task_config,
74
+ precipitation_interval_hours=int(
75
+ config["data"]["precipitation_interval_hours"]
76
+ ),
77
+ load_future_targets=False,
78
+ )
79
+ inputs, targets, forcings = dataset[args.sample_index]
80
+
81
+ def forward(rng, inputs, targets_template, forcings):
82
+ return model.predict(
83
+ params, state, rng, inputs, targets_template, forcings
84
+ )[0]
85
+
86
+ forward = jax.jit(forward)
87
+ seed = int(config["inference"]["seed"])
88
+ rngs = np.stack([jax.random.fold_in(jax.random.PRNGKey(seed), i) for i in range(num_members)])
89
+ chunks = rollout.chunked_prediction_generator_multiple_runs(
90
+ predictor_fn=forward,
91
+ rngs=rngs,
92
+ inputs=inputs,
93
+ targets_template=targets * np.nan,
94
+ forcings=forcings,
95
+ num_steps_per_chunk=1,
96
+ num_samples=num_members,
97
+ pmap_devices=None,
98
+ )
99
+ output = resolve_path(args.output or config["output"]["prediction"])
100
+ if bool(config["inference"].get("stream_chunks", True)):
101
+ output_dir = output.with_suffix("")
102
+ output_dir.mkdir(parents=True, exist_ok=True)
103
+ for chunk_index, chunk in enumerate(chunks):
104
+ host_chunk = jax.device_get(chunk)
105
+ member = int(host_chunk.coords["sample"])
106
+ lead = int(host_chunk.time.values[0] / np.timedelta64(1, "h"))
107
+ host_chunk = host_chunk.drop_vars("sample").assign_coords(time=[lead])
108
+ host_chunk.coords["time"].attrs = {"long_name": "forecast lead time hours"}
109
+ host_chunk.attrs.update(
110
+ model="GenCast", target_channel_count=84,
111
+ forecast_reference_time=inputs.attrs["forecast_reference_time"],
112
+ )
113
+ path = output_dir / f"member_{member:03d}_lead_{lead:04d}h.nc"
114
+ host_chunk.to_netcdf(path)
115
+ print(f"Saved prediction chunk to {path}")
116
+ return
117
+
118
+ chunks = list(chunks)
119
+ member_chunks: list[list[xarray.Dataset]] = [[] for _ in range(num_members)]
120
+ for chunk in chunks:
121
+ host_chunk = jax.device_get(chunk)
122
+ member = int(host_chunk.coords["sample"])
123
+ member_chunks[member].append(host_chunk.drop_vars("sample"))
124
+ members = [
125
+ xarray.concat(parts, dim="time").expand_dims(sample=[member])
126
+ for member, parts in enumerate(member_chunks)
127
+ ]
128
+ predictions = xarray.concat(members, dim="sample")
129
+ predictions.attrs.update(
130
+ model="GenCast",
131
+ target_channel_count=84,
132
+ ensemble_members=num_members,
133
+ step_hours=12,
134
+ forecast_reference_time=inputs.attrs["forecast_reference_time"],
135
+ )
136
+ # Store lead time as plain hours; xarray_jax's internal dtype attribute is
137
+ # not valid CF metadata and conflicts with decoding after NetCDF round-trip.
138
+ lead_hours = (
139
+ predictions.coords["time"].values / np.timedelta64(1, "h")
140
+ ).astype(np.int32)
141
+ predictions = predictions.assign_coords(time=("time", lead_hours))
142
+ predictions.coords["time"].attrs = {
143
+ "long_name": "forecast lead time",
144
+ "units": "hours",
145
+ }
146
+ reference_time = np.datetime64(inputs.attrs["forecast_reference_time"])
147
+ predictions = predictions.assign_coords(
148
+ valid_time=("time", reference_time + lead_hours.astype("timedelta64[h]"))
149
+ )
150
+ output.parent.mkdir(parents=True, exist_ok=True)
151
+ temporary = output.with_suffix(output.suffix + ".tmp")
152
+ predictions.to_netcdf(temporary)
153
+ temporary.replace(output)
154
+ print(f"Saved predictions to {output}")
155
+
156
+
157
+ if __name__ == "__main__":
158
+ main()
scripts/result.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """可视化 GenCast 集合预报结果。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ import warnings
9
+ from pathlib import Path
10
+
11
+ import matplotlib.pyplot as plt
12
+ import numpy as np
13
+ import xarray
14
+
15
+ warnings.filterwarnings("ignore", message="Changing the sparsity structure")
16
+
17
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
18
+ sys.path.insert(0, str(PROJECT_ROOT))
19
+
20
+ from model.common import load_config, resolve_path
21
+
22
+
23
+ def load_predictions(path: Path) -> xarray.Dataset:
24
+ """Load predictions from either a single NetCDF or a chunked directory."""
25
+ # Auto-detect: if path.nc doesn't exist but path/ directory does, use chunks
26
+ if not path.exists() and path.suffix == ".nc":
27
+ chunk_dir = path.with_suffix("")
28
+ if chunk_dir.is_dir():
29
+ path = chunk_dir
30
+ if path.is_dir():
31
+ files = sorted(path.glob("member_*_lead_*.nc"))
32
+ if not files:
33
+ raise FileNotFoundError(f"No prediction chunks found in {path}")
34
+ datasets = [xarray.load_dataset(f) for f in files]
35
+ members = sorted(set(int(f.stem.split("_")[1]) for f in files))
36
+ member_parts = []
37
+ for m in members:
38
+ member_ds = [ds for ds, f in zip(datasets, files)
39
+ if f"member_{m:03d}_lead_" in str(f)]
40
+ member_parts.append(xarray.concat(member_ds, dim="time"))
41
+ return xarray.concat(member_parts, dim="sample")
42
+ else:
43
+ return xarray.load_dataset(path)
44
+
45
+
46
+ def parse_args() -> argparse.Namespace:
47
+ parser = argparse.ArgumentParser(description=__doc__)
48
+ parser.add_argument("--config", default=str(PROJECT_ROOT / "conf/config.yaml"))
49
+ parser.add_argument("--prediction")
50
+ parser.add_argument("--variable", default="2m_temperature")
51
+ parser.add_argument("--output")
52
+ parser.add_argument("--lead", type=int, default=0, help="lead time index (0-based)")
53
+ return parser.parse_args()
54
+
55
+
56
+ def main() -> None:
57
+ args = parse_args()
58
+ config = load_config(args.config)
59
+ prediction_path = resolve_path(args.prediction or config["output"]["prediction"])
60
+ output_path = resolve_path(args.output or config["output"]["plot"])
61
+ prediction = load_predictions(prediction_path)[args.variable]
62
+
63
+ sample_dim = "sample" if "sample" in prediction.dims else None
64
+ mean = prediction.mean(sample_dim) if sample_dim else prediction
65
+ spread = prediction.std(sample_dim) if sample_dim else xarray.zeros_like(mean)
66
+
67
+ # Select lead time index (0=first, -1=last)
68
+ lead_idx = args.lead
69
+ field = mean.isel(batch=0, time=lead_idx).values
70
+ spread_field = spread.isel(batch=0, time=lead_idx).values
71
+
72
+ fig, axes = plt.subplots(1, 2, figsize=(12, 4), constrained_layout=True)
73
+ im0 = axes[0].imshow(field, origin="lower", cmap="viridis", aspect="auto")
74
+ axes[0].set_title(f"GenCast ensemble mean: {args.variable}")
75
+ fig.colorbar(im0, ax=axes[0], orientation="horizontal")
76
+ im1 = axes[1].imshow(spread_field, origin="lower", cmap="magma", aspect="auto")
77
+ axes[1].set_title("Ensemble spread")
78
+ fig.colorbar(im1, ax=axes[1], orientation="horizontal")
79
+
80
+ output_path.parent.mkdir(parents=True, exist_ok=True)
81
+ fig.savefig(output_path, dpi=180)
82
+ plt.close(fig)
83
+ print(f"Saved plot to {output_path}")
84
+
85
+
86
+ if __name__ == "__main__":
87
+ main()
scripts/train.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """使用官方单步 EDM 去噪目标训练 GenCast。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import itertools
8
+ import sys
9
+ import warnings
10
+ from pathlib import Path
11
+
12
+ import xarray
13
+
14
+ # Mesh adjacency construction triggers one-time scipy CSR restructure warning.
15
+ warnings.filterwarnings("ignore", message="Changing the sparsity structure")
16
+
17
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
18
+ sys.path.insert(0, str(PROJECT_ROOT))
19
+
20
+ from model.common import configure_jax, load_config, load_stats, resolve_path
21
+
22
+
23
+ def parse_args() -> argparse.Namespace:
24
+ parser = argparse.ArgumentParser(description=__doc__)
25
+ parser.add_argument("--config", default=str(PROJECT_ROOT / "conf/config.yaml"))
26
+ parser.add_argument("--max-steps", type=int)
27
+ parser.add_argument("--resume")
28
+ parser.add_argument("--parallel-mode", choices=("single", "pmap"))
29
+ parser.add_argument("--num-devices", type=int)
30
+ parser.add_argument("--global-batch-size", type=int)
31
+ parser.add_argument("--checkpoint")
32
+ parser.add_argument("--seed", type=int)
33
+ return parser.parse_args()
34
+
35
+
36
+ def _adam_init(params):
37
+ import jax
38
+ import jax.numpy as jnp
39
+
40
+ zeros = jax.tree_util.tree_map(jnp.zeros_like, params)
41
+ return {"count": jnp.asarray(0, dtype=jnp.int32), "mu": zeros, "nu": zeros}
42
+
43
+
44
+ def _adam_update(params, grads, state, learning_rate, beta1, beta2, eps):
45
+ import jax
46
+ import jax.numpy as jnp
47
+
48
+ count = state["count"] + 1
49
+ mu = jax.tree_util.tree_map(
50
+ lambda old, grad: beta1 * old + (1.0 - beta1) * grad,
51
+ state["mu"], grads,
52
+ )
53
+ nu = jax.tree_util.tree_map(
54
+ lambda old, grad: beta2 * old + (1.0 - beta2) * jnp.square(grad),
55
+ state["nu"], grads,
56
+ )
57
+ mu_hat = jax.tree_util.tree_map(lambda value: value / (1.0 - beta1**count), mu)
58
+ nu_hat = jax.tree_util.tree_map(lambda value: value / (1.0 - beta2**count), nu)
59
+ params = jax.tree_util.tree_map(
60
+ lambda value, first, second: value - learning_rate * first / (jnp.sqrt(second) + eps),
61
+ params, mu_hat, nu_hat,
62
+ )
63
+ return params, {"count": count, "mu": mu, "nu": nu}
64
+
65
+
66
+ def _replicate(tree, devices):
67
+ import jax
68
+
69
+ return jax.device_put_replicated(tree, devices)
70
+
71
+
72
+ def _unreplicate(tree):
73
+ import jax
74
+
75
+ return jax.tree_util.tree_map(lambda value: value[0], tree)
76
+
77
+
78
+ def _device_batch(batch, device_count):
79
+ """Add a leading device dimension to each GenCast xarray input."""
80
+ result = []
81
+ for value in batch:
82
+ if not isinstance(value, xarray.Dataset):
83
+ raise TypeError("GenCast batches must contain xarray.Dataset values")
84
+ value = value.transpose("batch", ...)
85
+ if "batch" not in value.dims:
86
+ value = value.expand_dims("batch")
87
+ if value.sizes["batch"] % device_count:
88
+ raise ValueError("Batch size must be divisible by the device count")
89
+ local_batch = value.sizes["batch"] // device_count
90
+ shards = [
91
+ value.isel(batch=slice(index * local_batch, (index + 1) * local_batch))
92
+ for index in range(device_count)
93
+ ]
94
+ result.append(xarray.concat(shards, dim="device"))
95
+ return tuple(result)
96
+
97
+
98
+ def main() -> None:
99
+ args = parse_args()
100
+ config = load_config(args.config)
101
+ parallel = config.setdefault("parallel", {})
102
+ if args.parallel_mode is not None:
103
+ parallel["mode"] = args.parallel_mode
104
+ if args.num_devices is not None:
105
+ parallel["num_devices"] = args.num_devices
106
+ if args.global_batch_size is not None:
107
+ parallel["global_batch_size"] = args.global_batch_size
108
+ if args.checkpoint is not None:
109
+ config["checkpoint"]["trainer"] = args.checkpoint
110
+ if args.seed is not None:
111
+ config["training"]["seed"] = args.seed
112
+ configure_jax(config["runtime"].get("platform", "auto"))
113
+
114
+ import jax
115
+ import jax.numpy as jnp
116
+
117
+ from model.gencast import GenCastModel, parameter_count
118
+ from model.common import (
119
+ load_trainer_checkpoint, save_trainer_checkpoint,
120
+ validate_checkpoint_config,
121
+ )
122
+ from model.data_loader import GenCastERA5Dataset, batch_iterator
123
+
124
+ mode = str(parallel.get("mode", "single")).lower()
125
+ if mode not in ("single", "pmap"):
126
+ raise ValueError("parallel.mode must be 'single' or 'pmap'")
127
+ devices = list(jax.local_devices())
128
+ requested_devices = int(parallel.get("num_devices", 1))
129
+ if requested_devices < 1:
130
+ raise ValueError("parallel.num_devices must be positive")
131
+ if mode == "pmap":
132
+ if requested_devices > len(devices):
133
+ raise ValueError(
134
+ f"Requested {requested_devices} devices, only {len(devices)} available"
135
+ )
136
+ devices = devices[:requested_devices]
137
+ else:
138
+ requested_devices = 1
139
+ devices = devices[:1]
140
+ global_batch_size = int(parallel.get("global_batch_size", requested_devices))
141
+ if global_batch_size < 1 or global_batch_size % requested_devices:
142
+ raise ValueError("global_batch_size must be divisible by the device count")
143
+ stats = load_stats(config["data"]["stats_dir"])
144
+ model = GenCastModel.from_config_and_stats(config, stats)
145
+ dataset = GenCastERA5Dataset(
146
+ resolve_path(config["data"]["data_dir"]),
147
+ list(config["data"]["train_years"]),
148
+ static_dir=resolve_path(config["data"]["static_dir"]),
149
+ prediction_steps=1,
150
+ stride=int(config["data"].get("train_stride", 1)),
151
+ precipitation_interval_hours=int(
152
+ config["data"]["precipitation_interval_hours"]
153
+ ),
154
+ )
155
+ first_batch = dataset[0]
156
+ seed = int(config["training"]["seed"])
157
+ start_step = 0
158
+ resume = args.resume or config["checkpoint"].get("resume")
159
+ if resume:
160
+ params, state, optimizer_state, start_step, saved_config = \
161
+ load_trainer_checkpoint(resume)
162
+ validate_checkpoint_config(config, saved_config)
163
+ else:
164
+ params, state = model.init(
165
+ jax.random.fold_in(jax.random.PRNGKey(seed), -1), *first_batch
166
+ )
167
+ optimizer_state = _adam_init(params)
168
+
169
+ learning_rate = float(config["training"]["learning_rate"])
170
+ beta1, beta2 = (float(value) for value in config["training"]["betas"])
171
+ epsilon = float(config["training"].get("epsilon", 1e-8))
172
+
173
+ def train_step(params, state, optimizer_state, rng, inputs, targets, forcings):
174
+ def objective(current_params, current_state):
175
+ (loss, diagnostics), next_state = model.loss(
176
+ current_params, current_state, rng, inputs, targets, forcings
177
+ )
178
+ return loss, (diagnostics, next_state)
179
+
180
+ (loss, (diagnostics, next_state)), grads = jax.value_and_grad(
181
+ objective, has_aux=True
182
+ )(params, state)
183
+ finite = jnp.logical_and(
184
+ jnp.isfinite(loss),
185
+ jnp.all(jnp.asarray([jnp.all(jnp.isfinite(x)) for x in jax.tree_util.tree_leaves(grads)])),
186
+ )
187
+ new_params, new_optimizer_state = _adam_update(
188
+ params, grads, optimizer_state, learning_rate, beta1, beta2, epsilon
189
+ )
190
+ params = jax.tree_util.tree_map(
191
+ lambda new, old: jnp.where(finite, new, old), new_params, params
192
+ )
193
+ next_state = jax.tree_util.tree_map(
194
+ lambda new, old: jnp.where(finite, new, old), next_state, state
195
+ )
196
+ new_optimizer_state = jax.tree_util.tree_map(
197
+ lambda new, old: jnp.where(finite, new, old),
198
+ new_optimizer_state,
199
+ optimizer_state,
200
+ )
201
+ return params, next_state, new_optimizer_state, loss, diagnostics, finite
202
+
203
+ if mode == "pmap":
204
+ axis_name = str(parallel.get("axis_name", "devices"))
205
+
206
+ def parallel_train_step(
207
+ params, state, optimizer_state, rng, inputs, targets, forcings
208
+ ):
209
+ rng = jax.random.fold_in(rng, jax.lax.axis_index(axis_name))
210
+
211
+ def objective(current_params, current_state):
212
+ (loss, diagnostics), next_state = model.loss(
213
+ current_params, current_state, rng, inputs, targets, forcings
214
+ )
215
+ return loss, (diagnostics, next_state)
216
+
217
+ (loss, (diagnostics, next_state)), grads = jax.value_and_grad(
218
+ objective, has_aux=True
219
+ )(params, state)
220
+ grads = jax.lax.pmean(grads, axis_name)
221
+ loss = jax.lax.pmean(loss, axis_name)
222
+ diagnostics = jax.tree_util.tree_map(
223
+ lambda value: jax.lax.pmean(value, axis_name), diagnostics
224
+ )
225
+ next_state = jax.tree_util.tree_map(
226
+ lambda value: jax.lax.pmean(value, axis_name), next_state
227
+ )
228
+ finite = jnp.logical_and(
229
+ jnp.isfinite(loss),
230
+ jnp.all(jnp.asarray([
231
+ jnp.all(jnp.isfinite(x))
232
+ for x in jax.tree_util.tree_leaves(grads)
233
+ ])),
234
+ )
235
+ finite = jax.lax.pmin(finite, axis_name)
236
+ new_params, new_optimizer_state = _adam_update(
237
+ params, grads, optimizer_state, learning_rate, beta1, beta2, epsilon
238
+ )
239
+ params = jax.tree_util.tree_map(
240
+ lambda new, old: jnp.where(finite, new, old), new_params, params
241
+ )
242
+ next_state = jax.tree_util.tree_map(
243
+ lambda new, old: jnp.where(finite, new, old), next_state, state
244
+ )
245
+ new_optimizer_state = jax.tree_util.tree_map(
246
+ lambda new, old: jnp.where(finite, new, old),
247
+ new_optimizer_state,
248
+ optimizer_state,
249
+ )
250
+ return params, next_state, new_optimizer_state, loss, diagnostics, finite
251
+
252
+ from model.graphcast import xarray_jax
253
+
254
+ train_step = xarray_jax.pmap(
255
+ parallel_train_step, dim="device", axis_name=axis_name, devices=devices
256
+ )
257
+ else:
258
+ train_step = jax.jit(train_step)
259
+ max_steps = int(args.max_steps or config["training"]["max_steps"])
260
+ save_interval = int(config["training"].get("save_interval", max_steps))
261
+ checkpoint_path = config["checkpoint"]["trainer"]
262
+ print(f"Training samples: {len(dataset)}; parameters: {parameter_count(params):,}")
263
+ if mode == "pmap":
264
+ params = _replicate(params, devices)
265
+ state = _replicate(state, devices)
266
+ optimizer_state = _replicate(optimizer_state, devices)
267
+ print(
268
+ f"Parallel mode: pmap; devices: {requested_devices}; "
269
+ f"global batch: {global_batch_size}"
270
+ )
271
+
272
+ step = start_step
273
+ batches_per_epoch = len(dataset) // global_batch_size
274
+ if batches_per_epoch < 1:
275
+ raise ValueError(
276
+ f"Dataset has {len(dataset)} samples, fewer than global_batch_size "
277
+ f"{global_batch_size}"
278
+ )
279
+ while step < max_steps:
280
+ epoch = step // batches_per_epoch
281
+ offset = step % batches_per_epoch
282
+ epoch_batches = batch_iterator(
283
+ dataset,
284
+ shuffle=True,
285
+ seed=seed + epoch,
286
+ batch_size=global_batch_size,
287
+ )
288
+ for batch in itertools.islice(epoch_batches, offset, None):
289
+ if step >= max_steps:
290
+ break
291
+ step_rng = jax.random.fold_in(jax.random.PRNGKey(seed), step)
292
+ if mode == "pmap":
293
+ batch = _device_batch(batch, requested_devices)
294
+ step_rng = jax.numpy.broadcast_to(
295
+ step_rng, (requested_devices, *step_rng.shape)
296
+ )
297
+ params, state, optimizer_state, loss, _, finite = train_step(
298
+ params, state, optimizer_state, step_rng, *batch
299
+ )
300
+ loss, finite = loss[0], finite[0]
301
+ else:
302
+ params, state, optimizer_state, loss, _, finite = train_step(
303
+ params, state, optimizer_state, step_rng, *batch
304
+ )
305
+ step += 1
306
+ print(f"step={step} loss={float(loss):.8f} finite={bool(finite)}")
307
+ if not bool(finite):
308
+ raise FloatingPointError(f"Non-finite GenCast loss at step {step}")
309
+ if step % save_interval == 0 or step == max_steps:
310
+ checkpoint_trees = (params, state, optimizer_state)
311
+ if mode == "pmap":
312
+ checkpoint_trees = tuple(map(_unreplicate, checkpoint_trees))
313
+ save_trainer_checkpoint(
314
+ checkpoint_path,
315
+ params=checkpoint_trees[0],
316
+ state=checkpoint_trees[1],
317
+ optimizer_state=checkpoint_trees[2],
318
+ step=step,
319
+ config=config,
320
+ )
321
+ print(f"Saved checkpoint to {resolve_path(checkpoint_path)}")
322
+
323
+
324
+ if __name__ == "__main__":
325
+ main()
weight/.gitkeep ADDED
File without changes